Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions test/e2e/dual_era_mixing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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")
Expand Down Expand Up @@ -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
})
}

Expand Down
18 changes: 7 additions & 11 deletions test/e2e/mcp_raw_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/mcp_raw_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 51 additions & 11 deletions test/e2e/vmcp_dual_era_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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{
Expand All @@ -249,19 +272,32 @@ 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
}

// 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
Expand Down Expand Up @@ -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
})
}

Expand Down