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

`Client.connect()` now honors its `signal` during the `server/discover` probe in `auto` and pinned `versionNegotiation` modes. Aborting the signal rejects the connect promptly with the same error the legacy `initialize` path raises, instead of waiting out the probe timeout, and cancels the probe's HTTP request. A signal that is already aborted rejects before anything is sent. With the SDK's stdio transport the rejection waits for the probe sibling process to be reaped (up to about a second if it ignores `SIGTERM`), and the session process is never started.
3 changes: 2 additions & 1 deletion packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1154,7 +1154,8 @@ export class Client extends Protocol<ClientContext> {
clientInfo: this._clientInfo,
capabilities: this._capabilities,
environment: detectProbeEnvironment(),
defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC
defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC,
signal: options?.signal
};
// The SDK's stdio transport probes on a disposable sibling process,
// so the caller's transport spends its one child life on the
Expand Down
48 changes: 43 additions & 5 deletions packages/client/src/client/versionNegotiation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ type RawProbeReply =
| { kind: 'response'; result?: unknown; error?: { code: number; message: string; data?: unknown } }
| { kind: 'send-error'; error: unknown }
| { kind: 'closed' }
| { kind: 'timeout' };
| { kind: 'timeout' }
| { kind: 'aborted'; reason: unknown };

/**
* Temporary ownership of a raw transport for the negotiation exchange, before
Expand Down Expand Up @@ -255,23 +256,33 @@ class ProbeWindow {
/**
* Send one probe request and await its reply. Probe ids are strings, so they
* never collide with Protocol's numeric ids (e.g. on a shared stdio pipe).
* An abort of `signal` settles the exchange and tears down the request.
*/
async exchange(buildRequest: (id: string) => JSONRPCRequest, timeoutMs: number): Promise<RawProbeReply> {
async exchange(buildRequest: (id: string) => JSONRPCRequest, timeoutMs: number, signal?: AbortSignal): Promise<RawProbeReply> {
if (signal?.aborted) {
return { kind: 'aborted', reason: signal.reason };
}
const id = `server-discover-probe-${++this._probeCounter}`;
return new Promise<RawProbeReply>(resolve => {
let settled = false;
const settle = (reply: RawProbeReply) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
if (this._pending?.id === id) {
this._pending = undefined;
}
resolve(reply);
};
const onAbort = () => settle({ kind: 'aborted', reason: signal?.reason });
const timer = setTimeout(() => settle({ kind: 'timeout' }), timeoutMs);
this._pending = { id, resolve: settle };
this._transport.send(buildRequest(id)).catch((error: unknown) => settle({ kind: 'send-error', error }));
// Registered before send() so an abort fired during the send still settles here.
signal?.addEventListener('abort', onAbort, { once: true });
const request = buildRequest(id);
const sent = signal === undefined ? this._transport.send(request) : this._transport.send(request, { requestSignal: signal });
sent.catch((error: unknown) => settle({ kind: 'send-error', error }));
});
}

Expand Down Expand Up @@ -384,7 +395,7 @@ function isUnusableReplyError(error: unknown): boolean {
return error instanceof Error && error.name === 'SyntaxError';
}

function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome {
function normalizeReply(reply: Exclude<RawProbeReply, { kind: 'aborted' }>, timeoutMs: number): ProbeOutcome {
switch (reply.kind) {
case 'response': {
return reply.error === undefined ? { kind: 'result', result: reply.result } : { kind: 'rpc-error', ...reply.error };
Expand Down Expand Up @@ -434,6 +445,11 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome {
}
}

/** The error a connect-signal abort raises; mirrors Protocol.request (update with #3046). */
function abortError(reason: unknown): SdkError {
return reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason));
}

export interface NegotiationDeps {
transport: Transport;
clientInfo: Implementation;
Expand All @@ -451,6 +467,8 @@ export interface NegotiationDeps {
* fallback stream and stays a typed error.
*/
disposableProbe?: boolean;
/** The caller's connect signal: an abort rejects the probe promptly. */
signal?: AbortSignal;
}

export type NegotiationResult = { era: 'modern'; version: string; discover: DiscoverResult } | { era: 'legacy' };
Expand All @@ -471,6 +489,9 @@ export async function negotiateEra(
const clientModernVersions = negotiation.kind === 'pin' ? [negotiation.version] : negotiation.modernVersions;
const fallbackAvailable = negotiation.kind === 'auto' && negotiation.fallbackAvailable;

if (deps.signal?.aborted) {
throw abortError(deps.signal.reason);
}
const window = await ProbeWindow.open(deps.transport);

const probe = async (): Promise<NegotiationResult> => {
Expand All @@ -485,8 +506,16 @@ export async function negotiateEra(
for (;;) {
const reply = await window.exchange(
id => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities),
timeoutMs
timeoutMs,
deps.signal
);
if (reply.kind === 'aborted') {
throw abortError(reply.reason);
}
// An abort landing in the same tick as a reply still wins: never a verdict.
if (deps.signal?.aborted) {
throw abortError(deps.signal.reason);
}

if (reply.kind === 'timeout' && timeoutRetriesRemaining > 0) {
timeoutRetriesRemaining--;
Expand Down Expand Up @@ -571,6 +600,11 @@ export async function negotiateEra(
window.detach();
throw error;
}
if (deps.signal?.aborted) {
// The probe settled, but the abort beat the handover; leave the transport untouched too.
window.detach();
throw abortError(deps.signal.reason);
}
window.release();
return result;
}
Expand Down Expand Up @@ -660,6 +694,10 @@ export async function negotiateStdioViaSibling(
// disposal window aborts: the session transport is never started.
throw callerCloseAbortError();
}
if (deps.signal?.aborted) {
// Same window for the caller's signal: an abort during disposal must not start the session.
throw abortError(deps.signal.reason);
}
return result;
}

Expand Down
Loading
Loading