mcp: tolerate 404 on subscriptions/listen in stateless mode (#1193)

When connecting to remote stateless servers (such as GitHub's MCP
server), optional streams like subscriptions/listen may be rejected by
middleware with a plain-text HTTP 404 before reaching a JSON-RPC
handler.

Previously, checkResponse misclassified these 404s as lost stateful
sessions (ErrSessionMissing) and tore down the transport connection
because the error didn't wrap jsonrpc2.ErrRejected.

This change aligns with the MCP 2026-07-28 Streamable HTTP
specification:

1. Conditionally check for an active session ID before returning
ErrSessionMissing, since a 404 on a stateless connection cannot mean a
session was lost.

2. Wrap subscriptions/listen failures with ErrRejected (matching
server/discover) so the jsonrpc2 layer doesn't permanently break the
transport when encountering non-compliant plain-text 404s.

---

I have manually tested this change against GitHub's hosted MCP server
and confirmed that it fixes
https://github.com/github/github-mcp-server/issues/3129
This commit is contained in:
Scott Leggett 2026-08-25 20:27:37 +08:00 • committed by GitHub
commit c68ad9a4e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1965,3 +1965,67 @@ func TestStreamableClientHandlerErrorPropagation(t *testing.T) {
})
}
}
// TestStreamableClient_StatelessSubscriptionsListen404 verifies that in stateless mode
// (protocol 2026-07-28), when a server rejects subscriptions/listen with 404 (e.g. Copilot MCP server),
// the client connection survives and subsequent RPCs (such as tools/list) succeed.
func TestStreamableClient_StatelessSubscriptionsListen404(t *testing.T) {
ctx := t.Context()
var listenServed atomic.Bool
fake := &fakeStreamableServer{
t: t,
responses: fakeResponses{
{"POST", "", methodDiscover, ""}: {
header: header{
"Content-Type": "application/json",
},
wantProtocolVersion: protocolVersion20260728,
responseFunc: func(r *jsonrpc.Request) (string, int) {
return jsonBody(t, resp(r.ID.Raw().(int64), discoverResult, nil)), http.StatusOK
},
},
{"POST", "", methodSubscriptionsListen, ""}: {
header: header{"Content-Type": "text/plain"},
responseFunc: func(r *jsonrpc.Request) (string, int) {
listenServed.Store(true)
return "404 Not Found", http.StatusNotFound
},
optional: true,
},
{"POST", "", methodListTools, ""}: {
header: header{"Content-Type": "application/json"},
responseFunc: func(r *jsonrpc.Request) (string, int) {
return jsonBody(t, resp(r.ID.Raw().(int64), &ListToolsResult{Tools: []*Tool{}}, nil)), http.StatusOK
},
optional: true,
},
},
}
httpServer := httptest.NewServer(fake)
defer httpServer.Close()
transport := &StreamableClientTransport{Endpoint: httpServer.URL}
client := NewClient(testImpl, &ClientOptions{
ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) {},
})
session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("Connect failed: %v", err)
}
defer session.Close()
// tools/list must succeed even though subscriptions/listen returned 404.
res, err := session.ListTools(ctx, nil)
if err != nil {
t.Fatalf("ListTools failed: %v", err)
}
if res == nil {
t.Fatal("ListTools result is nil")
}
if !listenServed.Load() {
t.Fatal("subscriptions/listen was not called")
}
}