Policy rule reference

Full grammar for entries under policy.rules[]. Rules are evaluated top-down; the first match wins.

policy:
  default_action: allow      # optional: allow | deny; default allow
  rules:
    - id: <string>             # required, unique within the rules list
      action: <action>         # required: allow | deny | redact | rate_limit | strip_app | guardrail
      when:                    # required: match conditions
        tool_name: <string>    # exact name, or "*" for wildcard
        # or one of: tool_prefix, tool_glob, tool_regex, tool_name_in
        method: <string>       # optional: exact JSON-RPC method (e.g. elicitation/create)
        direction: <string>    # optional: client_to_server (default) | server_to_client
        claims:                # optional: OAuth claim conditions
          groups_in: [admins]
      # action-specific fields below

Common fields

Field Type Notes
id string Unique identifier. Appears in audit.jsonl (rule_id), spans (mcp.policy.rule_id), and metric labels. Use kebab-case.
action enum allow, deny, redact, rate_limit, strip_app, guardrail. See per-action sections below.
when.tool_name string Exact tool name (e.g. fs_read), or the wildcard "*" to match any tool call. Case-sensitive.
when.tool_prefix string Prefix match, e.g. fs_ matches fs_read and fs_write.
when.tool_glob string Shell-style glob via Go path.Match, e.g. fs_*read*.
when.tool_regex string Go RE2 regexp. Patterns are anchored to the full tool name.
when.tool_name_in list Exact match against any listed tool name.
when.method string Exact JSON-RPC method (e.g. elicitation/create, notifications/cancelled). Combine with direction: server_to_client to govern server-initiated SSE frames.
when.direction enum client_to_server (default) or server_to_client. The SSE frame inspector evaluates rules with direction: server_to_client against each JSON-RPC envelope flowing back from the upstream. See how-to: govern elicitation.
when.result_type enum input_required only. Matches responses by result.resultType (2026-07-28 MRTR — the stateless replacement for server-initiated elicitation/sampling/roots). Requires direction: server_to_client. See Server→client frames.
when.claims object Optional OAuth identity-claim conditions. Applies to policy rules only, never routes. See when.claims.

The when block matches tools/call requests by default. To target other JSON-RPC methods, set when.method explicitly (and, for server→client frames, when.direction: server_to_client). Methods not covered by any rule are forwarded to default_upstream.

Within a single when: block, set at most one tool matcher. Use multiple rules when you need multiple alternatives. method and direction are independent of the tool matchers and may be combined with any of them. An empty when: block is a catch-all.


when.claims

when.claims restricts a policy rule to callers whose verified OAuth identity has matching claims. Claim checks run after the tool, method, and direction checks. All configured claim conditions in one block must match.

policy:
  default_action: deny
  rules:
    - id: admins-may-write
      action: allow
      when:
        tool_prefix: "fs_"
        method: "tools/call"
        claims:
          groups_in: [admins, platform-ops]
Field Type Matches when
groups list of string The caller is a member of every listed group.
groups_in list of string The caller is a member of at least one listed group.
scopes_in list of string The caller holds at least one listed OAuth scope.
subject_in list of string The caller’s JWT sub equals one listed subject.

An omitted or empty list is not a constraint. Multiple keys are ANDed together: groups_in: [admins] plus scopes_in: [write] requires both admin membership and the write scope.

Claim-gated rules fail closed for callers without an OAuth identity. API-key callers, auth-disabled callers, and requests with no verified JWT do not match a non-empty when.claims block. Pair claim-gated allow rules with policy.default_action: deny; under the default allow mode, a non-matching claim rule can still fall through to the default allow.

Group values come from identity.groups_claim in mcpgw.yaml and default to the JWT groups claim. Common alternatives include roles for Keycloak. See configuration: identity.

Routes reject match.claims; routing is based on tool names only and is not an authorization surface.

You can test claim rules offline:

mcpgw policy test --config=mcpgw.yaml --tool=fs_write --groups=admins --json

Default action

When no rule matches, policy.default_action decides the request:

policy:
  default_action: deny
  rules:
    - id: allow-readonly
      action: allow
      when: { tool_name_in: [git_log, git_diff, git_show] }
Value Behavior
allow or absent Preserve v1 behavior: unmatched tool calls are allowed.
deny Unmatched requests are denied with rule_id: "default_deny", except the handshake/discovery allowlist below.

Under default_action: deny, exactly these methods stay reachable without an explicit allow rule (catalog metadata only, never content):

  • initialize, ping — legacy dialect (removed in spec 2026-07-28; kept ≥12 months)
  • server/discover — stateless-dialect (2026-07-28) replacement for the initialize handshake
  • tools/list, resources/list, resources/templates/list, prompts/list
  • notifications: notifications/initialized, notifications/cancelled (exact names — arbitrary notifications/* are denied)

Everything else is fail-closed, including the 2026-07-28 additions subscriptions/listen (a data channel) and tasks/get / tasks/update / tasks/cancel (task lifecycle): grant them with explicit method: allow rules when your upstreams use them.

Default-deny reuses HTTP 403 and JSON-RPC -32001 policy_denied.


action: deny

Block the request outright. Upstream is never contacted.

- id: deny-shell
  action: deny
  when: { tool_name: "shell_exec" }

Wire response:

  • HTTP 403 Forbidden
  • JSON-RPC body: {"jsonrpc":"2.0","id":<req-id>,"error":{"code":-32001,"message":"policy_denied"}}

Audit: action: "deny", rule_id: "deny-shell".

Span: mcp.policy.decision = deny, mcp.policy.rule_id = deny-shell.


action: redact

Apply regex substitutions to the raw JSON-RPC request body before forwarding upstream.

- id: redact-secrets
  action: redact
  when: { tool_name: "*" }
  redact:
    - regex: 'Bearer [A-Za-z0-9._-]+'
      replacement: "[REDACTED]"
    - regex: 'sk-[A-Za-z0-9]{20,}'
      replacement: "[REDACTED]"

redact[] is a list of substitutions. Each entry has:

Field Type Notes
regex string RE2 (Go regexp) syntax. Compiled at startup; invalid regex is a fatal validation error.
replacement string Substitution string. Backreferences ($1, $2) are supported as in Go’s regexp.Expand.

Regex patterns are applied in order. The substituted body is what the upstream receives.

Wire response: as if the policy did nothing — the upstream’s response is forwarded verbatim. The redaction is invisible to the client.

Audit: action: "redact", rule_id: "redact-secrets".

Span: mcp.policy.decision = redact.

The jsonpath field is reserved and rejected at startup. No tagged release supports JSONPath-scoped redaction. See Explanation: redact vs JSONPath for why.


action: rate_limit

Token-bucket throttle. Bucket key is (rule_id, principal). For OAuth callers, the principal is derived from identity.principal_claim (default JWT sub) with fallback to client_id; API-key callers use the key id; unauthenticated callers fall back to the TCP peer address.

- id: rl-fs-write
  action: rate_limit
  when: { tool_name: "fs_write" }
  tokens_per_second: 10
  burst: 20
Field Type Notes
tokens_per_second float Refill rate. Accepts fractions (e.g. 0.0001). Must be > 0.
burst int Bucket capacity. Default 1. Must be >= 1.

The bucket is not keyed by the Mcp-Session-Id header. Authenticated callers use their verified principal. Anonymous IP fallbacks and gateway input throttling use RemoteAddr unless the immediate peer matches trusted_proxies, in which case mcpgw resolves the first untrusted address in the Forwarded/X-Forwarded-For chain. See Explanation: rate-limit identity.

Wire response when blocked:

  • HTTP 429 Too Many Requests
  • JSON-RPC body: {"jsonrpc":"2.0","id":<req-id>,"error":{"code":-32003,"message":"rate_limited"}}

Audit: action: "rate_limit_blocked", rule_id: "rl-fs-write".

Span: mcp.policy.decision = rate_limit_blocked.


action: allow

Explicit allow. The request is forwarded unchanged. Useful when paired with default_action: deny. With the default allow mode, allow rules are mostly redundant since the absence of a matching rule already means “allow”.

- id: allow-fs-read
  action: allow
  when: { tool_name: "fs_read" }

Wire response: upstream’s response forwarded verbatim.

Audit: action: "allow", rule_id: "allow-fs-read".


action: strip_app

Remove MCP Apps content from a tools/call response — specifically content blocks with type: "ui" and any content whose MIME type starts with application/vnd.mcp-ui+. The rest of the response is preserved. Use this when you want agents to keep calling a tool but you don’t want its interactive UI surfaces reaching the client.

- id: strip-app-fs
  action: strip_app
  when: { tool_prefix: "fs_" }

Wire response: upstream response forwarded with the offending content blocks dropped. If the entire content list was UI, the field is omitted.

Audit: action: "strip_app", rule_id: "strip-app-fs".

Span: mcp.policy.decision = strip_app.

See how-to: govern MCP Apps content for selective allow / monitor patterns.


action: guardrail

Delegate the decision to an outbound webhook endpoint named in the top-level guardrails: section. The engine returns the endpoint name; the proxy makes a single synchronous HTTP POST with the full JSON-RPC envelope and folds the verdict into the flow.

guardrails:
  - name: pii-scanner
    url: https://guardrails.internal/check
    timeout: 1s          # default 1s; max 10s
    fail_open: false     # default: endpoint down → deny

policy:
  rules:
    - id: guard-pii-on-writes
      action: guardrail
      guardrail: pii-scanner    # required; must reference a defined endpoint
      when:
        tool_prefix: "fs_"
Field Type Notes
guardrail string Endpoint name from guardrails:. Required on action: guardrail; rejected on any other action. A dangling reference is a fatal validation error.

The webhook answers HTTP 200 with {"action": "pass" | "mask" | "reject"}; mask carries the mutated envelope in body, and reject may carry an operator-facing reason (never sent to the client, never audited). Anything else — non-200, timeout, malformed JSON, unknown action — is a callout error resolved by the endpoint’s fail_open posture: deny (default, error_kind: guardrail_unreachable) or continue (audited as verdict error_failopen).

Webhook verdict Effective outcome
pass Envelope forwarded unchanged (audit allow)
mask + body Mutated body forwarded (audit redact)
reject Denied: HTTP 403, JSON-RPC -32001 policy_denied (audit deny)
callout error, fail_open: false Denied as above, error_kind: guardrail_unreachable
callout error, fail_open: true Envelope continues (audit allow, verdict error_failopen)

Audit: action per the table plus guardrail_name, guardrail_verdict, guardrail_latency_ms on the request line.

Span: mcp.policy.decision = guardrail, plus mcp.guardrail.name / mcp.guardrail.verdict / mcp.guardrail.latency_ms.

Applies to client_to_server requests and server_to_client buffered JSON responses. A direction: server_to_client guardrail rule is a no-op on SSE streams in v1 — frames are never intercepted; policy lint warns (guardrail-sse-direction). The webhook receives the full JSON-RPC body; treat the endpoint as a trusted data recipient. See How-to: enable a guardrail webhook and Explanation: guardrail webhooks.


Server→client frames

Set direction: server_to_client to evaluate a rule against JSON-RPC envelopes flowing back from the upstream over SSE. The frame inspector runs the same engine the request path uses; only when.method typically applies (frames have no tool_name).

- id: deny-elicitation
  action: deny
  when:
    direction: server_to_client
    method: elicitation/create

A deny decision drops the frame from the SSE stream (the client never sees it). redact rewrites the frame body. rate_limit throttles per (rule_id, principal). See how-to: govern elicitation for the canonical patterns.

when.result_type — governing MRTR responses (2026-07-28)

Under the 2026-07-28 dialect, servers no longer send elicitation/create, sampling/createMessage, or roots/list as server-initiated requests. Instead the response to the client’s own request carries result.resultType: "input_required" (Model-Requested Turn Reversal). Method-based rules never see these — the envelope’s method is the client’s original method (e.g. tools/call).

when.result_type: input_required matches responses by their result.resultType. It requires direction: server_to_client and applies on both the plain-JSON response path and per-SSE-frame. A hostile client embedding resultType in a request body cannot trip it.

Migration — each legacy method rule needs a result_type counterpart to keep governing stateless clients:

Legacy (≤2025) rule 2026-07-28 counterpart
when: { direction: server_to_client, method: elicitation/create } when: { direction: server_to_client, result_type: input_required }
when: { direction: server_to_client, method: sampling/createMessage } same — result_type: input_required (inspect result.inputRequests[].kind upstream if you need to distinguish)
when: { direction: server_to_client, method: roots/list } same — result_type: input_required

Keep both rules while you serve mixed dialects. mcpgw policy lint warns (legacy-mrtr-method) when a legacy method rule has no result_type: input_required counterpart.

- id: deny-elicitation        # legacy dialect
  action: deny
  when: { direction: server_to_client, method: elicitation/create }
- id: deny-mrtr               # 2026-07-28 dialect
  action: deny
  when: { direction: server_to_client, result_type: input_required }

Wildcard semantics

tool_name: "*" is a policy wildcard. After any method and direction predicates are checked, it matches every JSON-RPC method, including non-tool methods such as tools/list and initialize.

Other tool matchers (tool_prefix, tool_glob, tool_regex, tool_name_in, or a non-"*" tool_name) only match tools/call requests.

tool_glob supports *, ?, and character classes such as [fg]s_*. Tool names are treated as flat, case-sensitive strings.

tool_regex uses Go regexp syntax and is anchored to the full tool name. tool_regex: "db_(select|describe)_.+" behaves like ^db_(select|describe)_.+$.


Ordering and shadowing

Rules are evaluated top-down. The first rule whose when matches is applied; subsequent rules are not evaluated.

This is important for two common patterns:

1. Hard-deny before wildcard redact. If you put redact with tool_name: "*" above a deny rule, the wildcard captures the call and the deny never fires. Always order denies first.

# CORRECT
- { id: deny-shell, action: deny, when: { tool_name: "shell_exec" } }
- { id: redact-all, action: redact, when: { tool_name: "*" }, redact: [...] }

# WRONG — deny-shell is shadowed and never fires
- { id: redact-all, action: redact, when: { tool_name: "*" }, redact: [...] }
- { id: deny-shell, action: deny, when: { tool_name: "shell_exec" } }

2. Specific rate-limit before wildcard redact. Same logic — a wildcard above your rate-limit rule means redact-and-forward instead of throttling.

The mcpgw server startup logs print the loaded rule order on every reload so you can verify visually.


Validation summary

The startup validator enforces:

  • Each id is unique
  • policy.default_action is allow, deny, or absent
  • action is one of allow, deny, redact, rate_limit, strip_app, guardrail
  • guardrail is required for action: guardrail, must reference a defined guardrails: endpoint, and is rejected on any other action
  • when.direction is empty, client_to_server, or server_to_client
  • At most one of tool_name, tool_prefix, tool_glob, tool_regex, and tool_name_in is set per when
  • when.claims keys are limited to groups, groups_in, scopes_in, and subject_in
  • when.result_type is empty or input_required, and requires when.direction: server_to_client
  • tool_glob parses as a valid glob
  • tool_regex compiles as Go regexp
  • tool_name_in is non-empty when set
  • For action: redact, redact[] is non-empty and each regex compiles
  • For action: rate_limit, tokens_per_second > 0
  • jsonpath is not present in any rule (reserved)