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:
parent
32404652e6
commit
c68ad9a4e6
1 changed files with 75 additions and 9 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue