Skip to content
Merged
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
10 changes: 10 additions & 0 deletions backend/sse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
21 changes: 21 additions & 0 deletions backend/sse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
41 changes: 26 additions & 15 deletions gateway/revision.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions gateway/revision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading