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

Stop propagating the transport-wide `AbortController` signal to outgoing POST and DELETE requests in `SSEClientTransport` and `StreamableHTTPClientTransport`. Previously, calling `close()` on a transport that had just completed a successful POST would still abort the underlying `fetch` controller, causing Undici-based instrumentation (e.g. OpenTelemetry) to report the successful request as aborted (`UND_ERR_ABORTED`). The transport's own signal is now used only for the SSE GET stream and reconnection-state gating; per-request cancellation can still be supplied by the user via `requestInit.signal`.
3 changes: 1 addition & 2 deletions packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,7 @@ export class SSEClientTransport implements Transport {
...this._requestInit,
method: 'POST',
headers,
body: JSON.stringify(message),
signal: this._abortController?.signal
body: JSON.stringify(message)
};

const response = await (this._fetch ?? fetch)(this._endpoint, init);
Expand Down
6 changes: 2 additions & 4 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,8 +548,7 @@ export class StreamableHTTPClientTransport implements Transport {
...this._requestInit,
method: 'POST',
headers,
body: JSON.stringify(message),
signal: this._abortController?.signal
body: JSON.stringify(message)
};

const response = await (this._fetch ?? fetch)(this._url, init);
Expand Down Expand Up @@ -715,8 +714,7 @@ export class StreamableHTTPClientTransport implements Transport {
const init = {
...this._requestInit,
method: 'DELETE',
headers,
signal: this._abortController?.signal
headers
};

const response = await (this._fetch ?? fetch)(this._url, init);
Expand Down
18 changes: 18 additions & 0 deletions packages/client/test/client/sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,24 @@ describe('SSEClientTransport', () => {
expect(JSON.parse((lastServerRequest as IncomingMessage & { body: string }).body)).toEqual(testMessage);
});

it('does not pass the transport-wide AbortController signal to POST requests (issue #1231)', async () => {
// Sharing the SSE-stream AbortController with POST requests means a successful POST
// followed by transport.close() leaves Undici-based instrumentation reporting the
// request as aborted. The transport must not propagate its own signal to POSTs.
let capturedSignal: AbortSignal | undefined | null = null;
const captureFetch: typeof fetch = (url, init) => {
capturedSignal = init?.signal ?? undefined;
return fetch(url as string | URL, init);
};

transport = new SSEClientTransport(resourceBaseUrl, { fetch: captureFetch });
await transport.start();

await transport.send({ jsonrpc: '2.0', id: 'test-1', method: 'test', params: {} });

expect(capturedSignal).toBeUndefined();
});

it('handles POST request failures', async () => {
// Create a server that returns 500 for POST
await resourceServer.close();
Expand Down
56 changes: 56 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,62 @@ describe('StreamableHTTPClientTransport', () => {
);
});

it('does not cancel in-flight POST requests when close() is called (issue #1231)', async () => {
// POST requests must not share the transport-wide AbortController, otherwise calling
// close() while a request is being processed (or just after a successful 200) makes
// Undici-based instrumentation (e.g. OpenTelemetry) report the request as aborted
// even though the server responded successfully.
let capturedSignal: AbortSignal | undefined;
let resolveResponse!: (value: { ok: boolean; status: number; headers: Headers }) => void;
const responsePromise = new Promise<{ ok: boolean; status: number; headers: Headers }>(resolve => {
resolveResponse = resolve;
});

(globalThis.fetch as Mock).mockImplementationOnce((_url: unknown, init?: RequestInit) => {
capturedSignal = init?.signal ?? undefined;
return responsePromise;
});

const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: {}, id: 'inflight-id' };
const sendPromise = transport.send(message);

// While the POST is in-flight, close the transport.
const closePromise = transport.close();

// The POST should not have been issued with the transport's abort signal.
expect(capturedSignal).toBeUndefined();

// Server completes the request after close().
resolveResponse({ ok: true, status: 202, headers: new Headers() });

await sendPromise;
await closePromise;
});

it('does not cancel in-flight DELETE (terminateSession) when close() is called', async () => {
// First, get a session ID so terminateSession actually issues a DELETE.
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'session-x' })
});
await transport.send({
jsonrpc: '2.0',
method: 'initialize',
params: { clientInfo: { name: 'c', version: '1.0' }, protocolVersion: '2025-03-26' },
id: 'init-id'
});

let capturedSignal: AbortSignal | undefined;
(globalThis.fetch as Mock).mockImplementationOnce((_url: unknown, init?: RequestInit) => {
capturedSignal = init?.signal ?? undefined;
return Promise.resolve({ ok: true, status: 200, headers: new Headers() });
});

await transport.terminateSession();
expect(capturedSignal).toBeUndefined();
});

it('should send batch messages', async () => {
const messages: JSONRPCMessage[] = [
{ jsonrpc: '2.0', method: 'test1', params: {}, id: 'id1' },
Expand Down
Loading