Summary
The AuthBridge forward-proxy fully buffers outbound response bodies via io.ReadAll whenever any plugin in the outbound pipeline declares ReadsBody or WritesBody. For MCP servers using the Streamable HTTP transport, tools/call responses can be returned as Content-Type: text/event-stream (chunked-transfer body with SSE-framed JSON-RPC messages) and stay open for the full duration of slow tool execution. Buffering converts that streaming response into a blocking Client.Do read, which is then bounded by the hard-coded 30s http.Client.Timeout and produces 502 bad gateway (or appears to the agent as a hang followed by retry).
This affects every realistic outbound pipeline because mcp-parser, inference-parser, and ibac all declare ReadsBody: true.
Reproduction
Deploy any agent + MCP server pair where MCP tool calls take longer than ~30s of upstream work, with an outbound pipeline containing mcp-parser (and/or ibac/inference-parser). Observed with the exgentic tau2 benchmark, where tool calls invoke a user-simulator LLM:
./deploy-and-evaluate.sh --benchmark tau2 --agent tool_calling \
--plugin-preset ibac-only --model openai/azure/gpt-5.5
AuthBridge logs show the request being admitted by the pipeline and then the body read failing:
17:51:21.467 mcp-parser: request method=tools/call isAction=true
17:51:35.619 forward-proxy: response body read error
host=exgentic-mcp-tau2-mcp:8000
error="context deadline exceeded ... while reading body"
17:51:56.416 forward-proxy: response body read error context canceled
The agent sees the tool call as never-returning and stalls.
Root cause
authlib/listener/forwardproxy/server.go:
-
Lines 277–291 — when OutboundPipeline.NeedsBody() is true, the entire response body is read with io.ReadAll(io.LimitReader(resp.Body, maxBodySize+1)) before any plugin runs and before any byte is forwarded downstream:
if s.OutboundPipeline.NeedsBody() && resp.Body != nil {
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize+1))
...
pctx.ResponseBody = respBody
resp.Body = io.NopCloser(bytes.NewReader(respBody))
}
-
Line 89 — the outbound HTTP client has Timeout: 30 * time.Second, which Go applies to the entire request lifecycle including body read. With buffering on, this caps the lifetime of any streaming MCP tools/call to 30s regardless of whether it's actually making progress.
-
Line 25 — maxBodySize = 1 << 20 (1 MiB). Even if the timeout were raised, large streaming responses (long tau2 simulator turns, large MCP results) would still hit 502 response body too large.
-
Line 344 — the downstream io.Copy(w, resp.Body) is a no-op stream because resp.Body is already a fully-buffered bytes.Reader by then.
Plugins declaring ReadsBody: true (all on the outbound path):
authlib/plugins/mcpparser/plugin.go:91
authlib/plugins/ibac/plugin.go:301
authlib/plugins/inferenceparser/plugin.go:30
Why a bigger timeout / bigger buffer is not the right fix
Raising http.Client.Timeout and maxBodySize masks the symptom for short tool calls but:
- Buffered tool-call responses block the agent until the upstream call fully completes, defeating the streaming-HTTP transport the agent and MCP server already negotiate.
- IBAC's verdict on
tools/call is decided at request time (before the upstream call goes out); response-side body inspection in IBAC is auxiliary (audit/event recording), so there is no semantic reason to gate the downstream wire on a fully-buffered response.
- mcp-parser's response-side parsing operates per JSON-RPC message, which composes naturally with frame-by-frame streaming.
Proposed fix
Special-case streaming responses in the forward-proxy response handler:
- Detect
Content-Type: text/event-stream (or any chunked, non-application/json response) and switch from the buffered branch to a streaming pass-through.
- For each completed JSON-RPC message in the SSE-framed body, run the existing response-phase plugin inspectors (mcp-parser, ibac, inference-parser) over that message, then immediately write the frame to the downstream
ResponseWriter and Flush().
- Keep the existing buffered path for
application/json one-shot responses — they're small, plugins want the full envelope, and latency cost is negligible.
- Once the streaming path exists, the 30s
http.Client.Timeout becomes a connect/header ceiling only (which is fine; tau2 servers return headers immediately), and maxBodySize no longer applies to the wire response, only to whatever slice a plugin actually inspects.
The Streamable HTTP transport is already in use end-to-end:
- Agent:
mcp.client.streamable_http.streamable_http_client (in the exgentic agent's mcp_wrapper.py).
- MCP server: FastMCP's
streamable_http_app().
So no agent or MCP-server changes are needed — only the proxy needs to stop converting streaming responses into buffered ones.
IBAC compatibility
The proposed fix does not affect IBAC's decision-making. IBAC's body inspection is request-side only:
describeAction() at authlib/plugins/ibac/plugin.go:598 builds the judge prompt from pctx.Method/Scheme/Host/Path, pctx.Body (request body excerpt), pctx.Extensions.MCP (parsed MCP method/tool/args from mcp-parser), and pctx.Extensions.Inference (when JudgeInference=true). All four are populated during the request phase, before forwarding upstream.
OnRequest() at plugin.go:333 is the only place the judge is called and the only place IBAC can emit a Reject. The verdict is final before the upstream MCP server starts producing a response.
OnResponse() at plugin.go:564 is a literal no-op (return pipeline.Action{Type: pipeline.Continue}) — IBAC does not inspect the response body today.
IBAC's ReadsBody: true capability covers its use of pctx.Body (the request body). The fact that the same flag also triggers response-side io.ReadAll is a coincidence of the current proxy implementation, not something IBAC requires. Request-side buffering remains unchanged under the proposed fix.
If a future IBAC feature wanted to inspect tool-call results, the streaming path would compose better than the current buffered one — each JSON-RPC result arrives as a discrete frame instead of being demultiplexed from a buffered SSE blob.
Severity
High for any deployment running ibac or mcp-parser against MCP servers whose tool calls do meaningful upstream work. Currently makes the tau2 benchmark unusable through the AuthBridge sidecar.
Summary
The AuthBridge forward-proxy fully buffers outbound response bodies via
io.ReadAllwhenever any plugin in the outbound pipeline declaresReadsBodyorWritesBody. For MCP servers using the Streamable HTTP transport,tools/callresponses can be returned asContent-Type: text/event-stream(chunked-transfer body with SSE-framed JSON-RPC messages) and stay open for the full duration of slow tool execution. Buffering converts that streaming response into a blockingClient.Doread, which is then bounded by the hard-coded 30shttp.Client.Timeoutand produces502 bad gateway(or appears to the agent as a hang followed by retry).This affects every realistic outbound pipeline because
mcp-parser,inference-parser, andibacall declareReadsBody: true.Reproduction
Deploy any agent + MCP server pair where MCP tool calls take longer than ~30s of upstream work, with an outbound pipeline containing
mcp-parser(and/oribac/inference-parser). Observed with theexgentictau2 benchmark, where tool calls invoke a user-simulator LLM:AuthBridge logs show the request being admitted by the pipeline and then the body read failing:
The agent sees the tool call as never-returning and stalls.
Root cause
authlib/listener/forwardproxy/server.go:Lines 277–291 — when
OutboundPipeline.NeedsBody()is true, the entire response body is read withio.ReadAll(io.LimitReader(resp.Body, maxBodySize+1))before any plugin runs and before any byte is forwarded downstream:Line 89 — the outbound HTTP client has
Timeout: 30 * time.Second, which Go applies to the entire request lifecycle including body read. With buffering on, this caps the lifetime of any streaming MCPtools/callto 30s regardless of whether it's actually making progress.Line 25 —
maxBodySize = 1 << 20(1 MiB). Even if the timeout were raised, large streaming responses (long tau2 simulator turns, large MCP results) would still hit502 response body too large.Line 344 — the downstream
io.Copy(w, resp.Body)is a no-op stream becauseresp.Bodyis already a fully-bufferedbytes.Readerby then.Plugins declaring
ReadsBody: true(all on the outbound path):authlib/plugins/mcpparser/plugin.go:91authlib/plugins/ibac/plugin.go:301authlib/plugins/inferenceparser/plugin.go:30Why a bigger timeout / bigger buffer is not the right fix
Raising
http.Client.TimeoutandmaxBodySizemasks the symptom for short tool calls but:tools/callis decided at request time (before the upstream call goes out); response-side body inspection in IBAC is auxiliary (audit/event recording), so there is no semantic reason to gate the downstream wire on a fully-buffered response.Proposed fix
Special-case streaming responses in the forward-proxy response handler:
Content-Type: text/event-stream(or any chunked, non-application/jsonresponse) and switch from the buffered branch to a streaming pass-through.ResponseWriterandFlush().application/jsonone-shot responses — they're small, plugins want the full envelope, and latency cost is negligible.http.Client.Timeoutbecomes a connect/header ceiling only (which is fine; tau2 servers return headers immediately), andmaxBodySizeno longer applies to the wire response, only to whatever slice a plugin actually inspects.The Streamable HTTP transport is already in use end-to-end:
mcp.client.streamable_http.streamable_http_client(in theexgenticagent'smcp_wrapper.py).streamable_http_app().So no agent or MCP-server changes are needed — only the proxy needs to stop converting streaming responses into buffered ones.
IBAC compatibility
The proposed fix does not affect IBAC's decision-making. IBAC's body inspection is request-side only:
describeAction()atauthlib/plugins/ibac/plugin.go:598builds the judge prompt frompctx.Method/Scheme/Host/Path,pctx.Body(request body excerpt),pctx.Extensions.MCP(parsed MCP method/tool/args from mcp-parser), andpctx.Extensions.Inference(whenJudgeInference=true). All four are populated during the request phase, before forwarding upstream.OnRequest()atplugin.go:333is the only place the judge is called and the only place IBAC can emit aReject. The verdict is final before the upstream MCP server starts producing a response.OnResponse()atplugin.go:564is a literal no-op (return pipeline.Action{Type: pipeline.Continue}) — IBAC does not inspect the response body today.IBAC's
ReadsBody: truecapability covers its use ofpctx.Body(the request body). The fact that the same flag also triggers response-sideio.ReadAllis a coincidence of the current proxy implementation, not something IBAC requires. Request-side buffering remains unchanged under the proposed fix.If a future IBAC feature wanted to inspect tool-call results, the streaming path would compose better than the current buffered one — each JSON-RPC result arrives as a discrete frame instead of being demultiplexed from a buffered SSE blob.
Severity
High for any deployment running
ibacormcp-parseragainst MCP servers whose tool calls do meaningful upstream work. Currently makes the tau2 benchmark unusable through the AuthBridge sidecar.