modelcontextprotocol / modelcontextprotocol/typescript-sdk
StreamableHTTPClientTransport leaves POST requests pending when their SSE response ends or errors
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
Description
What happened?
With @modelcontextprotocol/sdk 1.26.0, a client request stays pending when its POST receives Content-Type: text/event-stream and that response body reaches EOF or errors before delivering the matching JSON-RPC response.
For an errored body, client.onerror fires immediately, but the request rejects only when its normal timeout expires. For a clean EOF, there is no transport error and the request still waits for its timeout.
There is no DELETE, aborted signal, timeout cancellation, or notifications/cancelled before the stream is lost. The request-scoped response leg simply ends after the server has accepted the POST. This distinguishes the case from #2691.
Reproduction
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const fetchImpl = async (_input, init) => {
if (init?.method === 'GET') return new Response(null, { status: 405 });
const message = JSON.parse(String(init?.body));
if (message.method === 'initialize') {
return new Response(JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: '2025-11-25',
capabilities: {},
serverInfo: { name: 'repro', version: '1.0.0' },
},
}), { headers: { 'content-type': 'application/json' } });
}
if (
message.method === 'notifications/initialized' ||
message.method === 'notifications/cancelled'
) {
return new Response(null, { status: 202 });
}
if (message.method === 'ping') {
const body = new ReadableStream({
start(controller) {
setTimeout(() => controller.error(new Error('response leg lost')), 0);
// Replace the line above with controller.close() to reproduce clean EOF.
},
});
return new Response(body, {
headers: { 'content-type': 'text/event-stream' },
});
}
throw new Error(`Unexpected method: ${message.method}`);
};
const transport = new StreamableHTTPClientTransport(
new URL('https://example.test/mcp'),
{ fetch: fetchImpl }
);
const client = new Client(
{ name: 'repro', version: '1.0.0' },
{ capabilities: {} }
);
client.onerror = error => console.log('onerror:', error.message);
await client.connect(transport);
const started = Date.now();
try {
await client.ping({ timeout: 300 });
} catch (error) {
console.log(`ping rejected after ${Date.now() - started}ms:`, error.message);
}
await client.close();
Observed with the errored stream:
onerror: SSE stream disconnected: Error: response leg lost
ping rejected after 301ms: MCP error -32001: Request timed out
A clean EOF produces only the timeout line.
Expected behavior
The request should reject promptly when its request-scoped SSE response can no longer deliver its JSON-RPC result or error. It should not wait for the unrelated protocol timeout.
Cause and suggested seam
send() starts _handleSseStream() without awaiting a request lifecycle. _handleSseStream() also launches its reader task without returning it. An SSE error therefore reaches only the global onerror callback, and EOF reaches neither the request nor onerror. Protocol has already seen send() resolve, so its timeout is the only remaining way to settle the request.
The transport needs a request-scoped lifecycle tied to the IDs in that POST. It should resolve after all matching responses arrive, transfer ownership when a stream is resumable, and reject if a non-resumable body ends first. send() can then expose that rejection to Protocol's existing send failure path. The same lifecycle should accept request cancellation so a resumed chain cannot outlive its caller.
JSON response mode already behaves this way because send() awaits response.json(), so a body failure rejects the request promptly.
Versions
Reproduced on Node 24.18.0 with:
@modelcontextprotocol/sdk1.26.0@modelcontextprotocol/sdk1.29.0
Related issues
- #2691: https://github.com/modelcontextprotocol/typescript-sdk/issues/2691, similar empty-SSE symptom, but its minimal reproduction intentionally cancelled via
DELETE; this report does not initiate cancellation. - #2098: https://github.com/modelcontextprotocol/typescript-sdk/issues/2098, standalone GET SSE reconnect exhaustion leaves later requests waiting for timeout.
- #2615: https://github.com/modelcontextprotocol/typescript-sdk/issues/2615, the inverse lifecycle leak, where a resumable request stream continues after the request has timed out.
- #731: https://github.com/modelcontextprotocol/typescript-sdk/issues/731, reconnect behavior for non-resumable standalone GET streams.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in the StreamableHTTPClientTransport entry point, client/streamableHttp.js, and trace send() through _handleSseStream(). Use the supplied reproduction to compare errored and clean EOF responses. Done means a request-scoped SSE response failure rejects promptly, while resumable streams and cancellation retain their intended lifecycle.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100