From e21c41f1277af26ea0c05082acf45c16f217c17f Mon Sep 17 00:00:00 2001 From: Emre K <110906681+kocaemre@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:50:28 +0200 Subject: [PATCH] Use conformant Accept in dual-era e2e Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com> --- test/e2e/dual_era_mixing_test.go | 24 ++++++------- test/e2e/mcp_raw_client.go | 18 ++++------ test/e2e/mcp_raw_client_test.go | 2 +- test/e2e/vmcp_dual_era_test.go | 62 ++++++++++++++++++++++++++------ 4 files changed, 70 insertions(+), 36 deletions(-) diff --git a/test/e2e/dual_era_mixing_test.go b/test/e2e/dual_era_mixing_test.go index 9337bb72cc..5bf7328b36 100644 --- a/test/e2e/dual_era_mixing_test.go +++ b/test/e2e/dual_era_mixing_test.go @@ -49,15 +49,9 @@ import ( // session id issued by initialize while Modern never sends one at all (see // runMixedRound's assertions). -// Deliberately NOT setting "Accept: application/json, text/event-stream" -// (mcp_raw_client.go's Send doc comment floats it for a live streamable-HTTP -// endpoint): verified empirically that doing so makes THIS proxy switch to -// its SSE response path (handleSingleRequestSSE in streamable_proxy.go, -// triggered by any Accept header containing "text/event-stream"), and the -// raw client's populateEnvelope only parses a plain JSON object body, not an -// SSE-framed one -- so nonce extraction silently returns "". Without the -// header the proxy returns a plain JSON response, which is what every other -// spec in this suite (and this one) relies on. +// Every request in this spec sends the streamable-HTTP Accept header both MCP +// revisions require. The proxy may answer Legacy requests as SSE, and +// e2e.RawMCPClient parses SSE-framed POST responses before nonce extraction. var _ = Describe("Dual-Era Concurrent Mixing", Label("proxy", "stateless", "dual-era", "e2e"), Serial, func() { const ( legacySessions = 3 @@ -119,7 +113,7 @@ var _ = Describe("Dual-Era Concurrent Mixing", Label("proxy", "stateless", "dual // mcp_raw_client_golden_test.go, which only covers Modern). sessionIDs := make([]string, legacySessions) for i := range sessionIDs { - initReq := e2e.NewLegacyInitializeRequest(fmt.Sprintf("legacy-client-%d", i), "1.0") + initReq := e2e.NewLegacyInitializeRequest(fmt.Sprintf("legacy-client-%d", i), "1.0").WithStreamableAccept() resp, err := client.Send(context.Background(), proxyURL, initReq) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(200)) @@ -138,7 +132,7 @@ var _ = Describe("Dual-Era Concurrent Mixing", Label("proxy", "stateless", "dual "arguments": map[string]any{"input": "shouldneverrun"}, }) Expect(err).ToNot(HaveOccurred()) - bogusReq.WithSessionID("bogus-" + uuid.NewString()) + bogusReq.WithSessionID("bogus-" + uuid.NewString()).WithStreamableAccept() resp, err := client.Send(context.Background(), proxyURL, bogusReq) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).ToNot(Equal(200), "a bogus/unregistered Mcp-Session-Id must be rejected") @@ -230,15 +224,19 @@ func runMixedRound( if err != nil { return nil, err } - return req.WithSessionID(sid), nil + return req.WithSessionID(sid).WithStreamableAccept(), nil }) } for i := 0; i < modernPerRound; i++ { fire(len(sessionIDs)+i, "modern", func() (*e2e.RawRequest, error) { - return e2e.NewModernRequest("tools/call", map[string]any{ + req, err := e2e.NewModernRequest("tools/call", map[string]any{ "name": "echo", "arguments": map[string]any{"input": "mixcheck"}, }) + if err != nil { + return nil, err + } + return req.WithStreamableAccept(), nil }) } diff --git a/test/e2e/mcp_raw_client.go b/test/e2e/mcp_raw_client.go index 455ea166cd..a91efd90af 100644 --- a/test/e2e/mcp_raw_client.go +++ b/test/e2e/mcp_raw_client.go @@ -174,12 +174,9 @@ func (r *RawRequest) WithClientInfo(name, version string) *RawRequest { } // WithStreamableAccept sets "Accept: application/json, text/event-stream". -// A real go-sdk streamable-HTTP server rejects a POST without this header -// (HTTP 400), so requests bound for a real backend (e.g. the k8s tier) must -// set it. Requests to the ToolHive proxy still omit it by convention: the -// proxy does not require it, and omitting it keeps responses plain JSON (the -// client parses SSE responses too -- see sseResponsePayload -- and flipping -// proxy-bound requests to the conformant Accept is tracked in #6104). +// A real streamable-HTTP MCP peer requires this header on POSTs. ToolHive's +// proxy does not require it, but conformant proxy-bound specs should set it; +// RawMCPClient parses both plain JSON and SSE-framed POST responses. func (r *RawRequest) WithStreamableAccept() *RawRequest { return r.SetHeader("Accept", "application/json, text/event-stream") } @@ -317,11 +314,10 @@ func NewRawMCPClient(timeout time.Duration) (*RawMCPClient, error) { }, nil } -// Send marshals req and POSTs it to url. No Accept header is set by default: -// the ToolHive proxy returns a plain JSON body without it, and switches to an -// SSE response body whenever Accept lists text/event-stream. A request bound -// for a REAL streamable-HTTP MCP backend -- which rejects a POST lacking that -// Accept with HTTP 400 -- should opt in via req.WithStreamableAccept(). +// Send marshals req and POSTs it to url. No Accept header is set by default. +// Streamable-HTTP requests that need the conformant POST Accept header should +// opt in via req.WithStreamableAccept(). ToolHive's proxy returns plain JSON +// without it and may switch to SSE framing when Accept lists text/event-stream. // // Either way the JSON-RPC envelope is parsed into the RawResponse: a // Content-Type: text/event-stream body has its response event extracted first diff --git a/test/e2e/mcp_raw_client_test.go b/test/e2e/mcp_raw_client_test.go index 76e3c21542..b51a11f579 100644 --- a/test/e2e/mcp_raw_client_test.go +++ b/test/e2e/mcp_raw_client_test.go @@ -333,7 +333,7 @@ func TestRawClientSend(t *testing.T) { _, err = client.Send(context.Background(), server.URL, req) require.NoError(t, err) require.Empty(t, captured.headers.Get("Accept"), - "no Accept by default: keeps proxy responses plain JSON; conformant-Accept flip tracked in #6104") + "no Accept by default: callers opt in when they need the conformant streamable-HTTP Accept header") withAccept, err := NewModernRequest("tools/list", nil) require.NoError(t, err) diff --git a/test/e2e/vmcp_dual_era_test.go b/test/e2e/vmcp_dual_era_test.go index ec1fc83941..37b2880e86 100644 --- a/test/e2e/vmcp_dual_era_test.go +++ b/test/e2e/vmcp_dual_era_test.go @@ -43,13 +43,10 @@ import ( // (mirrors the same finding already documented in dual_era_mixing_test.go // for the single-server transparent proxy). // -// Deliberate harness choice: no request in this file sets -// "Accept: application/json, text/event-stream" (a MUST on both revisions). -// e2e.RawMCPClient now parses SSE-framed POST responses (sseResponsePayload, -// mcp_raw_client.go), but these specs still omit the header so Legacy -// responses stay plain JSON; flipping them to the conformant Accept is -// tracked in #6104. Until then the bridge is proven here only under a -// non-conformant Accept header. +// Every request in this file sends the streamable-HTTP Accept header both MCP +// revisions require: "application/json, text/event-stream". Legacy responses +// may therefore be SSE-framed by the go-sdk transport, while Modern responses +// remain plain JSON; e2e.RawMCPClient parses both shapes before assertions. var _ = Describe("vMCP Dual-Era Bridge", Label("vmcp", "dual-era", "e2e"), Serial, func() { Context("one Legacy and one Modern backend in the same group", func() { var ( @@ -154,6 +151,25 @@ var _ = Describe("vMCP Dual-Era Bridge", Label("vmcp", "dual-era", "e2e"), Seria assertBridgedCall(resp, "legacytomodern", "") }) + It("preserves response content under plain JSON and SSE-capable Accept", func() { + ctx := context.Background() + sessionID := legacyInitialize(ctx, rawClient, vMCPURL) + + legacyPlain := legacyToolCallWithAccept(ctx, rawClient, vMCPURL, sessionID, legacyToolName, "legacyaccept", false) + legacyConformant := legacyToolCallWithAccept(ctx, rawClient, vMCPURL, sessionID, legacyToolName, "legacyaccept", true) + assertBridgedCall(legacyPlain, "legacyaccept", "") + assertBridgedCall(legacyConformant, "legacyaccept", "") + Expect(legacyConformant.Result).To(MatchJSON(legacyPlain.Result), + "Legacy content must be identical whether the transport frames the response as plain JSON or SSE") + + modernPlain := modernToolCallWithAccept(ctx, rawClient, vMCPURL, modernToolName, "modernaccept", false) + modernConformant := modernToolCallWithAccept(ctx, rawClient, vMCPURL, modernToolName, "modernaccept", true) + assertBridgedCall(modernPlain, "modernaccept", "complete") + assertBridgedCall(modernConformant, "modernaccept", "complete") + Expect(modernConformant.Result).To(MatchJSON(modernPlain.Result), + "Modern content must be identical under both Accept values") + }) + It("never cross-delivers between two concurrent principals across mismatched backends", func() { // Principal A: a Legacy session calling the Legacy backend, perEra // times per round. Principal B: a stateless Modern client calling the @@ -218,7 +234,7 @@ var _ = Describe("vMCP Dual-Era Bridge", Label("vmcp", "dual-era", "e2e"), Seria // not accepted. func legacyInitialize(ctx context.Context, client *e2e.RawMCPClient, url string) string { GinkgoHelper() - req := e2e.NewLegacyInitializeRequest("dual-era-legacy-client", "1.0") + req := e2e.NewLegacyInitializeRequest("dual-era-legacy-client", "1.0").WithStreamableAccept() resp, err := client.Send(ctx, url, req) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(200), "body: %s", resp.Body) @@ -228,7 +244,7 @@ func legacyInitialize(ctx context.Context, client *e2e.RawMCPClient, url string) notifyReq, err := e2e.NewLegacyRequest("notifications/initialized", nil) Expect(err).ToNot(HaveOccurred()) // WithID(nil) omits "id" entirely, making this a true JSON-RPC notification. - notifyReq.WithID(nil).WithSessionID(sessionID).SetHeader(e2e.HeaderMCPProtocolVersion, mcpparser.MCPVersionLegacy) + notifyReq.WithID(nil).WithSessionID(sessionID).SetHeader(e2e.HeaderMCPProtocolVersion, mcpparser.MCPVersionLegacy).WithStreamableAccept() notifyResp, err := client.Send(ctx, url, notifyReq) Expect(err).ToNot(HaveOccurred()) Expect(notifyResp.StatusCode).To(Equal(202), "body: %s", notifyResp.Body) @@ -241,6 +257,13 @@ func legacyInitialize(ctx context.Context, client *e2e.RawMCPClient, url string) // every post-initialize request. func legacyToolCall( ctx context.Context, client *e2e.RawMCPClient, url, sessionID, toolName, input string, +) *e2e.RawResponse { + GinkgoHelper() + return legacyToolCallWithAccept(ctx, client, url, sessionID, toolName, input, true) +} + +func legacyToolCallWithAccept( + ctx context.Context, client *e2e.RawMCPClient, url, sessionID, toolName, input string, streamableAccept bool, ) *e2e.RawResponse { GinkgoHelper() req, err := e2e.NewLegacyRequest("tools/call", map[string]any{ @@ -249,6 +272,9 @@ func legacyToolCall( }) Expect(err).ToNot(HaveOccurred()) req.WithSessionID(sessionID).SetHeader(e2e.HeaderMCPProtocolVersion, mcpparser.MCPVersionLegacy) + if streamableAccept { + req.WithStreamableAccept() + } resp, err := client.Send(ctx, url, req) Expect(err).ToNot(HaveOccurred()) return resp @@ -256,12 +282,22 @@ func legacyToolCall( // modernToolCall sends a stateless Modern tools/call for toolName. func modernToolCall(ctx context.Context, client *e2e.RawMCPClient, url, toolName, input string) *e2e.RawResponse { + GinkgoHelper() + return modernToolCallWithAccept(ctx, client, url, toolName, input, true) +} + +func modernToolCallWithAccept( + ctx context.Context, client *e2e.RawMCPClient, url, toolName, input string, streamableAccept bool, +) *e2e.RawResponse { GinkgoHelper() req, err := e2e.NewModernRequest("tools/call", map[string]any{ "name": toolName, "arguments": map[string]any{"input": input}, }) Expect(err).ToNot(HaveOccurred()) + if streamableAccept { + req.WithStreamableAccept() + } resp, err := client.Send(ctx, url, req) Expect(err).ToNot(HaveOccurred()) return resp @@ -332,15 +368,19 @@ func fireConcurrentBridgedBatch( if err != nil { return nil, err } - return req.WithSessionID(sessionID).SetHeader(e2e.HeaderMCPProtocolVersion, mcpparser.MCPVersionLegacy), nil + return req.WithSessionID(sessionID).SetHeader(e2e.HeaderMCPProtocolVersion, mcpparser.MCPVersionLegacy).WithStreamableAccept(), nil }) } for i := 0; i < perEra; i++ { fire(perEra+i, func() (*e2e.RawRequest, error) { - return e2e.NewModernRequest("tools/call", map[string]any{ + req, err := e2e.NewModernRequest("tools/call", map[string]any{ "name": modernClientTool, "arguments": map[string]any{"input": "concurrencycheck"}, }) + if err != nil { + return nil, err + } + return req.WithStreamableAccept(), nil }) }