Fix: Forward-proxy streams SSE responses without a StreamingResponder#675
Conversation
The forward proxy only took its flushing SSE path (handleStreamingResponse) when a StreamingResponder plugin was configured. A plain pipeline (e.g. just token-exchange) fell through to an unflushed io.Copy, so intermittent SSE events from an MCP server never reached the agent until the upstream closed the connection — the agent timed out. Add streamPassthrough: a byte-faithful flushing relay used when no StreamingResponder is present. It copies raw chunks and flushes each write, preserving the event:/id:/retry: lines that generic SSE clients (MCP Streamable HTTP) rely on — re-framing via sseframe would drop them. The WritesBody buffered-fallback guard and the responder re-framing path are unchanged. Add a regression test whose upstream holds the SSE connection open after the first event (the shape the existing repro misses), asserting the event is delivered before the upstream closes and that event:/id:/data: survive verbatim. Fixes rossoctl#642 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
📝 WalkthroughWalkthroughThe forward proxy adds incremental SSE passthrough for outbound pipelines without streaming responders. It preserves response-phase hooks, flushes upstream bytes as they arrive, warns about unsupported body-reading plugins, and adds regression coverage for streaming and denial behavior. ChangesSSE passthrough streaming
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant UpstreamSSE
participant serveOutbound
participant ResponsePlugin
participant DownstreamClient
UpstreamSSE->>serveOutbound: Send text/event-stream response
serveOutbound->>ResponsePlugin: Run OnResponse
ResponsePlugin-->>serveOutbound: Allow or deny response
serveOutbound->>DownstreamClient: Write SSE bytes
serveOutbound->>DownstreamClient: Flush each write
DownstreamClient-->>serveOutbound: Read streamed frame
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 golangci-lint (2.12.2)level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies" Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
streamPassthrough previously skipped RunResponse (matching handleStreamingResponse), which silently bypassed header/status response gates on SSE responses — e.g. opa's response-phase deny and litellm-budgettrack's cost accounting would not run for a streamed response. Run RunResponse before the first byte in streamPassthrough and honor a deny. This is safe only here: the path is reached exclusively when no StreamingResponder is configured, so RunResponse cannot double-dispatch a plugin that also implements OnResponseFrame, and the plugins reachable here use status/headers only (never pctx.ResponseBody). Body-level response inspection on a stream still requires implementing StreamingResponder. Add tests: a response-phase deny short-circuits before any SSE byte is written, and a non-denying header-level OnResponse still runs while the stream is delivered verbatim. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
pdettori
left a comment
There was a problem hiding this comment.
A focused, well-reasoned fix for #642: SSE responses over the forward proxy now stream byte-for-byte with per-write flushing when no StreamingResponder is configured, instead of falling through to an unflushed io.Copy.
Verified against source:
- No double-dispatch —
streamPassthroughrunsRunResponseonly on the flushing branch; the no-flusher branch delegates tostreamFallbackBuffered, which runs its ownRunResponse. Runs exactly once either way. - Deny recording consistency — a response-phase
Rejectreturns before the deferredrecordOutboundResponseEvent, matching the existing buffered path. - WritesBody fallthrough unchanged (buffered + warning).
Tests are strong: the regression test holds the upstream connection open (the shape the old repro missed), plus deny short-circuit and header-only response-phase coverage.
One non-blocking suggestion inline re: the ReadsBody guard asymmetry. Not a blocker — no current plugin hits it and CI is green.
Approving.
Assisted-By: Claude Code
| // each SSE frame; handleStreamingResponse re-frames via sseframe. | ||
| s.handleStreamingResponse(w, r, resp, pctx) | ||
| return | ||
| } else { |
There was a problem hiding this comment.
suggestion (non-blocking): this branch guards WritesBody() but not ReadsBody(). Since NeedsBody() = ReadsBody || WritesBody, a plugin with ReadsBody:true, WritesBody:false that is not a StreamingResponder now falls into streamPassthrough, and its OnResponse sees an empty pctx.ResponseBody — whereas before this PR that plugin+SSE combination went through the buffered path and saw the full body.
The PR prose documents this invariant ("plugins reachable here do not read pctx.ResponseBody"), but the code doesn't enforce or warn on it, so it's a latent footgun for a future response-body-reading plugin.
Buffering isn't the fix here (it would reintroduce the #642 timeout for that plugin on a truly live stream — such a plugin really should implement StreamingResponder). But a slog.Warn mirroring the existing WritesBody fallback message would surface the misconfiguration instead of silently starving the plugin of the body.
There was a problem hiding this comment.
Added the warning in e2314be. Since WritesBody() is already false in this else branch, NeedsBody() here implies a ReadsBody-only, non-StreamingResponder plugin — so the guard is just if s.OutboundPipeline.NeedsBody(), logging a slog.Warn that mirrors the WritesBody fallback message above it. No buffering (that would reintroduce the #642 timeout on a live stream); such a plugin should implement StreamingResponder.
New test TestForwardProxy_SSE_ReadsBodyPluginWarnsAndStreams locks it in: a ReadsBody-only probe on an SSE response still gets the stream verbatim, its OnResponse sees an empty body, and the warning fires.
huang195
left a comment
There was a problem hiding this comment.
Author self-review — GitHub blocks self-approval, so this is a COMMENT-event review; treat as LGTM.
Tightly-scoped, well-reasoned fix for #642: forward-proxy SSE responses now stream with per-write flushing when no StreamingResponder is configured, instead of falling through to an unflushed io.Copy.
I verified the load-bearing claims against the full source:
handleStreamingResponsegenuinely skipsRunResponse(server.go:570-574) — dispatch is per-frame viaRunResponseFrame.streamFallbackBufferedruns its ownRunResponse, sostreamPassthroughdelegating to it before running its ownRunResponseavoids double-dispatch.- Skipping the response event on a response-phase deny is consistent across all three paths (buffered, fallback, passthrough), and
RunFinishstill fires via the early defer. - The raw header-copy loop matches the file's existing convention (buffered path +
handleStreamingResponsedo the same). Transfer-Encodingisn't inresp.Headerfor a Go-client response, soDel("Content-Length")is sufficient.
New tests are assertive and cover the real regression shape (upstream holds the connection open past the first flush), the response-phase deny short-circuit, and a non-denying header-level OnResponse. One non-blocking doc suggestion inline.
LGTM. Assisted-By: Claude Code
| // gates still fire on a streamed response and a deny short-circuits before | ||
| // anything is written. streamFallbackBuffered runs its own RunResponse, so | ||
| // this is done only on the flushing path to avoid double-dispatch. | ||
| if respAction := s.OutboundPipeline.RunResponse(r.Context(), pctx); respAction.Type == pipeline.Reject { |
There was a problem hiding this comment.
Good call running RunResponse here so header/status response gates aren't silently bypassed on the passthrough path.
One nuance worth a half-sentence in the doc comment: pctx.ResponseHeaders is a .Clone() of resp.Header (server.go:369), and this path writes headers from resp.Header. So a response-phase plugin that adds a header via pctx.ResponseHeaders would have it dropped — same as the buffered path, so not a regression, and no in-tree plugin does this today (opa / litellm-budgettrack only read status + headers). Since the doc comment cites cost accounting to justify running RunResponse, it'd help future plugin authors to note that only status-based deny + read-only header inspection propagate on this path; header mutations don't survive on any streaming path.
Non-blocking.
A plugin that declares ReadsBody but is not a StreamingResponder falls into streamPassthrough, where its OnResponse runs against an empty pctx.ResponseBody (the stream is forwarded unbuffered to avoid the rossoctl#642 timeout). Emit a slog.Warn -- mirroring the existing WritesBody fallback -- so the misconfiguration surfaces instead of silently starving the plugin of the body. Buffering is intentionally not the fix; such a plugin should implement StreamingResponder. Add TestForwardProxy_SSE_ReadsBodyPluginWarnsAndStreams: the stream is still delivered verbatim, OnResponse sees an empty body, and the warning fires. Addresses review feedback on rossoctl#675. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
authbridge/authlib/listener/forwardproxy/mcp_sse_stream_test.go (1)
231-231: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuePrefer
strings.NewReaderoverbytes.NewReader([]byte(...)).Using
strings.NewReaderwith string literals avoids the unnecessary allocation of converting a string to a byte slice. This is a common and idiomatic optimization in Go tests.
authbridge/authlib/listener/forwardproxy/mcp_sse_stream_test.go#L231-L231: Replacebytes.NewReader([]byte(...))withstrings.NewReader(...).authbridge/authlib/listener/forwardproxy/mcp_sse_stream_test.go#L258-L258: Replacebytes.NewReader([]byte(...))withstrings.NewReader(...).authbridge/authlib/listener/forwardproxy/mcp_sse_stream_test.go#L297-L297: Replacebytes.NewReader([]byte(...))withstrings.NewReader(...).♻️ Proposed refactor for the anchor site
- req, _ := http.NewRequest("POST", url, bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))) + req, _ := http.NewRequest("POST", url, strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@authbridge/authlib/listener/forwardproxy/mcp_sse_stream_test.go` at line 231, Replace bytes.NewReader([]byte(...)) with strings.NewReader(...) for the request bodies at authbridge/authlib/listener/forwardproxy/mcp_sse_stream_test.go lines 231-231, 258-258, and 297-297, and update imports as needed while preserving the existing request behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@authbridge/authlib/listener/forwardproxy/mcp_sse_stream_test.go`:
- Line 231: Replace bytes.NewReader([]byte(...)) with strings.NewReader(...) for
the request bodies at
authbridge/authlib/listener/forwardproxy/mcp_sse_stream_test.go lines 231-231,
258-258, and 297-297, and update imports as needed while preserving the existing
request behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: edcb81a3-b46e-42df-9090-defd155b82a1
📒 Files selected for processing (2)
authbridge/authlib/listener/forwardproxy/mcp_sse_stream_test.goauthbridge/authlib/listener/forwardproxy/server.go
🚧 Files skipped from review as they are similar to previous changes (1)
- authbridge/authlib/listener/forwardproxy/server.go
Summary
Fixes #642 — the forward proxy blocked SSE responses from a generic (e.g. MCP Streamable HTTP) upstream: an agent connecting to an SSE-based MCP server over the forward proxy received no events until it timed out and closed the connection.
Root cause
In
forwardproxy/server.goserveOutbound, the frame-by-frame flushing SSE path (handleStreamingResponse) was gated onHasStreamingResponders():A plain pipeline (the reporter's is just
token-exchange— not aStreamingResponder) failed that gate and fell through to the unflushedio.Copy(w, resp.Body). Go's defaultResponseWriterbuffers small writes, so intermittent SSE events were never pushed until the handler returned / the upstream closed — the agent timed out.Fix
Relax the gate and add a dedicated byte-faithful flushing passthrough for the no-responder case:
WritesBody→ buffered fallback (unchanged — a body mutator can't stream).HasStreamingResponders→handleStreamingResponse(unchanged — parse + per-frame dispatch for inference/a2a parsers).streamPassthrough: copies raw chunks andFlush()es each write, relaying the exact upstream bytes soevent:/id:/retry:/ comment lines survive. Re-framing throughsseframe/writeSSEFramewould drop them, which generic SSE clients like MCP Streamable HTTP depend on.streamPassthroughruns the response-phase pipeline (RunResponse) before the first byte and honors a deny. This keeps header/status-based response gates working on streamed responses — e.g.opa's response-phase deny andlitellm-budgettrack's cost accounting — rather than silently bypassing them. It's safe specifically here: this path is reached only when noStreamingResponderis configured, soRunResponsecannot double-dispatch a plugin that also implementsOnResponseFrame(the reasonhandleStreamingResponseskips it), and every plugin reachable here uses status/headers only, neverpctx.ResponseBody. Body-level response inspection on a stream still requires implementingStreamingResponder.streamPassthroughreuses the existingidleReaderwedge-guard,streamReadIdleTimeout,streamFallbackBuffered(no-http.Flusherfallback), andrecordOutboundResponseEvent.Testing
New tests in
mcp_sse_stream_test.go:TestForwardProxy_SSE_StreamsWithoutResponder— upstream flushes one event then holds the connection open (the shape the existingmcp_sse_repro_test.gomisses); asserts the event reaches the client within 2s while the upstream is still blocked, and thatevent:/id:/data:survive verbatim. Before the fix this times out.TestForwardProxy_SSE_ResponsePhaseDenyShortCircuits— a plugin that denies inOnResponseshort-circuits before any SSE byte is written (403, no body).TestForwardProxy_SSE_HeaderOnResponseRuns— a non-denying header-levelOnResponsestill runs while the stream is delivered verbatim.go test ./listener/forwardproxy/... ./listener/internal/sseframe/...all pass, including the unchanged WritesBody guard (writesse_test.go) and responder path (mcp_sse_repro_test.go).go vet ./...(GOWORK=off) andgofmtclean.Scope / notes
Forward proxy only. The reverse proxy already streams SSE (#657). No pipeline/API changes.
One pre-existing limitation is left as-is: a mixed pipeline that does contain a
StreamingResponder(e.g.[opa, inference-parser]) still routes tohandleStreamingResponse, which skipsRunResponse— so a response-phaseopadeny is not enforced there. That predates this PR and applies regardless of #642; aligning both streaming paths would require selective per-plugin response dispatch and is out of scope here.Assisted-By: Claude (Anthropic AI) noreply@anthropic.com
Summary by CodeRabbit
New Features
Bug Fixes