Octelium documentation · Latest

Plugins

MCP plugins apply controls and mutations on a per-request basis. Every plugin requires a unique name and a Condition:

mcp: plugins: - name: plugin-name isDisabled: false condition: matchAny: true # The plugin type is set here

isDisabled turns a plugin off without removing it. condition is evaluated against the request context even for a response plugin. The response itself is not available to a condition.

The MCP-specific guardrail plugin always runs in the POST_AUTH phase. Setting phase: PRE_AUTH for it is rejected. Generic HTTP plugins use POST_AUTH by default and can explicitly use PRE_AUTH.

Guardrail

An MCP guardrail inspects the content exchanged with an MCP server. It addresses two common boundaries:

  • Tool arguments are downstream-controlled content sent to a server and can leak credentials or personal information.

  • Tool results, resources, prompts and tool definitions are server-controlled content sent to an agent and can carry indirect prompt injection or tool poisoning.

A guardrail is a content control. Policies continue to govern the identity, method and target of a request, while protocol and limits govern its shape.

Request Guardrail

The following plugin denies detected credentials and strips email addresses from tool arguments:

mcp: plugins: - name: protect-tool-arguments condition: match: ctx.request.mcp.method == "tools/call" guardrail: leg: REQUEST scopes: - TOOL_ARGUMENTS patterns: - secrets: {} action: DENY - type: EMAIL action: REDACT denyMessage: The tool arguments contain restricted content

REQUEST is the default leg. Its default scope is TOOL_ARGUMENTS.

Response Guardrail

The following plugin withholds server content that contains a prompt-injection phrase:

mcp: plugins: - name: protect-server-content condition: any: of: - match: ctx.request.mcp.method == "tools/call" - match: ctx.request.mcp.method == "resources/read" - match: ctx.request.mcp.method == "prompts/get" - match: ctx.request.mcp.method == "tools/list" guardrail: leg: RESPONSE scopes: - TOOL_RESULTS - RESOURCE_CONTENTS - PROMPT_MESSAGES - TOOL_DEFINITIONS patterns: - regex: '(?i)ignore (all )?previous instructions' action: DENY denyMessage: The MCP server returned restricted content

A response streamed as SSE is withheld in full until it ends and passes inspection. This guarantees that matched content cannot reach the client, but delays the first event by the duration of the upstream stream.

Both Legs

BOTH inspects both request and response scopes with the same deny patterns:

guardrail: leg: BOTH scopes: - TOOL_ARGUMENTS - TOOL_RESULTS - RESOURCE_CONTENTS patterns: - secrets: {} action: DENY denyMessage: Restricted content was detected

A scope that does not belong to the current leg is a no-op. This lets one BOTH guardrail carry scopes for each leg.

Scopes

The available scopes are:

  • TOOL_ARGUMENTS for the arguments in a tools/call request.

  • TOOL_RESULTS for content blocks and structured content in its result.

  • RESOURCE_CONTENTS for a resources/read result.

  • PROMPT_MESSAGES for messages in a prompts/get result.

  • TOOL_DEFINITIONS for names, descriptions and JSON Schemas in a tools/list result.

  • ALL for every scope.

TOOL_DEFINITIONS is inspect-only because it is structured content. Use a dedicated MCP server or a generic mutation plugin when definitions must be rewritten.

Pattern Types

Patterns are shared with the LLM mode. An RE2 expression matches custom text:

patterns: - regex: 'CUSTOMER-[0-9]+' action: DENY

Built-in deterministic personal-information detectors are also available:

patterns: - type: EMAIL action: REDACT - type: CREDIT_CARD action: DENY - type: IBAN action: DENY - type: US_SSN action: STRIP

The secrets detector recognizes API keys, tokens, private keys and connection strings from hundreds of providers:

patterns: - secrets: excludeRules: - example-rule-id action: DENY

excludeRules disables known false-positive rules by case-insensitive identifier. Unknown identifiers are ignored.

Actions

The request leg supports:

  • DENY, which is the default, rejects the JSON-RPC request.

  • REDACT replaces a matched span with a detector-specific placeholder.

  • STRIP removes the matched span.

  • REPLACE computes replacement content.

A replacement can be static:

patterns: - regex: 'CUSTOMER-[0-9]+' action: REPLACE replace: value: CUSTOMER-ID

Or it can use CEL or OPA:

patterns: - regex: 'TENANT-[0-9]+' action: REPLACE replace: eval: '"TENANT-" + ctx.user.metadata.uid' - regex: 'REGION-[A-Z]+' action: REPLACE replace: opa: | package octelium.eval result := "REGION-REDACTED"

The response leg and TOOL_DEFINITIONS only support DENY. A BOTH guardrail is also limited to deny actions because it includes the response leg. An evaluation or inspection error rejects the exchange rather than bypassing the control.

Lua

Lua can inspect and intentionally rewrite the JSON-RPC request or response (read more here):

mcp: plugins: - name: add-authenticated-user condition: all: of: - match: ctx.request.mcp.method == "tools/call" - match: ctx.request.mcp.name == "lookup" lua: inline: | function onRequest(ctx) local body = json.decode(octelium.req.getRequestBody()) body.params.arguments.userUID = ctx.user.metadata.uid octelium.req.setRequestBody(json.encode(body)) end

After a request-body mutation, Octelium parses and validates it again so later processing sees the updated MCP context.

If Lua changes a version, method or target that was also supplied in a reserved MCP header, it must update the header as well. The same applies to Mcp-Session-Id. An upstream server rejects inconsistent headers and bodies.

ExtProc

An Envoy-compatible external processing server can receive selected headers and bodies (read more here):

mcp: plugins: - name: external-mcp-control condition: match: ctx.request.mcp.method == "tools/call" extProc: address: ext-proc.default.svc:8080 processingMode: requestHeaderMode: SEND requestBodyMode: BUFFERED responseHeaderMode: SEND responseBodyMode: BUFFERED messageTimeout: seconds: 5

The external processor can alternatively run as a managed container. It has the same responsibility as Lua for keeping reserved MCP headers consistent with a rewritten message.

Request Rate Limit

The HTTP rate-limit plugin bounds request frequency with a global sliding window backed by the Cluster Redis store:

mcp: plugins: - name: user-request-rate condition: matchAny: true rateLimit: key: perUser: true limit: 100 window: minutes: 2 statusCode: 429 body: inline: '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Rate limit exceeded"},"id":null}' headers: - key: Retry-After value: "120"

The default key is per Session. It can be explicitly set to perSession, perUser or a CEL result:

rateLimit: key: eval: 'ctx.user.metadata.labels["tenant"]' limit: 1000 window: hours: 1

JSON Schema

The jsonSchema plugin validates the complete JSON-RPC request body:

mcp: plugins: - name: validate-transfer condition: all: of: - match: ctx.request.mcp.method == "tools/call" - match: ctx.request.mcp.name == "transfer" jsonSchema: inline: | { "type": "object", "required": ["jsonrpc", "method", "params", "id"], "properties": { "jsonrpc": { "const": "2.0" }, "method": { "const": "tools/call" }, "params": { "type": "object", "required": ["name", "arguments"], "properties": { "name": { "const": "transfer" }, "arguments": { "type": "object", "required": ["amount"], "properties": { "amount": { "type": "number", "minimum": 0, "maximum": 1000 } } } } } } } statusCode: 400 body: inline: '{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid parameters"},"id":null}'

Direct Response

direct answers without invoking the upstream. Its body must be a valid JSON-RPC response or error for the MCP client:

mcp: plugins: - name: maintenance-response condition: match: ctx.request.mcp.method == "tools/list" direct: statusCode: 200 body: inline: '{"jsonrpc":"2.0","result":{"tools":[]},"id":null}' headers: - key: Content-Type value: application/json

The response identifier is static, so use Lua or ExtProc instead when it must echo the request identifier.

Path

The path plugin conditionally removes and adds upstream prefixes:

mcp: plugins: - name: versioned-upstream-path condition: match: ctx.request.mcp.protocolVersion == "2026-07-28" path: removePrefix: /mcp addPrefix: /v2/mcp

Use the path in upstream.url when every request goes to the same server endpoint. The configuration-level mcp.path applies to every request served by that configuration.

Plugin Boundary

Generic HTTP plugins run before MCP guardrails on the request and after them on the response. Lua, ExtProc or Direct can therefore create response content that no guardrail has inspected. A generic plugin used to carry MCP content must inspect that content itself where necessary.

A generic plugin whose condition cannot be evaluated is skipped, following HTTP behavior. An MCP guardrail whose condition cannot be evaluated rejects the request to prevent a silent security-control bypass.

The HTTP cache plugin is not supported because it understands neither MCP authorization scope nor MCP cache invalidation semantics.