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 ownerJune 18, 2026 10:24
@changeset-bot

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

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-28Jun 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 freeto 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

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(server): settle a cancelled serveStdio probe so a pipelined initialize fallback cannot wedge the connection by felixweinberger · Pull Request #2317 · modelcontextprotocol/typescript-sdk · GitHub
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 ownerJune 18, 2026 10:24
@changeset-bot

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

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-28Jun 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 freeto 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

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(server): settle a cancelled serveStdio probe so a pipelined initialize fallback cannot wedge the connection by felixweinberger · Pull Request #2317 · modelcontextprotocol/typescript-sdk · GitHub
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 ownerJune 18, 2026 10:24
@changeset-bot

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

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-28Jun 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 freeto 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

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(server): settle a cancelled serveStdio probe so a pipelined initialize fallback cannot wedge the connection by felixweinberger · Pull Request #2317 · modelcontextprotocol/typescript-sdk · GitHub
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 ownerJune 18, 2026 10:24
@changeset-bot

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

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-28Jun 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 freeto 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

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(server): settle a cancelled serveStdio probe so a pipelined initialize fallback cannot wedge the connection by felixweinberger · Pull Request #2317 · modelcontextprotocol/typescript-sdk · GitHub
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 ownerJune 18, 2026 10:24
@changeset-bot

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

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-28Jun 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 freeto 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

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(server): settle a cancelled serveStdio probe so a pipelined initialize fallback cannot wedge the connection by felixweinberger · Pull Request #2317 · modelcontextprotocol/typescript-sdk · GitHub
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 ownerJune 18, 2026 10:24
@changeset-bot

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

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-28Jun 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 freeto 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

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(server): settle a cancelled serveStdio probe so a pipelined initialize fallback cannot wedge the connection by felixweinberger · Pull Request #2317 · modelcontextprotocol/typescript-sdk · GitHub
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 ownerJune 18, 2026 10:24
@changeset-bot

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

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-28Jun 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 freeto 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

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(server): settle a cancelled serveStdio probe so a pipelined initialize fallback cannot wedge the connection by felixweinberger · Pull Request #2317 · modelcontextprotocol/typescript-sdk · GitHub
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 ownerJune 18, 2026 10:24
@changeset-bot

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

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-28Jun 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 freeto 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

@felixweinberger