Skip to content

AuthBridge forward-proxy buffers entire response body, breaking streaming MCP tool calls #477

Description

@kellyaa

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggoPull requests that update go code

    Type

    No type

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions