Govern elicitation/create messages
Problem: upstream MCP servers can send elicitation/create messages — server-initiated requests that pause a tool and ask the end user for input. You want to block those requests from specific upstreams, strip PII from prompts before users see them, or limit how frequently a misbehaving server can solicit input.
Solution: use the method and direction matchers on a policy rule. Any rule with direction: server_to_client operates on SSE frames coming back from the upstream rather than on inbound requests from the client.
How it works
Elicitation messages travel upstream → mcpgw → client over the SSE response stream. When direction: server_to_client rules are present, mcpgw’s SSE inspector parses every data: line, and any frame whose content is a JSON-RPC envelope is evaluated by the policy engine.
client mcpgw upstream
│ POST /mcp tools/call │ POST /mcp tools/call │
│ ────────────────────► │ ────────────────────► │
│ │ │
│ │ ◄ event-stream (SSE) │
│ │ data: {"method":"elicitation/create",...}
│ │ │ policy.Evaluate(direction=server_to_client)
│ │ ▼ deny → drop frame
│ │ ▼ redact → rewrite frame
│ │ ▼ allow → forward
│ ◄ event-stream │
│ ◄──────────────────── │
The existing inbound path (client-to-server) is unaffected. Inbound rules continue to operate as before; outbound rules run as a second pass on the SSE response.
Deny all elicitations from an upstream
The simplest case: refuse interactive prompts entirely. The upstream’s tool call proceeds, but the elicitation frame never reaches the client.
policy:
rules:
- id: no-elicitation-from-untrusted
action: deny
when:
method: elicitation/create
direction: server_to_client
After SIGHUP, any elicitation/create frame is dropped before reaching the client. From the upstream tool’s perspective, the user simply never responded to the prompt. The audit log records:
{"action":"deny_frame","rule_id":"no-elicitation-from-untrusted","method":"elicitation/create","direction":"server_to_client","session_id":"...","ts":"..."}
Redact PII out of elicitation prompts
If you want users to see elicitations but not have upstream context leak credential material or PII, use action: redact:
policy:
rules:
- id: redact-elicit-secrets
action: redact
when:
method: elicitation/create
direction: server_to_client
redact:
- regex: 'sk-[A-Za-z0-9_-]{20,}'
replacement: "[REDACTED]"
- regex: '\b\d{3}-\d{2}-\d{4}\b'
replacement: "[SSN-REDACTED]"
- regex: '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'
replacement: "[EMAIL]"
The regex patterns are applied globally over the raw frame data: bytes before forwarding. The rest of the SSE envelope (event:, id:, blank lines) is preserved intact. Audit records action: redact_frame.
Redact is regex-over-bytes: patterns apply anywhere in the raw JSON-RPC frame. See Explanation: redact vs JSONPath for why v1 does not offer path-scoped redaction.
Allow only known-safe server-initiated methods
If an upstream uses elicitation/create legitimately for confirmations but you want to block other server-initiated methods, allow elicitation/create explicitly and deny everything else in that direction:
policy:
default_action: allow
rules:
- id: allow-elicit-create
action: allow
when:
method: elicitation/create
direction: server_to_client
- id: deny-other-server-initiated
action: deny
when:
direction: server_to_client
Rules are first-match-wins. elicitation/create frames hit the first rule and are forwarded; any other server-initiated JSON-RPC envelope hits the second rule and is dropped.
Rate-limit interactive prompts
An upstream that fires elicitations in a tight loop can pin the user in a prompt cycle. Use action: rate_limit with a conservative refill rate:
policy:
rules:
- id: limit-elicit-rate
action: rate_limit
when:
method: elicitation/create
direction: server_to_client
tokens_per_second: 0.1
burst: 3
This allows three elicitations back-to-back and then one every ten seconds per session. Rate-limited frames are dropped and audit as action: rate_limit_blocked with direction: server_to_client.
Verifying it works
Send a request that causes an upstream to emit an elicitation. Watch the audit log filtered to the server-to-client direction:
tail -f /var/log/mcpgw/audit.jsonl | jq 'select(.direction == "server_to_client")'
Expected action values:
action | Meaning |
|---|---|
allow | Frame passed through — no rule matched, or a matching allow rule fired. |
deny_frame | Frame was dropped by a deny rule. |
redact_frame | Frame’s data: bytes were rewritten by a redact rule. |
rate_limit_blocked | Frame was dropped because the rate-limit bucket was empty. |
Telemetry spans carry matching counters. On the proxy span for the call:
mcp.frames.total = 5
mcp.frames.denied = 1
mcp.frames.redacted = 2
mcp.direction = server_to_client
Limitations
Frame-level only. Policy evaluation operates on individual JSON-RPC envelopes inside SSE frames. Comment-only heartbeat lines (bare :) and non-JSON-RPC data: lines pass through unconditionally — the engine has nothing to match against.
Direction is binary. client_to_server (inbound requests) and server_to_client (SSE frames) are the only two values. There is no per-tool or per-upstream variation at the direction level.
Redact rewrites bytes, not semantics. If a redaction regex does not match the actual format used by the upstream, the frame is forwarded unchanged. Test patterns against real upstream responses before deploying.
SIGHUP required for rule changes. Adding or removing direction: server_to_client rules takes effect on the next SIGHUP (docker kill --signal=HUP mcpgw). In-flight SSE streams continue under the old policy until they end naturally.
Related
- Policy cookbook — direction-aware recipe gallery
- Reference: policy rule schema
- Reference: audit log —
deny_frame,redact_frameactions - Reference: telemetry —
mcp.frames.*attributes - Reference: configuration —
match.method,match.directionfields