diff --git a/backend/sse.go b/backend/sse.go index 41ae583..0b08570 100644 --- a/backend/sse.go +++ b/backend/sse.go @@ -151,6 +151,16 @@ func (b *SSEBackend) Start(ctx context.Context) error { if resp.StatusCode >= 300 { defer resp.Body.Close() cancel() + // 405 on the GET stream is the signature of a streamable-http + // endpoint: that transport serves MCP on POST and the spec lets it + // refuse the optional GET stream outright. Without naming the fix + // the operator sees only the server's "use POST" text relayed + // through a 502, which reads like a gateway bug rather than a + // one-word connection setting. + if resp.StatusCode == http.StatusMethodNotAllowed { + return fmt.Errorf("sse backend %s: stream http 405: %s; endpoint speaks streamable-http, set transport: streamable-http", + b.name, readExcerpt(resp.Body)) + } return fmt.Errorf("sse backend %s: stream http %d: %s", b.name, resp.StatusCode, readExcerpt(resp.Body)) } if mt := mediaType(resp.Header.Get("Content-Type")); mt != "text/event-stream" { diff --git a/backend/sse_test.go b/backend/sse_test.go index 3e0a3e5..d1848b0 100644 --- a/backend/sse_test.go +++ b/backend/sse_test.go @@ -263,6 +263,27 @@ func TestSSEWrongContentTypeRejected(t *testing.T) { } } +// A streamable-http endpoint refuses the SSE transport's GET stream with 405. +// Linear does exactly this, and the bare relayed message ("Method not allowed. +// Use POST for MCP requests.") reaches the operator as a 502 that names no +// fix. The error must say which setting to change, as the content-type +// mismatch above already does. +func TestSSEMethodNotAllowedHintsStreamableHTTP(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprint(w, `{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not allowed. Use POST for MCP requests."},"id":null}`) + })) + defer srv.Close() + + b := NewSSE("test", config.Backend{Transport: "sse", URL: srv.URL}, nil) + err := b.Start(context.Background()) + if err == nil || !strings.Contains(err.Error(), "streamable-http") { + t.Fatalf("expected 405 to hint at streamable-http, got: %v", err) + } +} + func TestSSEUnauthorizedSurfacesChallenge(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/gateway/revision.go b/gateway/revision.go index f7ed5ea..483ae1a 100644 --- a/gateway/revision.go +++ b/gateway/revision.go @@ -27,31 +27,42 @@ func classify(r *http.Request, env *jsonrpc.Message) (mcp.Revision, *jsonrpc.Mes meta := mcp.ParseMeta(env.Params) header := r.Header.Get(mcp.HeaderProtocolVersion) - // Neither the header nor _meta claims a revision: a Legacy client, which - // never sent either. Note a Legacy client cannot reach the Modern arm by - // accident, so this branch stays the common path. - if !meta.HasModernSignal() && header == "" { + // MCP-Protocol-Version is not a Modern invention. Since 2025-06-18 the + // spec requires a Legacy client to echo the version it negotiated at + // `initialize` on every subsequent HTTP request, so the header alone + // says nothing about the era — only _meta does. Reading the header as a + // Modern signal rejected the very clients that follow the spec most + // closely: claude-code negotiates 2025-11-25, echoes it as required, and + // its next message (notifications/initialized) came back + // "-32022 unsupported protocol version" naming 2025-11-25 as both the + // requested and the only supported version. + // + // So the era is decided by _meta, and the header is only cross-checked + // against it. + if !meta.HasModernSignal() { + if header != "" && header != mcp.VersionLegacy && header != mcp.VersionModern { + // A Legacy-shaped request echoing a version this gateway does + // not speak. Still worth refusing, but for the right reason. + return mcp.RevisionLegacy, unsupportedVersion(env.ID, header) + } return mcp.RevisionLegacy, nil } - // One of the two claims a revision. From here a disagreement is an error - // rather than a silent downgrade: a client that mirrors its version into - // a header and then contradicts it in the body is exactly the split-brain - // the spec's header validation exists to catch. - if header != "" && meta.ProtocolVersion != "" && header != meta.ProtocolVersion { + // _meta claims a revision. From here a disagreement with the mirrored + // header is an error rather than a silent downgrade: a client that + // mirrors its version into a header and then contradicts it in the body + // is exactly the split-brain the spec's header validation exists to + // catch. + if header != "" && header != meta.ProtocolVersion { return mcp.RevisionLegacy, headerMismatch(env.ID, fmt.Sprintf("%s header %q does not match _meta protocolVersion %q", mcp.HeaderProtocolVersion, header, meta.ProtocolVersion)) } - claimed := meta.ProtocolVersion - if claimed == "" { - claimed = header - } - if claimed != mcp.VersionModern { + if meta.ProtocolVersion != mcp.VersionModern { // A version the gateway does not serve. Naming what it does serve is // what lets a dual-era client retry instead of failing outright. - return mcp.RevisionLegacy, unsupportedVersion(env.ID, claimed) + return mcp.RevisionLegacy, unsupportedVersion(env.ID, meta.ProtocolVersion) } if msg := validateModernHeaders(r, env); msg != nil { diff --git a/gateway/revision_test.go b/gateway/revision_test.go index a545060..957a94c 100644 --- a/gateway/revision_test.go +++ b/gateway/revision_test.go @@ -162,6 +162,55 @@ func TestClassifyUnknownVersionNamesSupported(t *testing.T) { } } +// A Legacy client must echo the negotiated version on every request after +// `initialize` (spec, since 2025-06-18). That header alone is not a Modern +// signal, and treating it as one rejected the clients that follow the spec +// most closely: claude-code negotiates 2025-11-25, echoes it as required, and +// its very next message came back "-32022 unsupported protocol version" +// naming 2025-11-25 as both requested and the only supported version. +func TestClassifyLegacyEchoedVersionHeaderIsNotModern(t *testing.T) { + // The exact message claude-code sends after a successful handshake. + env, _, r := req(t, `{"method":"notifications/initialized","jsonrpc":"2.0"}`, + mcp.HeaderProtocolVersion, mcp.VersionLegacy, + sessionHeader, "cb6be304f0e1ba50511dddd353de1c5f") + rev, errMsg := classify(r, env) + if rev != mcp.RevisionLegacy || errMsg != nil { + t.Fatalf("got rev=%v err=%v, want legacy/nil", rev, errMsg) + } + + // Same for an ordinary request, which additionally carries no Mcp-Method + // header — the Modern arm would have demanded one. + env, _, r = req(t, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + mcp.HeaderProtocolVersion, mcp.VersionLegacy) + if rev, errMsg := classify(r, env); rev != mcp.RevisionLegacy || errMsg != nil { + t.Fatalf("got rev=%v err=%v, want legacy/nil", rev, errMsg) + } +} + +// The header still cannot smuggle in a revision nobody serves. A Legacy-shaped +// request echoing an unknown version is refused, but for the honest reason. +func TestClassifyLegacyEchoedUnknownVersionRefused(t *testing.T) { + env, _, r := req(t, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + mcp.HeaderProtocolVersion, "2099-01-01") + _, errMsg := classify(r, env) + if errMsg == nil || errMsg.Error.Code != mcp.CodeUnsupportedProtocolVersion { + t.Fatalf("got %v, want -32022", errMsg) + } +} + +// Only _meta puts a request on the Modern path. A header-only Modern claim is +// a Legacy client echoing a version it negotiated, not a stateless request: +// it carries none of the per-request metadata the Modern arm reads. +func TestClassifyModernRequiresMetaNotJustHeader(t *testing.T) { + env, _, r := req(t, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + mcp.HeaderProtocolVersion, mcp.VersionModern, + mcp.HeaderMethod, "tools/list") + rev, errMsg := classify(r, env) + if rev != mcp.RevisionLegacy || errMsg != nil { + t.Fatalf("got rev=%v err=%v, want legacy/nil", rev, errMsg) + } +} + func TestRefuseModernNamesBlockers(t *testing.T) { // The refusal must be actionable: an operator reading the response needs // to know which controls are keeping the gateway on the Legacy path.