Skip to content

Commit c856452

Browse files
authored
Merge pull request #480 from kagenti/fix/477-streaming-mcp-responses
fix(authbridge): Stream text/event-stream responses frame-by-frame
2 parents 1488200 + 2fd8c0f commit c856452

18 files changed

Lines changed: 2800 additions & 115 deletions

File tree

authbridge/authlib/listener/extproc/server.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
package extproc
55

66
import (
7+
"bytes"
78
"context"
89
"encoding/json"
10+
"io"
911
"log/slog"
1012
"net/http"
1113
"strconv"
@@ -20,6 +22,7 @@ import (
2022
"google.golang.org/grpc/status"
2123

2224
"github.com/kagenti/kagenti-extensions/authbridge/authlib/auth"
25+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/internal/sseframe"
2326
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
2427
"github.com/kagenti/kagenti-extensions/authbridge/authlib/session"
2528
)
@@ -559,6 +562,16 @@ func (s *Server) handleResponseHeaders(ctx context.Context, headers *corev3.Head
559562
return rejectFromAction(action)
560563
}
561564

565+
// Body-less response: deliver an empty last=true frame so
566+
// StreamingResponder plugins can finalize (and emit no_response_body
567+
// Skip rows for pairing). Mirrors the buffered-body path's single
568+
// last=true dispatch.
569+
if p.HasStreamingResponders() {
570+
if frameAction := p.RunResponseFrame(ctx, pctx, nil, true); frameAction.Type == pipeline.Reject {
571+
return rejectFromAction(frameAction)
572+
}
573+
}
574+
562575
// No body phase will run; record the response event here. A2A responses
563576
// need the body to extract contextId, so the rekey path is body-only;
564577
// skip it on this header-only path.
@@ -596,6 +609,22 @@ func (s *Server) handleResponseBody(ctx context.Context, body []byte, pctx *pipe
596609
return rejectFromAction(action)
597610
}
598611

612+
// Streaming-aware plugins use a single code path for both shapes
613+
// (mirrors forwardproxy/reverseproxy). pipeline.RunResponse skips
614+
// StreamingResponder plugins so they wouldn't get a response-phase
615+
// dispatch otherwise; deliver the buffered body via RunResponseFrame
616+
// so mcp/inference/a2a parsers populate their response state and
617+
// the inbound A2A contextId rekey below sees pctx.Extensions.A2A
618+
// fully populated. For text/event-stream bodies (Envoy already
619+
// buffered them at this point), re-parse with sseframe so each
620+
// event arrives as its own frame; otherwise dispatch the whole
621+
// body as one last=true frame.
622+
if p.HasStreamingResponders() {
623+
if frameAction := dispatchBufferedFrames(ctx, p, pctx); frameAction.Type == pipeline.Reject {
624+
return rejectFromAction(frameAction)
625+
}
626+
}
627+
599628
// The server's response may carry the server-assigned A2A contextId. If
600629
// the request phase recorded events under DefaultSessionID (because the
601630
// client had no contextId yet), migrate them to the real ID so subsequent
@@ -849,3 +878,51 @@ func getHeader(headers *corev3.HeaderMap, key string) string {
849878
}
850879
return ""
851880
}
881+
882+
// dispatchBufferedFrames feeds the buffered response body to
883+
// StreamingResponder plugins via RunResponseFrame, mirroring the
884+
// proxy listeners' single-dispatch contract for buffered bodies.
885+
// Envoy's ext_proc delivers response bodies pre-buffered (we requested
886+
// ResponseBodyMode_BUFFERED via ModeOverride), so we get the whole
887+
// body in one shot regardless of upstream framing.
888+
//
889+
// For application/json the entire body is one last=true frame, so
890+
// non-streaming JSON-RPC responses look the same to plugins as on
891+
// the proxy listeners. For text/event-stream we re-parse with
892+
// sseframe so each event arrives as its own non-last frame followed
893+
// by a final last=true — matches the per-message dispatch shape
894+
// streaming-aware plugins expect.
895+
func dispatchBufferedFrames(ctx context.Context, p *pipeline.Holder, pctx *pipeline.Context) pipeline.Action {
896+
contentType := pctx.ResponseHeaders.Get("Content-Type")
897+
if isEventStream(contentType) && len(pctx.ResponseBody) > 0 {
898+
reader := sseframe.NewReader(bytes.NewReader(pctx.ResponseBody), maxBodySize)
899+
for {
900+
frame, err := reader.ReadFrame()
901+
if err == io.EOF {
902+
break
903+
}
904+
if err != nil {
905+
slog.Warn("extproc: SSE re-parse error", "error", err)
906+
break
907+
}
908+
if action := p.RunResponseFrame(ctx, pctx, frame, false); action.Type == pipeline.Reject {
909+
return action
910+
}
911+
}
912+
return p.RunResponseFrame(ctx, pctx, nil, true)
913+
}
914+
return p.RunResponseFrame(ctx, pctx, pctx.ResponseBody, true)
915+
}
916+
917+
// isEventStream reports whether a Content-Type header value names the
918+
// SSE media type. Tolerates parameters and ASCII case differences.
919+
// Mirrors the helpers in forwardproxy/reverseproxy.
920+
func isEventStream(contentType string) bool {
921+
if contentType == "" {
922+
return false
923+
}
924+
if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
925+
contentType = contentType[:idx]
926+
}
927+
return strings.EqualFold(strings.TrimSpace(contentType), "text/event-stream")
928+
}

authbridge/authlib/listener/extproc/server_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,3 +1570,114 @@ func TestExtProc_PopulatesSchemeFromPseudoHeader(t *testing.T) {
15701570
})
15711571
}
15721572
}
1573+
1574+
// streamingRecorderPlugin is a Plugin + StreamingResponder used to
1575+
// verify the extproc listener dispatches the buffered response body
1576+
// through OnResponseFrame. The pipeline.RunResponse skip introduced
1577+
// for the proxy listeners would otherwise leave streaming-aware
1578+
// plugins (mcp/inference/a2a parsers) with no response-phase
1579+
// dispatch under envoy-sidecar — the regression this guards.
1580+
type streamingRecorderPlugin struct {
1581+
frames [][]byte
1582+
lasts []bool
1583+
onResponseCalls int
1584+
}
1585+
1586+
func (p *streamingRecorderPlugin) Name() string { return "streaming-recorder" }
1587+
func (p *streamingRecorderPlugin) Capabilities() pipeline.PluginCapabilities {
1588+
return pipeline.PluginCapabilities{ReadsBody: true}
1589+
}
1590+
func (p *streamingRecorderPlugin) OnRequest(_ context.Context, _ *pipeline.Context) pipeline.Action {
1591+
return pipeline.Action{Type: pipeline.Continue}
1592+
}
1593+
func (p *streamingRecorderPlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action {
1594+
p.onResponseCalls++
1595+
return pipeline.Action{Type: pipeline.Continue}
1596+
}
1597+
func (p *streamingRecorderPlugin) OnResponseFrame(_ context.Context, _ *pipeline.Context, frame []byte, last bool) pipeline.Action {
1598+
cp := make([]byte, len(frame))
1599+
copy(cp, frame)
1600+
p.frames = append(p.frames, cp)
1601+
p.lasts = append(p.lasts, last)
1602+
return pipeline.Action{Type: pipeline.Continue}
1603+
}
1604+
1605+
// TestExtProc_BufferedJSONResponse_DispatchesToStreamingResponder
1606+
// asserts the framework's "pick one path" contract on extproc:
1607+
// pipeline.RunResponse skips StreamingResponder plugins, so the
1608+
// listener MUST deliver the buffered application/json body via
1609+
// RunResponseFrame(last=true) — otherwise mcp/inference/a2a parsers
1610+
// lose response observability under envoy-sidecar entirely.
1611+
func TestExtProc_BufferedJSONResponse_DispatchesToStreamingResponder(t *testing.T) {
1612+
probe := &streamingRecorderPlugin{}
1613+
p, err := pipeline.New([]pipeline.Plugin{probe})
1614+
if err != nil {
1615+
t.Fatal(err)
1616+
}
1617+
srv := &Server{InboundPipeline: pipeline.NewHolder(p), OutboundPipeline: pipeline.NewHolder(p)}
1618+
1619+
pctx := &pipeline.Context{
1620+
Direction: pipeline.Inbound,
1621+
ResponseHeaders: http.Header{
1622+
"Content-Type": []string{"application/json"},
1623+
},
1624+
}
1625+
body := []byte(`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`)
1626+
resp := srv.handleResponseBody(context.Background(), body, pctx, "inbound")
1627+
if resp.GetImmediateResponse() != nil {
1628+
t.Fatalf("unexpected immediate response: %+v", resp.GetImmediateResponse())
1629+
}
1630+
1631+
if len(probe.frames) != 1 || !probe.lasts[0] {
1632+
t.Fatalf("frames=%d lasts=%v; want exactly one last=true frame", len(probe.frames), probe.lasts)
1633+
}
1634+
if string(probe.frames[0]) != string(body) {
1635+
t.Errorf("frame[0] = %q; want %q", probe.frames[0], body)
1636+
}
1637+
// OnResponse must NOT fire for a StreamingResponder plugin —
1638+
// pipeline.RunResponse skips it on purpose so the body isn't
1639+
// delivered through both hooks.
1640+
if probe.onResponseCalls != 0 {
1641+
t.Errorf("OnResponse called %d times; want 0", probe.onResponseCalls)
1642+
}
1643+
}
1644+
1645+
// TestExtProc_BufferedSSEResponse_DispatchesPerEvent verifies that
1646+
// when Envoy buffers a text/event-stream body, the extproc listener
1647+
// re-parses with sseframe and dispatches one OnResponseFrame call
1648+
// per SSE event followed by a final last=true. Mirrors what the
1649+
// proxy listeners do over their wire-streaming dispatch path so
1650+
// streaming-aware plugins see the same shape regardless of mode.
1651+
func TestExtProc_BufferedSSEResponse_DispatchesPerEvent(t *testing.T) {
1652+
probe := &streamingRecorderPlugin{}
1653+
p, err := pipeline.New([]pipeline.Plugin{probe})
1654+
if err != nil {
1655+
t.Fatal(err)
1656+
}
1657+
srv := &Server{InboundPipeline: pipeline.NewHolder(p), OutboundPipeline: pipeline.NewHolder(p)}
1658+
1659+
pctx := &pipeline.Context{
1660+
Direction: pipeline.Inbound,
1661+
ResponseHeaders: http.Header{
1662+
"Content-Type": []string{"text/event-stream"},
1663+
},
1664+
}
1665+
body := []byte("data: {\"id\":1}\n\ndata: {\"id\":2}\n\ndata: {\"id\":3}\n\n")
1666+
resp := srv.handleResponseBody(context.Background(), body, pctx, "inbound")
1667+
if resp.GetImmediateResponse() != nil {
1668+
t.Fatalf("unexpected immediate response: %+v", resp.GetImmediateResponse())
1669+
}
1670+
1671+
// Three events as non-last + one final last=true call.
1672+
if len(probe.frames) != 4 {
1673+
t.Fatalf("got %d frames, want 4 (3 events + 1 last=true)", len(probe.frames))
1674+
}
1675+
for i := 0; i < 3; i++ {
1676+
if probe.lasts[i] {
1677+
t.Errorf("frame[%d] last=true, want false", i)
1678+
}
1679+
}
1680+
if !probe.lasts[3] {
1681+
t.Errorf("frame[3] last=false, want true (final)")
1682+
}
1683+
}

0 commit comments

Comments
 (0)