# Plugins

> Octelium documentation. Canonical page: <https://octelium.com/docs/octelium/latest/management/core/service/mcp/plugins>.

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

```yaml
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:

```yaml
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:

```yaml
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:

```yaml
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:

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

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

```yaml
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:

```yaml
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:

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

Or it can use CEL or OPA:

```yaml
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](https://octelium.com/docs/octelium/latest/management/core/service/http-plugins.md#lua)):

```yaml
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](https://octelium.com/docs/octelium/latest/management/core/service/http-plugins.md#envoy-ext-proc)):

```yaml
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:

```yaml
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:

```yaml
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:

```yaml
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:

```yaml
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:

```yaml
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.
