Skip to content

Commit c6abb6d

Browse files
committed
fix(client): opt-in graceful close drains in-flight requests before transport teardown
close({ drainPendingRequests: true }) waits for in-flight requests to settle before the transport closes. Without it, transport teardown aborts in-flight HTTP requests the server had already answered, which OpenTelemetry's undici instrumentation reports as UND_ERR_ABORTED on 200 OK responses (#1231). - Protocol tracks pending request ids alongside the response-handler lifecycle and drains them before transport close when opted in - Client.close({ drainPendingRequests }) and a ClientOptions.gracefulClose constructor default expose the behavior; an explicit argument wins - Requests outstanding after the drain timeout (default 2s) settle via the normal close path; default close() behavior is unchanged
1 parent dcc0102 commit c6abb6d

6 files changed

Lines changed: 367 additions & 4 deletions

File tree

.changeset/graceful-close-drain.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
'@modelcontextprotocol/core-internal': patch
4+
---
5+
6+
Add opt-in graceful close: `client.close({ drainPendingRequests: true })` (or a per-call `{ timeoutMs }`) waits for in-flight requests to settle before the transport closes, so a completed HTTP response is no longer aborted mid-read by teardown — which OpenTelemetry's undici instrumentation previously reported as `UND_ERR_ABORTED` on 200 OK responses. A `ClientOptions.gracefulClose` default covers SIGINT-style shutdowns where the caller does not know what is in flight; requests still outstanding after the drain timeout are abandoned to the normal close path, and the default `close()` behavior is unchanged.

packages/client/src/client/client.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,40 @@ export type ClientOptions = ProtocolOptions & {
345345
* regardless. The spec defines absent-or-≤0 as "immediately stale".
346346
*/
347347
defaultCacheTtlMs?: number;
348+
349+
/**
350+
* Default close posture for {@linkcode Client.close | close()}.
351+
*
352+
* `true` (or an object with a `timeoutMs`) makes every parameterless
353+
* `close()` call wait for in-flight requests to settle before the
354+
* transport closes — useful for SIGINT-style shutdowns where the caller
355+
* does not know what is in flight. `false` or absent keeps today's
356+
* behavior: the transport closes immediately and in-flight requests
357+
* settle with a connection-closed error.
358+
*
359+
* An explicit argument to `close()` always wins over this default.
360+
*/
361+
gracefulClose?: boolean | { timeoutMs?: number };
362+
};
363+
364+
/**
365+
* Options for {@linkcode Client.close | Client.close()}.
366+
*/
367+
export type ClientCloseOptions = {
368+
/**
369+
* Wait for in-flight requests to settle before closing the transport.
370+
* `true` uses the default drain timeout (2s); an object sets
371+
* `timeoutMs` explicitly. `false` closes immediately (the default
372+
* behavior when absent, unless {@linkcode ClientOptions.gracefulClose}
373+
* was set at construction).
374+
*
375+
* Without draining, the transport's teardown aborts in-flight HTTP
376+
* requests that the server may have already answered, which
377+
* instrumentation such as OpenTelemetry's undici instrumentation reports
378+
* as aborted requests (`UND_ERR_ABORTED` on 200 OK responses). Draining
379+
* lets those responses land first so telemetry reflects the real outcome.
380+
*/
381+
drainPendingRequests?: boolean | { timeoutMs?: number };
348382
};
349383

350384
/**
@@ -555,6 +589,8 @@ export class Client extends Protocol<ClientContext> {
555589
*/
556590
private readonly _listChangedConfig?: ListChangedHandlers;
557591
private _enforceStrictCapabilities: boolean;
592+
/** The constructor `gracefulClose` posture applied when `close()` gets no explicit argument. */
593+
private readonly _gracefulCloseDefault?: boolean | { timeoutMs?: number };
558594
private _versionNegotiation?: VersionNegotiationOptions;
559595
private _supportedProtocolVersionsOption?: string[];
560596
private _inputRequiredDriverConfig: ResolvedInputRequiredDriverConfig;
@@ -615,9 +651,13 @@ export class Client extends Protocol<ClientContext> {
615651
this._cache.resetForReconnect();
616652
}
617653

618-
override async close(): Promise<void> {
654+
override async close(options?: ClientCloseOptions): Promise<void> {
655+
// An explicit argument wins over the constructor default. `false`
656+
// (or absent with no default) drains nothing — the historical
657+
// immediate-close behavior.
658+
const resolved = options?.drainPendingRequests ?? this._gracefulCloseDefault;
619659
try {
620-
await super.close();
660+
await super.close(resolved ? { drainPendingRequests: resolved } : undefined);
621661
} finally {
622662
// Per-connection state is cleared even when the transport's close
623663
// rejects, so a stale negotiated era / live listen state cannot
@@ -637,6 +677,7 @@ export class Client extends Protocol<ClientContext> {
637677
this._capabilities = options?.capabilities ? { ...options.capabilities } : {};
638678
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator();
639679
this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false;
680+
this._gracefulCloseDefault = options?.gracefulClose;
640681
this._versionNegotiation = options?.versionNegotiation;
641682
this._supportedProtocolVersionsOption = options?.supportedProtocolVersions;
642683
// Multi-round-trip auto-fulfilment driver (2026-07-28): on by default,

packages/client/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,14 @@ export {
6767
PrivateKeyJwtProvider,
6868
StaticPrivateKeyJwtProvider
6969
} from './client/authExtensions';
70-
export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, ConnectOptions, McpSubscription } from './client/client';
70+
export type {
71+
CacheableRequestOptions,
72+
CallToolRequestOptions,
73+
ClientCloseOptions,
74+
ClientOptions,
75+
ConnectOptions,
76+
McpSubscription
77+
} from './client/client';
7178
export { Client } from './client/client';
7279
export { getSupportedElicitationModes } from './client/client';
7380
export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess';
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* Graceful close — opt-in drain of in-flight requests (issue #1231).
3+
*
4+
* `close({ drainPendingRequests })` waits for in-flight requests to settle
5+
* before the transport closes, so a completed-but-still-reading HTTP response
6+
* is not torn down by the transport's abort (which OpenTelemetry's undici
7+
* instrumentation reports as UND_ERR_ABORTED on a 200 OK). Default close
8+
* behavior is unchanged.
9+
*/
10+
import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal';
11+
import { InMemoryTransport } from '@modelcontextprotocol/core-internal';
12+
import { describe, expect, it } from 'vitest';
13+
14+
import { Client } from '../../src/client/client';
15+
16+
const flush = () => new Promise(r => setTimeout(r, 10));
17+
18+
type ScriptedServer = {
19+
clientTx: InMemoryTransport;
20+
serverTx: InMemoryTransport;
21+
written: JSONRPCMessage[];
22+
/** Replies to the oldest outstanding non-initialize request. */
23+
reply: (result: Record<string, unknown>) => void;
24+
/** Replies to the oldest outstanding non-initialize request on a delay. */
25+
replyAfter: (ms: number, result: Record<string, unknown>) => Promise<void>;
26+
};
27+
28+
/**
29+
* A linked in-memory pair where the server auto-answers the legacy
30+
* `initialize` handshake (so `connect()` resolves) but holds every other
31+
* request until the test calls `reply()` / `replyAfter()`.
32+
*/
33+
async function scriptedLegacyServer(): Promise<ScriptedServer> {
34+
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
35+
const written: JSONRPCMessage[] = [];
36+
const pendingIds: (number | string)[] = [];
37+
serverTx.onmessage = message => {
38+
written.push(message);
39+
const req = message as { id?: number | string; method?: string; params?: { protocolVersion?: string } };
40+
if (req.method === 'initialize' && req.id !== undefined) {
41+
void serverTx.send({
42+
jsonrpc: '2.0',
43+
id: req.id,
44+
result: {
45+
protocolVersion: req.params?.protocolVersion ?? '2025-06-18',
46+
capabilities: {},
47+
serverInfo: { name: 'scripted', version: '1' }
48+
}
49+
});
50+
return;
51+
}
52+
if (req.method === 'notifications/initialized') {
53+
return;
54+
}
55+
if (req.id !== undefined) {
56+
pendingIds.push(req.id);
57+
}
58+
};
59+
await serverTx.start();
60+
const reply = (result: Record<string, unknown>) => {
61+
const id = pendingIds.shift();
62+
if (id === undefined) {
63+
throw new Error('no pending request to reply to');
64+
}
65+
void serverTx.send({ jsonrpc: '2.0', id, result });
66+
};
67+
return {
68+
clientTx,
69+
serverTx,
70+
written,
71+
reply,
72+
replyAfter: async (ms: number, result: Record<string, unknown>) => {
73+
await new Promise(r => setTimeout(r, ms));
74+
reply(result);
75+
}
76+
};
77+
}
78+
79+
async function connectClient(options?: ConstructorParameters<typeof Client>[1]): Promise<{ client: Client; server: ScriptedServer }> {
80+
const server = await scriptedLegacyServer();
81+
const client = new Client({ name: 'test-client', version: '1.0.0' }, options);
82+
await client.connect(server.clientTx);
83+
return { client, server };
84+
}
85+
86+
/** Spies on the client transport's close() without changing behavior. */
87+
function spyTransportClose(client: Client): { closed: () => boolean } {
88+
let closed = false;
89+
const transport = client.transport!;
90+
const originalClose = transport.close.bind(transport);
91+
transport.close = async () => {
92+
closed = true;
93+
await originalClose();
94+
};
95+
return { closed: () => closed };
96+
}
97+
98+
describe('Client.close graceful drain', () => {
99+
it('default close() is unchanged: transport closes with a request in flight', async () => {
100+
const { client } = await connectClient();
101+
const inFlight = client.request({ method: 'ping' }).catch(e => e);
102+
await flush();
103+
await client.close();
104+
const settled = (await inFlight) as Error;
105+
// The request is settled by the close itself, not by a response.
106+
expect(settled).toBeInstanceOf(Error);
107+
expect((settled as Error).message).toMatch(/closed/i);
108+
});
109+
110+
it('close({ drainPendingRequests: true }) waits for the in-flight response before closing', async () => {
111+
const { client, server } = await connectClient();
112+
let settled: unknown;
113+
const inFlight = client
114+
.request({ method: 'ping' })
115+
.then(r => (settled = r))
116+
.catch(e => (settled = e));
117+
await flush();
118+
119+
const spy = spyTransportClose(client);
120+
const closing = client.close({ drainPendingRequests: true });
121+
122+
// The transport must still be open while the request is outstanding.
123+
await flush();
124+
expect(spy.closed()).toBe(false);
125+
126+
// The response lands on the still-open connection; the drain then
127+
// completes and the transport closes.
128+
await server.replyAfter(20, {});
129+
await inFlight;
130+
await closing;
131+
expect(spy.closed()).toBe(true);
132+
expect(settled).toBeDefined();
133+
});
134+
135+
it('multiple in-flight requests all drain before the transport closes', async () => {
136+
const { client, server } = await connectClient();
137+
const first = client.request({ method: 'ping' }).catch(e => e);
138+
const second = client.request({ method: 'ping' }).catch(e => e);
139+
await flush();
140+
141+
const spy = spyTransportClose(client);
142+
const closing = client.close({ drainPendingRequests: true });
143+
await flush();
144+
expect(spy.closed()).toBe(false);
145+
146+
server.reply({});
147+
await first;
148+
await flush();
149+
// One of two requests still outstanding: no close yet.
150+
expect(spy.closed()).toBe(false);
151+
152+
server.reply({});
153+
await second;
154+
await closing;
155+
expect(spy.closed()).toBe(true);
156+
});
157+
158+
it('falls back to a hard close after the drain timeout and requests settle with the close', async () => {
159+
const { client } = await connectClient();
160+
const inFlight = client.request({ method: 'ping' }).catch(e => e);
161+
await flush();
162+
163+
await client.close({ drainPendingRequests: { timeoutMs: 30 } });
164+
const settled = (await inFlight) as Error;
165+
expect(settled).toBeInstanceOf(Error);
166+
expect((settled as Error).message).toMatch(/closed/i);
167+
});
168+
169+
it('drain resolves immediately when nothing is in flight', async () => {
170+
const { client } = await connectClient();
171+
const start = Date.now();
172+
await client.close({ drainPendingRequests: true });
173+
expect(Date.now() - start).toBeLessThan(500);
174+
});
175+
176+
it('ClientOptions.gracefulClose applies to parameterless close()', async () => {
177+
const { client, server } = await connectClient({ gracefulClose: true });
178+
const inFlight = client.request({ method: 'ping' }).catch(e => e);
179+
await flush();
180+
181+
const spy = spyTransportClose(client);
182+
const closing = client.close();
183+
await flush();
184+
expect(spy.closed()).toBe(false);
185+
186+
await server.replyAfter(20, {});
187+
await inFlight;
188+
await closing;
189+
expect(spy.closed()).toBe(true);
190+
});
191+
192+
it('an explicit close({ drainPendingRequests: false }) overrides the constructor default', async () => {
193+
const { client } = await connectClient({ gracefulClose: true });
194+
const inFlight = client.request({ method: 'ping' }).catch(e => e);
195+
await flush();
196+
197+
const spy = spyTransportClose(client);
198+
await client.close({ drainPendingRequests: false });
199+
expect(spy.closed()).toBe(true);
200+
const settled = (await inFlight) as Error;
201+
expect(settled).toBeInstanceOf(Error);
202+
expect((settled as Error).message).toMatch(/closed/i);
203+
});
204+
});

packages/core-internal/src/exports/public/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export { getDisplayName } from '../../shared/metadataUtils';
4949
export type {
5050
BaseContext,
5151
ClientContext,
52+
CloseOptions,
5253
NotificationOptions,
5354
ProgressCallback,
5455
ProtocolOptions,

0 commit comments

Comments
 (0)