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 POST reply with a 2xx status 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 of such a reply and keeps it open no longer stalls `send()` or `Client.connect()`. The unread body is still read to its end, but `send()` does not wait for that; a body that has not ended after 100 ms is cancelled, which closes its HTTP/1.1 connection unless a custom `fetch` holds a clone of the reply. A body with no `getReader()`, as a custom `fetch` may return, is read in the same way but never cancelled. POST replies with any other status and `terminateSession()` still wait for the end of the body.
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
20 changes: 20 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,26 @@ 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`;
}

/** How long `releaseBody()` lets a body end by itself. */
const RELEASE_BODY_DEADLINE_MS = 100;

/** Reads the unread body of `response` to its end without waiting for it, and cancels what has not ended at the deadline. Never throws. */
export function releaseBody(response: Response): void {
const release = async () => {
if (typeof response.body?.getReader !== 'function') return void (await response.text());
const reader = response.body.getReader();
const cancel = async () => reader.cancel();
const timer = setTimeout(() => void cancel().catch(() => {}), RELEASE_BODY_DEADLINE_MS);
try {
let done = false;
while (!done) ({ done } = await reader.read());
} finally {
clearTimeout(timer);
}
};
release().catch(() => {});
}

/**
* Options for sending a JSON-RPC message.
*/
Expand Down
Loading
Loading