Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-sse-terminated.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 9 additions & 2 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
107 changes: 107 additions & 0 deletions test/integration/test/issues/issue-1211-sse-terminated.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* 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);
});
});
Loading