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/release-post-reply-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

`StreamableHTTPClientTransport.send()` and `SSEClientTransport.send()` no longer wait for the end of a successful POST reply whose body they do not read (a `202`, a reply to a message with no requests, a reply with an unexpected content type). A server or proxy that sends the headers and keeps the response open no longer stalls `send()` or `Client.connect()`. The unread body is now cancelled without waiting, instead of being read to its end; when part of it has not arrived yet, the connection is closed instead of reused. A body with no `cancel()`, as a custom `fetch` may return, is still read, but no longer waited for.
3 changes: 2 additions & 1 deletion packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
fetchLeavingRedirects,
fetchWithinOrigin,
JSONRPCMessageSchema,
releaseBody,
SdkError,
SdkErrorCode,
SdkHttpError,
Expand Down Expand Up @@ -491,7 +492,7 @@ export class SSEClientTransport implements Transport {
}

// Release connection - POST responses don't have content we need
await response.text?.().catch(() => {});
releaseBody(response);
} catch (error) {
this.onerror?.(error as Error);
throw error;
Expand Down
7 changes: 4 additions & 3 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
mcpNameSource,
mediaTypeEssence,
PROTOCOL_VERSION_META_KEY,
releaseBody,
SdkError,
SdkErrorCode,
SdkHttpError,
Expand Down Expand Up @@ -1164,7 +1165,7 @@ export class StreamableHTTPClientTransport implements Transport {

// If the response is 202 Accepted, there's no body to process
if (response.status === 202) {
await response.text?.().catch(() => {});
releaseBody(response);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: pre-existing: A server or proxy that holds a reply open still stalls terminateSession() and any send() answered with a 401, exactly as on the base branch. Only the four 2xx POST drains were converted. Other drains whose text is thrown away still run await response.text?.(). Fix: release every discarded reply body without awaiting, for example with releaseBody(response), which covers the 5 sites listed. Same pattern at 5 sites (streamableHttp.ts:1095, streamableHttp.ts:1099, streamableHttp.ts:1266, sse.ts:472, sse.ts:476). The description leaves these out on purpose, but the DELETE reply is as unread as a 202.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger (peer behaviour, not reproducible from this checkout): the peer the changeset names, one that sends headers and keeps the response open, answers a DELETE with 200 or a POST with 401 while an authProvider is set. terminateSession() at packages/client/src/client/streamableHttp.ts:1266 runs await response.text?.().catch(() => {}) before it reads the status, so it never settles. _send() awaits the same discarded drain at :1095 (after onUnauthorized, before the retry) and :1099 (before throwing UnauthorizedError), and packages/client/src/client/sse.ts:472 and :476 do the same, so send() and a Client.connect() whose first POST is challenged still hang. The base branch behaves identically; the diff changes only :1168, :1212, :1219 and sse.ts:495. No timeout guards these awaits; only transport.close() aborting _abortController ends them. The drains at :1111 and sse.ts:488 use the text and are not part of this.

Verification: pre-existing — triggers when a server or proxy sends headers and holds the response open on a DELETE reply, or on a 401 POST reply while an authProvider is set. packages/client/src/client/streamableHttp.ts:1265-1266 — terminateSession() runs await response.text?.().catch(() => {}); before the status check at :1270. None of the lines above appear in the diff, so the base fails the same way by the same route.

Finding ID: f-a05dae63

// if the accepted notification is initialized, we start the SSE stream
// if it's supported by the server
if (isInitializedNotification(message)) {
Expand Down Expand Up @@ -1208,14 +1209,14 @@ export class StreamableHTTPClientTransport implements Transport {
this.onmessage?.(msg);
}
} else {
await response.text?.().catch(() => {});
releaseBody(response);
throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${contentType}`, {
contentType
});
}
} else {
// No requests in message but got 200 OK - still need to release connection
await response.text?.().catch(() => {});
releaseBody(response);
}
} catch (error) {
// Intentional per-request abort BEFORE response headers (the
Expand Down
6 changes: 6 additions & 0 deletions packages/core-internal/src/shared/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ export function unfollowedRedirect(url: string | URL, response: Response): strin
return `Redirect to ${target.href} not followed; use that URL as the endpoint if it is the intended server`;
}

/** Releases the unread body of `response` without waiting for it; reads it to its end when it has no `cancel()`. Never throws. */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): maintainers get a JSDoc block on a helper that is not public API, which CLAUDE.md principle 8 asks to avoid. releaseBody at packages/core-internal/src/shared/transport.ts:115 is exported only through the private core-internal barrel, not through exports/public/index.ts. Fix: make the comment at line 114 a one-line // comment, or drop it and keep the reasoning in the PR description. Neighbouring internal helpers in this file (lines 51, 70, 77, 101) already carry the same one-line /** */ form, so this follows local precedent and nothing breaks at runtime. [also at: packages/core-internal/src/shared/transport.ts:114 - nit: CLAUDE.md (Brief comments) keeps JSDoc to the public API: the new releaseBody helper carries a /** ... */ JSDoc block, but it is internal — it lives in the private @ modelcontextprotocol/core-internal package and is not re-exported from src/exports/public/index.ts.]

Why this was flagged

No runtime trigger; this is a convention check. packages/core-internal/src/shared/transport.ts:114 adds a /** ... */ JSDoc to releaseBody. The function is reachable only via export * from './shared/transport' in packages/core-internal/src/index.ts:21; packages/core-internal/src/exports/public/index.ts:66-67 re-exports only FetchLike, Transport, TransportSendOptions and createFetchWithInit from that file, and packages/core-internal/package.json marks the package "private": true. So the documented symbol is internal. CLAUDE.md principle 8 limits JSDoc to the public API and wants inline comments kept to one line. The base branch has no such comment because the helper is new. No safeguard applies: Prettier and ESLint do not enforce this. The comment text itself is accurate (cancel when body.cancel is a function, otherwise response.text?.(), rejection swallowed).

Verification: nit — triggering condition: none at runtime; this is a rule check on a line the diff adds. packages/core-internal/src/shared/transport.ts:114, added by this diff, is a /** ... */ JSDoc block on releaseBody (line 115). So JSDoc syntax is used on a non-public helper, which breaks the rule as written. Nothing observable changes for users.

Finding ID: f-1893d194

export function releaseBody(response: Response): void {
const release = async () => (typeof response.body?.cancel === 'function' ? response.body.cancel() : response.text?.());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) SSE-transport users can get a fresh connection per POST against ordinary servers, where the base reused one keep-alive connection. releaseBody calls body.cancel() in the same tick the headers resolve (sse.ts:495), so a short body arriving in a later read is cut off and the socket closed. Servers that write headers and body separately (uvicorn answering 202 with Accepted) hit this. Fix: never block send(), yet let a reply that ends promptly finish, e.g. read un-awaited and cancel after a short deadline, covering all 4 call sites. The changeset names the close but frames it as the held-open case. [also at: packages/client/src/client/streamableHttp.ts:1168 - Servers that flush the reply headers and finish the work before ending the body now have the POST connection closed mid-work; on the base branch the client waited.]

Why this was flagged

Trigger: a successful POST reply whose body bytes (or chunked terminator) reach the client in a later socket read or TLS record than the headers, e.g. uvicorn/Starlette Response("Accepted", 202), which writes headers and body with two transport writes. Entry points: SSEClientTransport._send packages/client/src/client/sse.ts:451-495 (every client message) and StreamableHTTPClientTransport._send packages/client/src/client/streamableHttp.ts:1168, 1212, 1219. Nothing is awaited between the fetch resolving and releaseBody(response), so packages/core-internal/src/shared/transport.ts:116 calls response.body.cancel() before the next read is processed. The base awaited response.text?.(), which read the body to its end and left the connection reusable. Result: a TCP (and TLS) handshake per message and connection resets on the server. No safeguard applies: the changeset sentence 'when part of it has not arrived yet, the connection is closed instead of reused' describes the mechanism but nothing limits it to held-open replies.

Verification: packages/core-internal/src/shared/transport.ts:115-118: releaseBody calls response.body.cancel() immediately and un-awaited, with no grace period for a body that is about to end. Base behaviour: the removed line await response.text?.().catch(() => {}) read the body to its end, which let the keep-alive connection return to the pool.

Finding ID: f-8c58c99d

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Clients whose fetch returns a body with no cancel() (node-fetch, React Native) now leave one socket and one pending read open per POST when a server holds replies open. releaseBody at transport.ts:116 falls back to an unawaited response.text?.(). Nothing ends that read until close() aborts the transport signal. The base stalled on the first such send(); now they pile up, once per message on the SSE transport. Fix: make the release end the reply on every fetch, for example by aborting a per-POST signal instead of reading. A cloned response has the same gap, because cancel() on one branch leaves the reply open.

Why this was flagged

Trigger: a custom fetch whose reply body has no cancel() (the PR's own case 'a fetch whose reply body is a Node stream, which has no cancel()', heldOpenPostReply.test.ts:84) and a server that sends headers and never ends the reply (holdOpen, heldOpenPostReply.test.ts:22). releaseBody (transport.ts:116) calls response.text?.() and drops the promise; that read settles only when the body ends, so the socket and the buffered read stay alive. The only line that ends them is this._abortController?.abort() in close() (streamableHttp.ts:973, sse.ts:426). On the base branch send() awaited the same read, so a sequential caller never had more than one such reply open; after the merge each send() returns and the next adds another, with no cap for the life of the transport. No safeguard applies: .catch(() => {}) at transport.ts:117 only swallows the rejection.

Verification: Triggering condition: a custom fetch whose reply body has no cancel(), talking to a server that never ends the POST reply. packages/core-internal/src/shared/transport.ts:116: with no cancel(), the fallback is an unawaited response.text(), which only settles when the body ends. Base await response.text?.().catch(() => {}) never returned, so the first such send() hung.

Finding ID: f-8d01272d

release().catch(() => {});
}

/**
* Options for sending a JSON-RPC message.
*/
Expand Down
195 changes: 195 additions & 0 deletions test/integration/test/client/heldOpenPostReply.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { Readable } from 'node:stream';

import { Client, SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { afterEach, describe, expect, it } from 'vitest';

const initializeResult = (id: unknown) => ({
jsonrpc: '2.0',
id,
result: { protocolVersion: '2025-06-18', capabilities: {}, serverInfo: { name: 'held-open', version: '1.0.0' } }
});

const readJson = async (req: IncomingMessage) => {
let raw = '';
for await (const chunk of req) raw += chunk;
return JSON.parse(raw);
};

// Sends the headers of a bodiless reply and never ends the response.
const holdOpen = (res: ServerResponse, status = 202, headers?: Record<string, string>) => {
res.writeHead(status, headers);
res.flushHeaders();
};

// A fetch whose POST replies carry `body` in place of their own.
const fetchWithBody =
(body: unknown, replies: Response[] = []): typeof fetch =>
async (input, init) => {
const response = await fetch(input, init);
if (init?.method !== 'POST') return response;
replies.push(response);
return Object.defineProperty(response, 'body', { value: body });
};

describe('POST reply that is never ended', () => {
let server: Server;

const listen = async (handler: (req: IncomingMessage, res: ServerResponse) => void) => {
server = createServer(handler);
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
return new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}/mcp`);
};

afterEach(async () => {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
});

const streamableHttpServer = () =>
listen(async (req, res) => {
if (req.method !== 'POST') {
res.writeHead(405).end();
return;
}
const message = await readJson(req);
if (message.method === 'initialize') {
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(initializeResult(message.id)));
return;
}
holdOpen(res);
});

const sseServer = () => {
let stream: ServerResponse | undefined;
return listen(async (req, res) => {
if (req.method === 'GET') {
res.writeHead(200, { 'content-type': 'text/event-stream' });
res.write('event: endpoint\ndata: /messages\n\n');
stream = res;
return;
}
const message = await readJson(req);
holdOpen(res);
if (message.method === 'initialize') {
stream?.write(`event: message\ndata: ${JSON.stringify(initializeResult(message.id))}\n\n`);
}
});
};

describe.each([
['the global fetch', fetch],
['a fetch whose reply body is a Node stream, which has no cancel()', fetchWithBody(Readable.from([]))],
[
'a fetch whose reply body has a cancel() that throws',
fetchWithBody({
cancel() {
throw new Error('cancel failed');
}
})
],
['a fetch whose reply body has a cancel() that returns undefined', fetchWithBody({ cancel() {} })]
])('with %s', (_name, fetch) => {
it('does not stall send() on the streamable HTTP transport', async () => {
const transport = new StreamableHTTPClientTransport(await streamableHttpServer(), { fetch });
await transport.start();

await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' });

await transport.close();
});

it('does not stall Client.connect() over streamable HTTP', async () => {
const client = new Client({ name: 'test', version: '1.0.0' });

await client.connect(new StreamableHTTPClientTransport(await streamableHttpServer(), { fetch }));

expect(client.getServerVersion()?.name).toBe('held-open');
await client.close();
});

it('does not stall send() of a notification on a 200 reply', async () => {
const url = await listen((_req, res) => holdOpen(res, 200));
const transport = new StreamableHTTPClientTransport(url, { fetch });
await transport.start();

await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' });

await transport.close();
});

it('does not stall the rejection of a reply with an unexpected content type', async () => {
const url = await listen((_req, res) => holdOpen(res, 200, { 'content-type': 'text/plain' }));
const transport = new StreamableHTTPClientTransport(url, { fetch });
await transport.start();

await expect(transport.send({ jsonrpc: '2.0', id: 1, method: 'ping' })).rejects.toThrow('Unexpected content type: text/plain');

await transport.close();
});

it('does not stall send() on the SSE transport', async () => {
const transport = new SSEClientTransport(await sseServer(), { fetch });
await transport.start();

await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' });

await transport.close();
});

it('does not stall Client.connect() over SSE', async () => {
const client = new Client({ name: 'test', version: '1.0.0' });

await client.connect(new SSEClientTransport(await sseServer(), { fetch }));

expect(client.getServerVersion()?.name).toBe('held-open');
await client.close();
});
});

it('does not stall send() when a custom fetch has cloned the response', async () => {
const url = await listen((req, res) => {
req.resume();
res.writeHead(202).end();
});
const fetchWithClone: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
response.clone();
return response;
};
const transport = new StreamableHTTPClientTransport(url, { fetch: fetchWithClone });
await transport.start();

await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' });

await transport.close();
});

it('closes the connection of the reply', async () => {
let closed: Promise<unknown> | undefined;
const url = await listen((_req, res) => {
closed = new Promise(resolve => res.on('close', resolve));
holdOpen(res);
});
const transport = new StreamableHTTPClientTransport(url);
await transport.start();

await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' });

await closed;
await transport.close();
});

it('reads a reply whose body has no cancel()', async () => {
const replies: Response[] = [];
const transport = new StreamableHTTPClientTransport(await streamableHttpServer(), { fetch: fetchWithBody({}, replies) });
await transport.start();

await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' });

expect(replies[0]?.bodyUsed).toBe(true);
await transport.close();
});
});
Loading