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 handshaketools/list,resources/list,resources/templates/list,prompts/list- notifications:
notifications/initialized,notifications/cancelled(exact names — arbitrarynotifications/*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
idis unique policy.default_actionisallow,deny, or absentactionis one ofallow,deny,redact,rate_limit,strip_app,guardrailguardrailis required foraction: guardrail, must reference a definedguardrails:endpoint, and is rejected on any other actionwhen.directionis empty,client_to_server, orserver_to_client- At most one of
tool_name,tool_prefix,tool_glob,tool_regex, andtool_name_inis set perwhen when.claimskeys are limited togroups,groups_in,scopes_in, andsubject_inwhen.result_typeis empty orinput_required, and requireswhen.direction: server_to_clienttool_globparses as a valid globtool_regexcompiles as Go regexptool_name_inis non-empty when set- For
action: redact,redact[]is non-empty and eachregexcompiles - For
action: rate_limit,tokens_per_second > 0 jsonpathis not present in any rule (reserved)