From 193a1f986aec3627e398a8d33b98060bad10a54e Mon Sep 17 00:00:00 2001 From: ZAIN Date: Tue, 30 Dec 2025 08:51:39 +0900 Subject: [PATCH 1/4] fix(client): handle graceful SSE stream termination without error reporting Fixes #1211 When a server closes an SSE connection gracefully (e.g., due to timeout or polling), the client would incorrectly report 'TypeError: terminated' as an error. This is expected behavior for long-polling scenarios where the server periodically closes connections. This change detects graceful termination (TypeError with message 'terminated' or 'body stream') and suppresses unnecessary error reporting while still allowing the reconnection logic to proceed. --- packages/client/src/client/streamableHttp.ts | 11 +- .../issues/issue-1211-sse-terminated.test.ts | 120 ++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 test/integration/test/issues/issue-1211-sse-terminated.test.ts diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 91709a9a6..4e04491bd 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -392,8 +392,15 @@ export class StreamableHTTPClientTransport implements Transport { ); } } catch (error) { - // Handle stream errors - likely a network disconnect - this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); + // Handle stream errors - check if this is a normal termination or an actual error + // "TypeError: terminated" occurs when the server closes the connection gracefully + // This is expected behavior for polling/timeout scenarios and should be handled quietly + const isGracefulTermination = error instanceof TypeError && + (error.message === 'terminated' || error.message.includes('body stream')); + + if (!isGracefulTermination) { + this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); + } // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing // Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID) diff --git a/test/integration/test/issues/issue-1211-sse-terminated.test.ts b/test/integration/test/issues/issue-1211-sse-terminated.test.ts new file mode 100644 index 000000000..20607c1a4 --- /dev/null +++ b/test/integration/test/issues/issue-1211-sse-terminated.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for SSE stream graceful termination handling + * + * Issue #1211: SSE stream disconnected: TypeError: terminated + * https://github.com/modelcontextprotocol/typescript-sdk/issues/1211 + * + * This test verifies that graceful stream termination (TypeError: terminated) + * is handled quietly without reporting unnecessary errors. + */ +import { randomUUID } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { + McpServer, + StreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; + +describe('SSE Stream Graceful Termination (Issue #1211)', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let client: Client; + let clientTransport: StreamableHTTPClientTransport; + let baseUrl: URL; + + beforeEach(async () => { + server = createServer(); + mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: {} + } + ); + + serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + await mcpServer.connect(serverTransport); + + server.on('request', async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + baseUrl = await listenOnRandomPort(server); + }); + + afterEach(async () => { + await client?.close().catch(() => {}); + await mcpServer?.close().catch(() => {}); + server?.close(); + }); + + test('should not report error when server closes SSE stream gracefully', async () => { + const errors: Error[] = []; + + clientTransport = new StreamableHTTPClientTransport(baseUrl); + client = new Client( + { name: 'test-client', version: '1.0.0' }, + { capabilities: {} } + ); + + // Track any errors + clientTransport.onerror = (error) => { + errors.push(error); + }; + + await client.connect(clientTransport); + + // Verify connection is working + expect(client.getServerCapabilities()).toBeDefined(); + + // Close the server-side transport (simulating graceful termination) + await serverTransport.close(); + + // Give some time for any error events to propagate + await new Promise(resolve => setTimeout(resolve, 100)); + + // Should not have any "TypeError: terminated" errors reported + const terminatedErrors = errors.filter( + e => e.message.includes('terminated') || e.message.includes('body stream') + ); + + expect(terminatedErrors).toHaveLength(0); + }); + + test('should handle server shutdown without reporting termination errors', async () => { + const errors: Error[] = []; + + clientTransport = new StreamableHTTPClientTransport(baseUrl); + client = new Client( + { name: 'test-client', version: '1.0.0' }, + { capabilities: {} } + ); + + clientTransport.onerror = (error) => { + errors.push(error); + }; + + await client.connect(clientTransport); + + // Wait a bit to simulate some activity + await new Promise(resolve => setTimeout(resolve, 50)); + + // Close server abruptly + await serverTransport.close(); + server.close(); + + await new Promise(resolve => setTimeout(resolve, 100)); + + // No terminated errors should be reported + const terminatedErrors = errors.filter( + e => e.message.includes('terminated') || e.message.includes('body stream') + ); + + expect(terminatedErrors).toHaveLength(0); + }); +}); From b1e21b549e3ab4e59c97e45bbd4f357583bfb1ae Mon Sep 17 00:00:00 2001 From: ZAIN Date: Tue, 30 Dec 2025 08:59:07 +0900 Subject: [PATCH 2/4] chore: add changeset for SSE graceful termination fix --- .changeset/fix-sse-terminated.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-sse-terminated.md diff --git a/.changeset/fix-sse-terminated.md b/.changeset/fix-sse-terminated.md new file mode 100644 index 000000000..7134a81ba --- /dev/null +++ b/.changeset/fix-sse-terminated.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/client": patch +--- + +Fix SSE stream graceful termination being incorrectly reported as an error. When a server closes an SSE connection gracefully (e.g., due to timeout), the client no longer reports "TypeError: terminated" via onerror. This reduces log noise while preserving reconnection behavior. From 580029f83284113e93acd497757c576812b64183 Mon Sep 17 00:00:00 2001 From: ZAIN Date: Tue, 30 Dec 2025 09:00:43 +0900 Subject: [PATCH 3/4] style: fix prettier formatting --- packages/client/src/client/streamableHttp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 4e04491bd..437b95918 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -395,8 +395,8 @@ export class StreamableHTTPClientTransport implements Transport { // Handle stream errors - check if this is a normal termination or an actual error // "TypeError: terminated" occurs when the server closes the connection gracefully // This is expected behavior for polling/timeout scenarios and should be handled quietly - const isGracefulTermination = error instanceof TypeError && - (error.message === 'terminated' || error.message.includes('body stream')); + const isGracefulTermination = + error instanceof TypeError && (error.message === 'terminated' || error.message.includes('body stream')); if (!isGracefulTermination) { this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); From 377936548e9d82a1e313bf8e5d55cf8a08a4bfeb Mon Sep 17 00:00:00 2001 From: ZAIN Date: Tue, 30 Dec 2025 09:01:58 +0900 Subject: [PATCH 4/4] style: fix test file prettier formatting --- .../issues/issue-1211-sse-terminated.test.ts | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/test/integration/test/issues/issue-1211-sse-terminated.test.ts b/test/integration/test/issues/issue-1211-sse-terminated.test.ts index 20607c1a4..c55fbb1ba 100644 --- a/test/integration/test/issues/issue-1211-sse-terminated.test.ts +++ b/test/integration/test/issues/issue-1211-sse-terminated.test.ts @@ -11,10 +11,7 @@ import { randomUUID } from 'node:crypto'; import { createServer, type Server } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { - McpServer, - StreamableHTTPServerTransport -} from '@modelcontextprotocol/server'; +import { McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; describe('SSE Stream Graceful Termination (Issue #1211)', () => { @@ -57,13 +54,10 @@ describe('SSE Stream Graceful Termination (Issue #1211)', () => { const errors: Error[] = []; clientTransport = new StreamableHTTPClientTransport(baseUrl); - client = new Client( - { name: 'test-client', version: '1.0.0' }, - { capabilities: {} } - ); + client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); // Track any errors - clientTransport.onerror = (error) => { + clientTransport.onerror = error => { errors.push(error); }; @@ -79,9 +73,7 @@ describe('SSE Stream Graceful Termination (Issue #1211)', () => { await new Promise(resolve => setTimeout(resolve, 100)); // Should not have any "TypeError: terminated" errors reported - const terminatedErrors = errors.filter( - e => e.message.includes('terminated') || e.message.includes('body stream') - ); + const terminatedErrors = errors.filter(e => e.message.includes('terminated') || e.message.includes('body stream')); expect(terminatedErrors).toHaveLength(0); }); @@ -90,12 +82,9 @@ describe('SSE Stream Graceful Termination (Issue #1211)', () => { const errors: Error[] = []; clientTransport = new StreamableHTTPClientTransport(baseUrl); - client = new Client( - { name: 'test-client', version: '1.0.0' }, - { capabilities: {} } - ); + client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); - clientTransport.onerror = (error) => { + clientTransport.onerror = error => { errors.push(error); }; @@ -111,9 +100,7 @@ describe('SSE Stream Graceful Termination (Issue #1211)', () => { await new Promise(resolve => setTimeout(resolve, 100)); // No terminated errors should be reported - const terminatedErrors = errors.filter( - e => e.message.includes('terminated') || e.message.includes('body stream') - ); + const terminatedErrors = errors.filter(e => e.message.includes('terminated') || e.message.includes('body stream')); expect(terminatedErrors).toHaveLength(0); });