Skip to content

Commit 6cc58fe

Browse files
committed
Fix(mcp-parser): parse streaming responses on Configurable plugins
MCP tool responses (tools/list, tools/call) over the Streamable HTTP transport are text/event-stream, dispatched to plugins via StreamingResponder.OnResponseFrame. In abctl these calls showed the request parsed (observe) but the response blank (no result, no invocation) — the response was never parsed. Root cause: plugins.Build wraps every Configurable plugin in pipeline.configuredPlugin (to surface raw config on /v1/pipeline). That wrapper embeds the Plugin INTERFACE, and Go does not promote method-set membership through an embedded interface — so the wrapped plugin's OnResponseFrame is not promoted, and the wrapper fails the StreamingResponder type-assertion in Pipeline.RunResponseFrame / HasStreamingResponders. mcp-parser implements Configure (Configurable), so it gets wrapped and silently loses response-frame dispatch; inference-parser has no Configure, isn't wrapped, and worked. The listener then takes a path that records the response event with no parser activity, leaving result/invocations empty. Fix: WrapConfigured returns a StreamingResponder-preserving wrapper (configuredStreamingPlugin) when, and only when, the inner plugin implements StreamingResponder — forwarding OnResponseFrame to it. Using a distinct type (rather than an unconditional no-op OnResponseFrame on configuredPlugin) keeps HasStreamingResponders exact, so the listener's streaming-vs-buffered path selection is unchanged for non-streaming pipelines. Also harden mcp-parser's OnResponseFrame to parse via the SSE-aware parseMCPResponse instead of a bare json.Unmarshal, so the buffered whole-body dispatch (which can hand it a raw "data: {...}" SSE blob) records the result instead of silently dropping it. Tests: pipeline regression (a Configurable+StreamingResponder plugin stays a StreamingResponder after wrapping; a non-streaming Configurable plugin does not become one); mcp-parser raw-SSE-blob frame records an observe; forward-proxy integration test drives a tools/list SSE response through the proxy with a WrapConfigured'd mcp-parser and asserts the response is parsed + observed. Verified e2e on Kind: the tools/list response now records result + mcp-parser observe. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent 72f9d38 commit 6cc58fe

5 files changed

Lines changed: 234 additions & 14 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package forwardproxy
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
11+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
12+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/inferenceparser"
13+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/mcpparser"
14+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/session"
15+
)
16+
17+
// TestForwardProxy_MCP_SSEResponse_RecordsObserve reproduces the live
18+
// weather-tool-mcp scenario: an MCP tools/list call whose response is
19+
// text/event-stream (Streamable HTTP). The request is parsed (observe), and
20+
// the RESPONSE must also be parsed into a result + recorded as an mcp-parser
21+
// observe on the response event. The live cluster showed result=N,
22+
// invocations=[] on the response — this test pins whether the proxy+parser
23+
// path reproduces that.
24+
func TestForwardProxy_MCP_SSEResponse_RecordsObserve(t *testing.T) {
25+
// Upstream mimics weather-tool-mcp: tools/list -> SSE with one data frame
26+
// carrying the JSON-RPC result, exactly as FastMCP emits it.
27+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
28+
w.Header().Set("Content-Type", "text/event-stream")
29+
w.WriteHeader(http.StatusOK)
30+
io.WriteString(w, "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tools\":[{\"name\":\"get_weather\"}]}}\n\n")
31+
if f, ok := w.(http.Flusher); ok {
32+
f.Flush()
33+
}
34+
}))
35+
defer upstream.Close()
36+
37+
store := session.New(5*time.Minute, 100, 0)
38+
defer store.Close()
39+
40+
// Production builds Configurable plugins through plugins.Build, which
41+
// wraps them via pipeline.WrapConfigured. mcp-parser is Configurable +
42+
// StreamingResponder, so it MUST stay a StreamingResponder after wrapping
43+
// — that's the bug this test guards (a bare wrapper drops OnResponseFrame
44+
// and RunResponseFrame skips it, leaving the SSE response unparsed).
45+
// inference-parser is not Configurable, so it's added raw, as in production.
46+
pipe, err := pipeline.New([]pipeline.Plugin{
47+
pipeline.WrapConfigured(mcpparser.NewMCPParser(), nil),
48+
inferenceparser.NewInferenceParser(),
49+
})
50+
if err != nil {
51+
t.Fatal(err)
52+
}
53+
srv, err := NewServer(pipeline.NewHolder(pipe), store, nil)
54+
if err != nil {
55+
t.Fatalf("NewServer: %v", err)
56+
}
57+
proxy := httptest.NewServer(srv.Handler())
58+
defer proxy.Close()
59+
60+
reqBody := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`
61+
req, _ := http.NewRequest("POST", upstream.URL+"/mcp", bytes.NewReader([]byte(reqBody)))
62+
req.Header.Set("Content-Type", "application/json")
63+
proxyClient := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(proxy.URL))}}
64+
resp, err := proxyClient.Do(req)
65+
if err != nil {
66+
t.Fatalf("request failed: %v", err)
67+
}
68+
_, _ = io.ReadAll(resp.Body)
69+
resp.Body.Close()
70+
71+
v := store.View(session.DefaultSessionID)
72+
if v == nil {
73+
t.Fatal("no session recorded")
74+
}
75+
var reqEv, respEv *pipeline.SessionEvent
76+
for i := range v.Events {
77+
e := &v.Events[i]
78+
if e.MCP == nil || e.MCP.Method != "tools/list" {
79+
continue
80+
}
81+
switch e.Phase {
82+
case pipeline.SessionRequest:
83+
reqEv = e
84+
case pipeline.SessionResponse:
85+
respEv = e
86+
}
87+
}
88+
if reqEv == nil || respEv == nil {
89+
t.Fatalf("missing req/resp events: req=%v resp=%v", reqEv != nil, respEv != nil)
90+
}
91+
92+
// Request side parsed (sanity).
93+
if reqEv.Invocations == nil || len(reqEv.Invocations.Outbound) == 0 {
94+
t.Errorf("request event has no mcp-parser invocation: %+v", reqEv.Invocations)
95+
}
96+
97+
// THE ASSERTION: the response was parsed into a result and recorded an
98+
// observe. Live cluster fails both.
99+
if respEv.MCP.Result == nil {
100+
t.Errorf("response MCP.Result not populated (parser never saw the SSE result)")
101+
}
102+
respObserved := respEv.Invocations != nil && len(respEv.Invocations.Outbound) > 0
103+
if !respObserved {
104+
t.Errorf("response event has NO mcp-parser invocation — reproduces the bug. invocations=%+v", respEv.Invocations)
105+
}
106+
}

authbridge/authlib/pipeline/configured.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ type RawConfigProvider interface {
2828
// implements each of those four interfaces explicitly and forwards
2929
// conditionally; see the methods below.
3030
//
31+
// StreamingResponder is handled differently — see configuredStreamingPlugin
32+
// and WrapConfigured. It must NOT be forwarded unconditionally here, because
33+
// listeners type-assert StreamingResponder to choose the streaming-vs-buffered
34+
// response path (Pipeline.HasStreamingResponders); making every wrapped plugin
35+
// a no-op StreamingResponder would silently flip that path selection for
36+
// non-streaming pipelines.
37+
//
3138
// Side effect of unconditional forwarding: every wrapped (i.e., every
3239
// Configurable) plugin satisfies all four optional interfaces regardless
3340
// of what the inner plugin actually implements. Callers that distinguish
@@ -41,6 +48,29 @@ type configuredPlugin struct {
4148
raw json.RawMessage
4249
}
4350

51+
// configuredStreamingPlugin is the StreamingResponder-preserving variant of
52+
// configuredPlugin, returned by WrapConfigured only when the wrapped plugin
53+
// implements StreamingResponder. The base wrapper embeds the Plugin interface,
54+
// which does not promote OnResponseFrame — so without this a Configurable +
55+
// StreamingResponder plugin (e.g. mcp-parser, which has Configure) would lose
56+
// its response-frame dispatch once wrapped: RunResponseFrame type-asserts
57+
// StreamingResponder, fails on the bare wrapper, and skips the plugin, leaving
58+
// MCP/SSE responses unparsed.
59+
//
60+
// Using a distinct type (instead of an unconditional no-op OnResponseFrame on
61+
// configuredPlugin) keeps HasStreamingResponders exact: only plugins that
62+
// genuinely stream are reported as streaming responders.
63+
type configuredStreamingPlugin struct {
64+
*configuredPlugin
65+
}
66+
67+
// OnResponseFrame forwards to the wrapped plugin's StreamingResponder
68+
// implementation. WrapConfigured constructs this wrapper only when the inner
69+
// plugin implements StreamingResponder, so the assertion always holds.
70+
func (c *configuredStreamingPlugin) OnResponseFrame(ctx context.Context, pctx *Context, frame []byte, last bool) Action {
71+
return c.Plugin.(StreamingResponder).OnResponseFrame(ctx, pctx, frame, last)
72+
}
73+
4474
// WrapConfigured returns a Plugin whose dynamic type retains the raw
4575
// config bytes the plugin was built from, so the session API can
4676
// surface them on /v1/pipeline. Callers (plugins.Build) invoke this
@@ -55,7 +85,14 @@ type configuredPlugin struct {
5585
// and on the rare hot-reload path.
5686
func WrapConfigured(p Plugin, raw json.RawMessage) Plugin {
5787
cp := append(json.RawMessage(nil), raw...)
58-
return &configuredPlugin{Plugin: p, raw: cp}
88+
base := &configuredPlugin{Plugin: p, raw: cp}
89+
// Preserve StreamingResponder: the embedded Plugin interface does not
90+
// promote OnResponseFrame, so a Configurable + StreamingResponder plugin
91+
// (e.g. mcp-parser) would otherwise lose response-frame dispatch.
92+
if _, ok := p.(StreamingResponder); ok {
93+
return &configuredStreamingPlugin{configuredPlugin: base}
94+
}
95+
return base
5996
}
6097

6198
// RawConfig returns a defensive copy of the raw config bytes the

authbridge/authlib/pipeline/configured_test.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type fakePlugin struct {
1515
responses int
1616
}
1717

18-
func (f *fakePlugin) Name() string { return f.name }
18+
func (f *fakePlugin) Name() string { return f.name }
1919
func (f *fakePlugin) Capabilities() PluginCapabilities { return f.caps }
2020
func (f *fakePlugin) OnRequest(ctx context.Context, pctx *Context) Action {
2121
f.requests++
@@ -210,3 +210,50 @@ func TestConfiguredPluginReadyDefaultsTrueForNonReadier(t *testing.T) {
210210
t.Fatal("non-Readier wrapped plugin should default to ready=true")
211211
}
212212
}
213+
214+
// streamingFakePlugin is a Plugin that also implements StreamingResponder,
215+
// modeling a Configurable + streaming plugin like mcp-parser.
216+
type streamingFakePlugin struct {
217+
fakePlugin
218+
frames int
219+
}
220+
221+
func (s *streamingFakePlugin) OnResponseFrame(_ context.Context, _ *Context, _ []byte, _ bool) Action {
222+
s.frames++
223+
return Action{Type: Continue}
224+
}
225+
226+
// TestWrapConfigured_PreservesStreamingResponder is the regression test for the
227+
// mcp-parser "tools/list response not parsed" bug: a Configurable plugin that
228+
// also implements StreamingResponder must STILL satisfy StreamingResponder
229+
// after WrapConfigured, and OnResponseFrame must forward to the inner plugin.
230+
// Before the fix, the wrapper embedded only the Plugin interface, so
231+
// OnResponseFrame was not promoted and RunResponseFrame skipped the plugin.
232+
func TestWrapConfigured_PreservesStreamingResponder(t *testing.T) {
233+
inner := &streamingFakePlugin{fakePlugin: fakePlugin{name: "mcp-parser"}}
234+
cp := WrapConfigured(inner, json.RawMessage(`{}`))
235+
236+
sr, ok := cp.(StreamingResponder)
237+
if !ok {
238+
t.Fatal("wrapped Configurable+StreamingResponder plugin must still satisfy StreamingResponder")
239+
}
240+
sr.OnResponseFrame(context.Background(), nil, []byte("frame"), true)
241+
if inner.frames != 1 {
242+
t.Fatalf("OnResponseFrame did not forward to inner plugin: frames=%d, want 1", inner.frames)
243+
}
244+
// The config is still surfaced through the streaming wrapper.
245+
if rc, ok := cp.(RawConfigProvider); !ok || string(rc.RawConfig()) != `{}` {
246+
t.Fatalf("streaming wrapper lost RawConfig: ok=%v", ok)
247+
}
248+
}
249+
250+
// TestWrapConfigured_NonStreamingNotPromoted confirms the fix preserves
251+
// HasStreamingResponders semantics: a Configurable plugin that does NOT
252+
// implement StreamingResponder must NOT become one through the wrapper, so the
253+
// listener's streaming-vs-buffered path selection is unchanged.
254+
func TestWrapConfigured_NonStreamingNotPromoted(t *testing.T) {
255+
cp := WrapConfigured(&fakePlugin{name: "token-exchange"}, json.RawMessage(`{}`))
256+
if _, ok := cp.(StreamingResponder); ok {
257+
t.Fatal("wrapping a non-streaming plugin must not make it a StreamingResponder")
258+
}
259+
}

authbridge/authlib/plugins/mcpparser/plugin.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -304,18 +304,22 @@ func (p *MCPParser) OnResponseFrame(_ context.Context, pctx *pipeline.Context, f
304304
return pipeline.Action{Type: pipeline.Continue}
305305
}
306306

307-
var rpc jsonRPCResponse
308-
if err := json.Unmarshal(frame, &rpc); err != nil {
309-
// A malformed JSON-RPC message in a stream is unusual but
310-
// recoverable — skip it and keep going. Don't return Reject
311-
// because the listener is forwarding bytes regardless of what
312-
// we say (record-only contract).
313-
slog.Debug("mcp-parser: malformed frame, skipping", "error", err, "frameLen", len(frame))
314-
return pipeline.Action{Type: pipeline.Continue}
315-
}
316-
if rpc.Result == nil && rpc.Error == nil {
317-
// Notifications, heartbeats, or other JSON-RPC shapes without
318-
// a result/error envelope — silently skip (no observation).
307+
// Parse SSE-aware: a frame is normally one SSE event's pre-stripped
308+
// JSON-RPC payload (streaming path), but the buffered dispatch hands the
309+
// WHOLE response body as a single last=true frame — and for a Streamable
310+
// HTTP server that body is a raw `data: {...}` SSE blob. A bare
311+
// json.Unmarshal fails on that blob and silently drops the result (the
312+
// tools/list-response-not-recorded bug). parseMCPResponse tries a direct
313+
// decode first (clean frame), then scans `data:` lines (raw SSE blob), so
314+
// both shapes record correctly.
315+
rpc, ok := parseMCPResponse(frame)
316+
if !ok {
317+
// Notifications, heartbeats, malformed frames, or other shapes with
318+
// no JSON-RPC result/error envelope — nothing to record per-frame.
319+
// The end-of-stream last=true call records a Skip if no envelope was
320+
// ever seen, so the response row still pairs with its request.
321+
slog.Debug("mcp-parser: response frame carried no JSON-RPC result/error",
322+
"frameLen", len(frame), "frame", parsercommon.Truncate(string(frame), parsercommon.DebugBodyMax))
319323
return pipeline.Action{Type: pipeline.Continue}
320324
}
321325

authbridge/authlib/plugins/mcpparser/streaming_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,32 @@ func TestMCPParser_OnResponseFrame_EmptyStreamRecordsSkip(t *testing.T) {
9797
}
9898
}
9999

100+
// TestMCPParser_OnResponseFrame_RawSSEBlob reproduces the live tools/list
101+
// bug: the forward proxy's buffered dispatch hands the WHOLE response body as
102+
// one last=true frame, and for a Streamable HTTP (MCP) server that body is a
103+
// raw "data: {...}" SSE blob — not pre-stripped JSON. The bare json.Unmarshal
104+
// failed on it and silently dropped the result (response recorded with no
105+
// result, no invocation). OnResponseFrame must parse the embedded JSON-RPC
106+
// result and record an observe.
107+
func TestMCPParser_OnResponseFrame_RawSSEBlob(t *testing.T) {
108+
p := NewMCPParser()
109+
pctx := &pipeline.Context{
110+
Extensions: pipeline.Extensions{MCP: &pipeline.MCPExtension{Method: "tools/list"}},
111+
}
112+
blob := []byte("event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tools\":[{\"name\":\"get_weather\"}]}}\n\n")
113+
pctx.SetCurrentPlugin("mcp-parser", pipeline.InvocationPhaseResponse)
114+
p.OnResponseFrame(context.Background(), pctx, blob, true)
115+
pctx.ClearCurrentPlugin()
116+
117+
if pctx.Extensions.MCP.Result == nil {
118+
t.Fatal("Result not populated from raw SSE blob — the bug")
119+
}
120+
inv := pctx.Extensions.Invocations
121+
if inv == nil || len(inv.Inbound)+len(inv.Outbound) == 0 {
122+
t.Fatal("no mcp-parser observe recorded for the SSE response")
123+
}
124+
}
125+
100126
func TestMCPParser_OnResponseFrame_MalformedFrameSkipped(t *testing.T) {
101127
p := NewMCPParser()
102128
pctx := &pipeline.Context{

0 commit comments

Comments
 (0)