Repository navigation
fix(client): do not wait for the end of an unread POST reply body #3082
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. */ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Why this was flaggedNo runtime trigger; this is a convention check. packages/core-internal/src/shared/transport.ts:114 adds a 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 Finding ID: |
||
| export function releaseBody(response: Response): void { | ||
| const release = async () => (typeof response.body?.cancel === 'function' ? response.body.cancel() : response.text?.()); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Why this was flaggedTrigger: 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 Verification: packages/core-internal/src/shared/transport.ts:115-118: Finding ID:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Clients whose fetch returns a body with no Why this was flaggedTrigger: a custom Verification: Triggering condition: a custom Finding ID: |
||
| release().catch(() => {}); | ||
| } | ||
|
|
||
| /** | ||
| * Options for sending a JSON-RPC message. | ||
| */ | ||
|
|
||
| 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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 anysend()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 runawait response.text?.(). Fix: release every discarded reply body without awaiting, for example withreleaseBody(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
authProvideris set.terminateSession()at packages/client/src/client/streamableHttp.ts:1266 runsawait response.text?.().catch(() => {})before it reads the status, so it never settles._send()awaits the same discarded drain at :1095 (afteronUnauthorized, before the retry) and :1099 (before throwingUnauthorizedError), and packages/client/src/client/sse.ts:472 and :476 do the same, sosend()and aClient.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; onlytransport.close()aborting_abortControllerends 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
authProvideris set. packages/client/src/client/streamableHttp.ts:1265-1266 —terminateSession()runsawait 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