Skip to content

Commit dcb0df4

Browse files
fix(core): stop MCP SSE error reconnect loops (#39671)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
1 parent 02f3504 commit dcb0df4

2 files changed

Lines changed: 56 additions & 1 deletion

File tree

packages/core/test/mcp.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from "node:path"
22
import { describe, expect, test } from "bun:test"
33
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
4+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
45
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
56
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
67
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
@@ -559,6 +560,42 @@ test("lists, reads, and reports MCP resource changes", async () => {
559560
)
560561
})
561562

563+
test("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
564+
let requests = 0
565+
const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
566+
fetch: async () => {
567+
requests += 1
568+
return new Response(
569+
new ReadableStream({
570+
start(controller) {
571+
controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
572+
controller.enqueue(
573+
new TextEncoder().encode(
574+
'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
575+
),
576+
)
577+
controller.close()
578+
},
579+
}),
580+
{ status: 200, headers: { "content-type": "text/event-stream" } },
581+
)
582+
},
583+
reconnectionOptions: {
584+
initialReconnectionDelay: 1,
585+
maxReconnectionDelay: 1,
586+
reconnectionDelayGrowFactor: 1,
587+
maxRetries: 2,
588+
},
589+
})
590+
591+
await transport.start()
592+
await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
593+
await Bun.sleep(25)
594+
await transport.close()
595+
596+
expect(requests).toBe(1)
597+
})
598+
562599
test("skips MCP resource requests when the capability is absent", async () => {
563600
await Effect.runPromise(
564601
Effect.scoped(

patches/@modelcontextprotocol%2Fsdk@1.29.0.patch

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.
141141
index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644
142142
--- a/dist/cjs/client/streamableHttp.js
143143
+++ b/dist/cjs/client/streamableHttp.js
144+
@@ -204,7 +204,7 @@ class StreamableHTTPClientTransport {
145+
if (!event.event || event.event === 'message') {
146+
try {
147+
const message = types_js_1.JSONRPCMessageSchema.parse(JSON.parse(event.data));
148+
- if ((0, types_js_1.isJSONRPCResultResponse)(message)) {
149+
+ if ((0, types_js_1.isJSONRPCResultResponse)(message) || (0, types_js_1.isJSONRPCErrorResponse)(message)) {
150+
// Mark that we received a response - no need to reconnect for this request
151+
receivedResponse = true;
152+
if (replayMessageId !== undefined) {
144153
@@ -290,7 +290,38 @@ class StreamableHTTPClientTransport {
145154
this.onclose?.();
146155
}
@@ -518,10 +527,19 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88
518527
@@ -1,5 +1,5 @@
519528
import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js';
520529
-import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
521-
+import { isInitializedNotification, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
530+
+import { isInitializedNotification, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
522531
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js';
523532
import { EventSourceParserStream } from 'eventsource-parser/stream';
524533
// Default reconnection options for StreamableHTTP connections
534+
@@ -200,7 +200,7 @@ export class StreamableHTTPClientTransport {
535+
if (!event.event || event.event === 'message') {
536+
try {
537+
const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));
538+
- if (isJSONRPCResultResponse(message)) {
539+
+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
540+
// Mark that we received a response - no need to reconnect for this request
541+
receivedResponse = true;
542+
if (replayMessageId !== undefined) {
525543
@@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport {
526544
this.onclose?.();
527545
}

0 commit comments

Comments
 (0)