Context
The /mcp handler (crates/aisix-proxy/src/mcp.rs) governs a tools/call with the same pipeline as an LLM request: per-tool ACL, rate-limit + budget, input + output guardrails, and a usage event. The input path is testable end-to-end through the router because an input guardrail blocks before any upstream is contacted (input_guardrail_blocks_tool_call_with_forbidden_args drives the real router and asserts the JSON-RPC error envelope + echoed id).
The output path cannot be driven end-to-end today: scanning the tool result happens after the gateway dispatches to a real MCP upstream, and the proxy test harness has no in-process MCP upstream that returns a scannable result. So output-side behavior is currently covered at two levels below the full handler:
output_guardrail_block (the scan/decision helper) — block / allow / no-result / decoded-text-vs-envelope cases.
jsonrpc_guardrail_block (the wire-envelope builder both hooks funnel through) — status, code -32600, content-type, echoed id, no result.
Gap
Two seams are only covered by structural equivalence to the input path, not directly:
- The clean-result pass-through: a clean tool result is buffered and re-wrapped via
Response::from_parts(resp_parts, Body::from(resp_bytes)). A regression that dropped/garbled headers or the body on this path would not be caught.
- The output-block wiring end-to-end: handler dispatches → buffers →
output_guardrail_block → jsonrpc_guardrail_block(rpc_id, "tool result", …).
Proposal
Add a minimal in-process MCP upstream stub to the proxy test harness (an mcp_servers entry backed by an EphemeralBridge-style handler that echoes its arguments as a text content block). Then add router-level tests:
- a
tools/call whose result text carries a forbidden token → HTTP 200 JSON-RPC error, code -32600, echoed request id;
- a clean
tools/call → the upstream result passes through unchanged (status, content-type, body bytes intact).
Why separate
Wiring a real rmcp upstream into the proxy harness is a non-trivial test-infra addition, out of scope for the output-guardrail change itself (#671). Surfaced by independent audit of #673; tracked here so the gap is explicit rather than silent.
Context
The
/mcphandler (crates/aisix-proxy/src/mcp.rs) governs atools/callwith the same pipeline as an LLM request: per-tool ACL, rate-limit + budget, input + output guardrails, and a usage event. The input path is testable end-to-end through the router because an input guardrail blocks before any upstream is contacted (input_guardrail_blocks_tool_call_with_forbidden_argsdrives the real router and asserts the JSON-RPC error envelope + echoed id).The output path cannot be driven end-to-end today: scanning the tool result happens after the gateway dispatches to a real MCP upstream, and the proxy test harness has no in-process MCP upstream that returns a scannable
result. So output-side behavior is currently covered at two levels below the full handler:output_guardrail_block(the scan/decision helper) — block / allow / no-result / decoded-text-vs-envelope cases.jsonrpc_guardrail_block(the wire-envelope builder both hooks funnel through) — status,code -32600, content-type, echoed id, noresult.Gap
Two seams are only covered by structural equivalence to the input path, not directly:
Response::from_parts(resp_parts, Body::from(resp_bytes)). A regression that dropped/garbled headers or the body on this path would not be caught.output_guardrail_block→jsonrpc_guardrail_block(rpc_id, "tool result", …).Proposal
Add a minimal in-process MCP upstream stub to the proxy test harness (an
mcp_serversentry backed by anEphemeralBridge-style handler that echoes its arguments as a text content block). Then add router-level tests:tools/callwhose result text carries a forbidden token → HTTP 200 JSON-RPC error,code -32600, echoed request id;tools/call→ the upstream result passes through unchanged (status,content-type, body bytes intact).Why separate
Wiring a real rmcp upstream into the proxy harness is a non-trivial test-infra addition, out of scope for the output-guardrail change itself (#671). Surfaced by independent audit of #673; tracked here so the gap is explicit rather than silent.