Skip to content

fix(server): settle a cancelled serveStdio probe so a pipelined initialize fallback cannot wedge the connection#2317

Merged
felixweinberger merged 1 commit into
v2-2026-07-28from
fweinberger/stdio-probe-cancel
Jun 18, 2026
Merged

fix(server): settle a cancelled serveStdio probe so a pipelined initialize fallback cannot wedge the connection#2317
felixweinberger merged 1 commit into
v2-2026-07-28from
fweinberger/stdio-probe-cancel

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

Fixes a connection wedge in serveStdio when a client cancels its server/discover probe before the answer is written and then falls back to initialize on the same connection.

Motivation and Context

A pipelined notifications/cancelled naming the probe request id aborts the in-flight discover handler, so the probe never receives a response; the probe-discard path then waits for that answer indefinitely and the connection's message pump never processes the fallback initialize or anything after it — a silent, permanent wedge only a disconnect clears.

How Has This Been Tested?

New regression test for the pipelined cancel-then-initialize sequence (fails before the fix, passes after); the existing probe-window tests (answer-then-cancel, fallback, repeated probe) stay green; server package suite, typecheck, and lint pass.

Breaking Changes

None.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Additional context

A delivered cancellation now settles the pending probe id (a cancelled request may legitimately go unanswered), and the discard-time wait for unanswered probe requests is bounded with an error report as a backstop so no future edge can stall the connection's pump indefinitely.

…ize cannot wedge serveStdio

A client may pipeline an enveloped notifications/cancelled naming its
server/discover probe and a fallback initialize behind the probe without
waiting for the DiscoverResult. The cancellation aborts the in-flight
discover handler, so no response is ever produced for the probe id; the
probe-discard path then waited forever for that answer before closing the
probe instance, and since the inbound pump processes messages in order, the
fallback initialize and every later message were never processed - a silent,
permanent connection wedge with no error reported.

The per-connection channel now settles a delivered request when a
notifications/cancelled naming its id is delivered (by protocol contract a
cancelled request may go unanswered), so the discard wait drains immediately
in that case while non-cancelled probes still get their answer delivered to
the wire before the probe instance is closed. As a backstop, the
wait-for-answers used by the discard is bounded by a short timeout; on
timeout the discard proceeds and the condition is reported through onerror,
so no future edge can hold the pump indefinitely. New test covers the
pipelined probe -> cancellation -> initialize sequence falling back to a
working legacy session.
@felixweinberger felixweinberger requested a review from a team as a code owner June 18, 2026 10:24
@changeset-bot

changeset-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 6af7ece

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2317

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2317

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2317

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2317

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2317

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2317

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2317

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2317

commit: 6af7ece

Comment on lines +187 to 198
} else if (isJSONRPCNotification(message) && message.method === 'notifications/cancelled') {
// By protocol contract a cancelled request may legitimately go
// unanswered (the instance aborts the in-flight handler and writes
// nothing for it), so a delivered cancellation settles the request
// it names: nothing should keep waiting for an answer that may
// never come. Non-cancelled requests still settle only when their
// answer is handed to the wire.
const cancelledId = (message.params as CancelledNotificationParams | undefined)?.requestId;
if (cancelledId !== undefined) {
this._settle(cancelledId);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing issue (not introduced by this PR): Protocol._oncancel in packages/core/src/shared/protocol.ts:512-515 guards with if (!notification.params.requestId) return, so a notifications/cancelled naming request id 0 — the very first id an SDK client uses — is silently ignored and the in-flight handler is never aborted. The new channel-level settle here correctly checks cancelledId !== undefined; a follow-up should change the Protocol guard to requestId === undefined so the two layers agree on whether id 0 is cancellable.

Extended reasoning...

What the bug is. Protocol._oncancel (packages/core/src/shared/protocol.ts:512-519) starts with if (!notification.params.requestId) { return; }. RequestId is string | number, and 0 (as well as '') is falsy, so a cancellation that names request id 0 is treated as if the field were absent: the method returns early, the matching AbortController is never looked up, and the in-flight request handler keeps running to completion.

Why id 0 is realistic — in fact the most likely id to be cancelled. Protocol initializes its request counter at 0 (private _requestMessageId = 0, protocol.ts:418) and assigns ids with post-increment (const messageId = this._requestMessageId++, protocol.ts:1132). So an SDK-built client's very first request on a connection — e.g. the opening server/discover probe that this PR's headline scenario is about — carries id 0, and the client's own cancellation path (protocol.ts:1165) sends notifications/cancelled with that same numeric id.

Concrete walkthrough. 1) An SDK 2026 client opens a stdio connection and sends server/discover with id 0 (its first request). 2) It decides to abandon the probe and pipelines notifications/cancelled with requestId: 0. 3) On the server, the entry delivers both messages to the probe instance; the new channel code in serveStdio.ts:187-198 correctly settles pending id 0 because it checks cancelledId !== undefined. 4) But Protocol._oncancel evaluates !0 === true and returns — the discover handler's abort signal never fires, the handler runs to completion, and its response is still written. The cancellation is silently ignored for that one id.

Why this PR doesn't prevent or recreate it. The PR only touches the channel layer in serveStdio.ts; protocol.ts is unchanged. Importantly, this does NOT recreate the wedge the PR fixes: because the handler for id 0 is never aborted, the discover answer still reaches the wire, send() settles the pending id, and the discard wait resolves — the connection's pump is never blocked. The visible impact is limited to wasted handler work and a response the cancelling client must ignore per spec. However, after this PR the channel layer (!== undefined) and the Protocol layer (falsy check) disagree on whether id 0 is a valid cancellation target, which is exactly the off-by-falsy class the new code in this diff was careful to avoid.

How to fix. In a follow-up to core (out of scope for this PR), change the guard to if (notification.params.requestId === undefined) { return; } — or drop it entirely, since the schema requires the field. String ids of '' would be handled correctly by the same change.

All four verifiers independently confirmed the falsy guard, the id-0 starting counter, and that the impact is non-blocking; there were no refutations.

@felixweinberger felixweinberger merged commit 6de84cf into v2-2026-07-28 Jun 18, 2026
17 checks passed
@felixweinberger felixweinberger deleted the fweinberger/stdio-probe-cancel branch June 18, 2026 10:47
felixweinberger added a commit that referenced this pull request Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant