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
6 changes: 6 additions & 0 deletions .changeset/graceful-close-drain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/core-internal': patch
---

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.
45 changes: 43 additions & 2 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,40 @@ export type ClientOptions = ProtocolOptions & {
* regardless. The spec defines absent-or-≤0 as "immediately stale".
*/
defaultCacheTtlMs?: number;

/**
* Default close posture for {@linkcode Client.close | close()}.
*
* `true` (or an object with a `timeoutMs`) makes every parameterless
* `close()` call wait for in-flight requests to settle before the
* transport closes — useful for SIGINT-style shutdowns where the caller
* does not know what is in flight. `false` or absent keeps today's
* behavior: the transport closes immediately and in-flight requests
* settle with a connection-closed error.
*
* An explicit argument to `close()` always wins over this default.
*/
gracefulClose?: boolean | { timeoutMs?: number };
};

/**
* Options for {@linkcode Client.close | Client.close()}.
*/
export type ClientCloseOptions = {
/**
* Wait for in-flight requests to settle before closing the transport.
* `true` uses the default drain timeout (2s); an object sets
* `timeoutMs` explicitly. `false` closes immediately (the default
* behavior when absent, unless {@linkcode ClientOptions.gracefulClose}
* was set at construction).
*
* Without draining, the transport's teardown aborts in-flight HTTP
* requests that the server may have already answered, which
* instrumentation such as OpenTelemetry's undici instrumentation reports
* as aborted requests (`UND_ERR_ABORTED` on 200 OK responses). Draining
* lets those responses land first so telemetry reflects the real outcome.
*/
drainPendingRequests?: boolean | { timeoutMs?: number };
};

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

override async close(): Promise<void> {
override async close(options?: ClientCloseOptions): Promise<void> {
// An explicit argument wins over the constructor default. `false`
// (or absent with no default) drains nothing — the historical
// immediate-close behavior.
const resolved = options?.drainPendingRequests ?? this._gracefulCloseDefault;
try {
await super.close();
await super.close(resolved ? { drainPendingRequests: resolved } : undefined);
} finally {
// Per-connection state is cleared even when the transport's close
// rejects, so a stale negotiated era / live listen state cannot
Expand All @@ -637,6 +677,7 @@ export class Client extends Protocol<ClientContext> {
this._capabilities = options?.capabilities ? { ...options.capabilities } : {};
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator();
this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false;
this._gracefulCloseDefault = options?.gracefulClose;
this._versionNegotiation = options?.versionNegotiation;
this._supportedProtocolVersionsOption = options?.supportedProtocolVersions;
// Multi-round-trip auto-fulfilment driver (2026-07-28): on by default,
Expand Down
9 changes: 8 additions & 1 deletion packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ export {
PrivateKeyJwtProvider,
StaticPrivateKeyJwtProvider
} from './client/authExtensions';
export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, ConnectOptions, McpSubscription } from './client/client';
export type {
CacheableRequestOptions,
CallToolRequestOptions,
ClientCloseOptions,
ClientOptions,
ConnectOptions,
McpSubscription
} from './client/client';
export { Client } from './client/client';
export { getSupportedElicitationModes } from './client/client';
export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess';
Expand Down
204 changes: 204 additions & 0 deletions packages/client/test/client/gracefulClose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* Graceful close — opt-in drain of in-flight requests (issue #1231).
*
* `close({ drainPendingRequests })` waits for in-flight requests to settle
* before the transport closes, so a completed-but-still-reading HTTP response
* is not torn down by the transport's abort (which OpenTelemetry's undici
* instrumentation reports as UND_ERR_ABORTED on a 200 OK). Default close
* behavior is unchanged.
*/
import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal';
import { InMemoryTransport } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';

import { Client } from '../../src/client/client';

const flush = () => new Promise(r => setTimeout(r, 10));

type ScriptedServer = {
clientTx: InMemoryTransport;
serverTx: InMemoryTransport;
written: JSONRPCMessage[];
/** Replies to the oldest outstanding non-initialize request. */
reply: (result: Record<string, unknown>) => void;
/** Replies to the oldest outstanding non-initialize request on a delay. */
replyAfter: (ms: number, result: Record<string, unknown>) => Promise<void>;
};

/**
* A linked in-memory pair where the server auto-answers the legacy
* `initialize` handshake (so `connect()` resolves) but holds every other
* request until the test calls `reply()` / `replyAfter()`.
*/
async function scriptedLegacyServer(): Promise<ScriptedServer> {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const written: JSONRPCMessage[] = [];
const pendingIds: (number | string)[] = [];
serverTx.onmessage = message => {
written.push(message);
const req = message as { id?: number | string; method?: string; params?: { protocolVersion?: string } };
if (req.method === 'initialize' && req.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: req.id,
result: {
protocolVersion: req.params?.protocolVersion ?? '2025-06-18',
capabilities: {},
serverInfo: { name: 'scripted', version: '1' }
}
});
return;
}
if (req.method === 'notifications/initialized') {
return;
}
if (req.id !== undefined) {
pendingIds.push(req.id);
}
};
await serverTx.start();
const reply = (result: Record<string, unknown>) => {
const id = pendingIds.shift();
if (id === undefined) {
throw new Error('no pending request to reply to');
}
void serverTx.send({ jsonrpc: '2.0', id, result });
};
return {
clientTx,
serverTx,
written,
reply,
replyAfter: async (ms: number, result: Record<string, unknown>) => {
await new Promise(r => setTimeout(r, ms));
reply(result);
}
};
}

async function connectClient(options?: ConstructorParameters<typeof Client>[1]): Promise<{ client: Client; server: ScriptedServer }> {
const server = await scriptedLegacyServer();
const client = new Client({ name: 'test-client', version: '1.0.0' }, options);
await client.connect(server.clientTx);
return { client, server };
}

/** Spies on the client transport's close() without changing behavior. */
function spyTransportClose(client: Client): { closed: () => boolean } {
let closed = false;
const transport = client.transport!;
const originalClose = transport.close.bind(transport);
transport.close = async () => {
closed = true;
await originalClose();
};
return { closed: () => closed };
}

describe('Client.close graceful drain', () => {
it('default close() is unchanged: transport closes with a request in flight', async () => {
const { client } = await connectClient();
const inFlight = client.request({ method: 'ping' }).catch(e => e);
await flush();
await client.close();
const settled = (await inFlight) as Error;
// The request is settled by the close itself, not by a response.
expect(settled).toBeInstanceOf(Error);
expect((settled as Error).message).toMatch(/closed/i);
});

it('close({ drainPendingRequests: true }) waits for the in-flight response before closing', async () => {
const { client, server } = await connectClient();
let settled: unknown;
const inFlight = client
.request({ method: 'ping' })
.then(r => (settled = r))
.catch(e => (settled = e));
await flush();

const spy = spyTransportClose(client);
const closing = client.close({ drainPendingRequests: true });

// The transport must still be open while the request is outstanding.
await flush();
expect(spy.closed()).toBe(false);

// The response lands on the still-open connection; the drain then
// completes and the transport closes.
await server.replyAfter(20, {});
await inFlight;
await closing;
expect(spy.closed()).toBe(true);
expect(settled).toBeDefined();
});

it('multiple in-flight requests all drain before the transport closes', async () => {
const { client, server } = await connectClient();
const first = client.request({ method: 'ping' }).catch(e => e);
const second = client.request({ method: 'ping' }).catch(e => e);
await flush();

const spy = spyTransportClose(client);
const closing = client.close({ drainPendingRequests: true });
await flush();
expect(spy.closed()).toBe(false);

server.reply({});
await first;
await flush();
// One of two requests still outstanding: no close yet.
expect(spy.closed()).toBe(false);

server.reply({});
await second;
await closing;
expect(spy.closed()).toBe(true);
});

it('falls back to a hard close after the drain timeout and requests settle with the close', async () => {
const { client } = await connectClient();
const inFlight = client.request({ method: 'ping' }).catch(e => e);
await flush();

await client.close({ drainPendingRequests: { timeoutMs: 30 } });
const settled = (await inFlight) as Error;
expect(settled).toBeInstanceOf(Error);
expect((settled as Error).message).toMatch(/closed/i);
});

it('drain resolves immediately when nothing is in flight', async () => {
const { client } = await connectClient();
const start = Date.now();
await client.close({ drainPendingRequests: true });
expect(Date.now() - start).toBeLessThan(500);
});

it('ClientOptions.gracefulClose applies to parameterless close()', async () => {
const { client, server } = await connectClient({ gracefulClose: true });
const inFlight = client.request({ method: 'ping' }).catch(e => e);
await flush();

const spy = spyTransportClose(client);
const closing = client.close();
await flush();
expect(spy.closed()).toBe(false);

await server.replyAfter(20, {});
await inFlight;
await closing;
expect(spy.closed()).toBe(true);
});

it('an explicit close({ drainPendingRequests: false }) overrides the constructor default', async () => {
const { client } = await connectClient({ gracefulClose: true });
const inFlight = client.request({ method: 'ping' }).catch(e => e);
await flush();

const spy = spyTransportClose(client);
await client.close({ drainPendingRequests: false });
expect(spy.closed()).toBe(true);
const settled = (await inFlight) as Error;
expect(settled).toBeInstanceOf(Error);
expect((settled as Error).message).toMatch(/closed/i);
});
});
1 change: 1 addition & 0 deletions packages/core-internal/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export { getDisplayName } from '../../shared/metadataUtils';
export type {
BaseContext,
ClientContext,
CloseOptions,
NotificationOptions,
ProgressCallback,
ProtocolOptions,
Expand Down
Loading
Loading