Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

[v1.x] Backport #2654: `notifications/cancelled` with `requestId: 0` is ignored, so the first server→client request cannot be cancelled (still in 1.32.1)

未关闭 适合新手
#3,043 1 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

@CloudCorpRecords 已经在做这个了。

开始于 2026年10月17日。

  • #3044 来自 @CloudCorpRecords —— 未关闭

评估

难度
2/5
预计耗时
1-3 小时
新手友好度
77/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃

调研方向

阅读 src/shared/protocol.ts,尤其是 _oncancel 和通知 debounce 保护;issue 指出了两处错误检查,以及在 #2654 中合并的修复。将 v1.x 代码与该修复进行比较,然后使用 abort 和 timeout 运行或调整针对请求 ID 0 和 ID 1 的复现。完成标准是:取消 ID 0 会 abort handler 信号,并且 ID 1 的现有行为仍然正常。

由索引模型根据 Issue 内容生成。

描述

v1

#2115, #2283 and #2117 were fixed on main (v2) by #2654 (merged 2026-08-16). The v1.x line — the @modelcontextprotocol/sdk package, whose latest is 1.32.1 — still has the same falsy check, and no v1.x backport PR exists. Comments on #2115, #2117 and #2283 already note that v1.x needs the same backport.

Versions tested: @modelcontextprotocol/sdk 1.31.0 and 1.32.1 — both affected. The v1.x branch tip (fa56d815) has the same line (src/shared/protocol.ts:556).
Environment: Node v24.21.0, Linux x86_64 (Ubuntu 24.04.4).
Transports: reproduced on InMemoryTransport and on stdio (StdioServerTransport / StdioClientTransport), same result.

Description: Protocol._requestMessageId starts at 0, so the first request each side sends has id 0. Protocol._oncancel returns early when !notification.params.requestId, which treats 0 as absent. On the client, the first server-initiated request is id 0 (elicitation/create, sampling/createMessage, roots/list, ping). When the server aborts it, or it times out, the SDK correctly sends notifications/cancelled with requestId: 0, but the client never aborts the handler's extra.signal: the handler runs on and answers a cancelled request. The same cancel for id 1 works.

Steps to reproduce: npm i @modelcontextprotocol/[email protected], save the script below as repro.mjs, run node repro.mjs.

// Minimal repro: a server->client request with JSON-RPC id 0 cannot be cancelled.
// Run: node repro.mjs   (with @modelcontextprotocol/sdk installed next to it)
import { readFileSync } from 'node:fs';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';

const sdkVersion = JSON.parse(readFileSync(new URL('./node_modules/@modelcontextprotocol/sdk/package.json', import.meta.url))).version;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function scenario({ pingFirst, how }) {
  const server = new McpServer({ name: 'repro-server', version: '1.0.0' });
  const client = new Client({ name: 'repro-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });

  let handler = null; // what the client-side handler saw
  client.setRequestHandler(ElicitRequestSchema, async (_req, extra) => {
    handler = { requestId: extra.requestId, signalAborted: false };
    await new Promise((resolve) => {
      const safety = setTimeout(resolve, 1500); // safety timeout: give up waiting for the abort
      extra.signal.addEventListener('abort', () => {
        handler.signalAborted = true;
        clearTimeout(safety);
        resolve();
      });
    });
    return { action: 'decline' };
  });

  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  const wire = []; // server -> client messages after initialization
  let logging = false;
  const send = serverTransport.send.bind(serverTransport);
  serverTransport.send = (msg, opts) => {
    if (logging) wire.push('id' in msg && 'method' in msg ? `request id=${msg.id} ${msg.method}` : 'method' in msg ? `notification ${msg.method} ${JSON.stringify(msg.params)}` : `response id=${msg.id}`);
    return send(msg, opts);
  };

  await server.connect(serverTransport);
  await client.connect(clientTransport);
  logging = true;

  if (pingFirst) await server.server.ping(); // takes id 0, so the elicitation gets id 1

  const params = { message: 'Pick something', requestedSchema: { type: 'object', properties: {} } };
  let options;
  if (how === 'abort') {
    const ac = new AbortController();
    setTimeout(() => ac.abort(new Error('aborted by server after 200 ms')), 200);
    options = { signal: ac.signal };
  } else {
    options = { timeout: 300 };
  }

  let serverSide;
  try {
    await server.server.elicitInput(params, options);
    serverSide = 'resolved';
  } catch (e) {
    serverSide = `rejected (${e.message})`;
  }
  await sleep(1700); // let the client handler finish (abort or safety timeout)
  await client.close();

  console.log(`[sdk ${sdkVersion}] ${how === 'abort' ? 'AbortSignal' : 'timeout: 300'}, elicitation is the server's ${pingFirst ? 'SECOND request (ping first)' : 'FIRST request'}`);
  for (const line of wire) console.log(`    wire s->c: ${line}`);
  console.log(`    server elicitInput: ${serverSide}`);
  console.log(`    client handler: extra.requestId=${handler?.requestId} extra.signal.aborted=${handler?.signalAborted}`);
}

for (const how of ['abort', 'timeout']) {
  for (const pingFirst of [false, true]) await scenario({ pingFirst, how });
}

Expected: extra.signal.aborted=true in all four cases.

Observed (1.32.1, InMemoryTransport):

[sdk 1.32.1] AbortSignal, elicitation is the server's FIRST request
    wire s->c: request id=0 elicitation/create
    wire s->c: notification notifications/cancelled {"requestId":0,"reason":"Error: aborted by server after 200 ms"}
    server elicitInput: rejected (MCP error -32001: Error: aborted by server after 200 ms)
    client handler: extra.requestId=0 extra.signal.aborted=false
[sdk 1.32.1] AbortSignal, elicitation is the server's SECOND request (ping first)
    wire s->c: request id=0 ping
    wire s->c: request id=1 elicitation/create
    wire s->c: notification notifications/cancelled {"requestId":1,"reason":"Error: aborted by server after 200 ms"}
    server elicitInput: rejected (MCP error -32001: Error: aborted by server after 200 ms)
    client handler: extra.requestId=1 extra.signal.aborted=true
[sdk 1.32.1] timeout: 300, elicitation is the server's FIRST request
    wire s->c: request id=0 elicitation/create
    wire s->c: notification notifications/cancelled {"requestId":0,"reason":"McpError: MCP error -32001: Request timed out"}
    server elicitInput: rejected (MCP error -32001: Request timed out)
    client handler: extra.requestId=0 extra.signal.aborted=false
[sdk 1.32.1] timeout: 300, elicitation is the server's SECOND request (ping first)
    wire s->c: request id=0 ping
    wire s->c: request id=1 elicitation/create
    wire s->c: notification notifications/cancelled {"requestId":1,"reason":"McpError: MCP error -32001: Request timed out"}
    server elicitInput: rejected (MCP error -32001: Request timed out)
    client handler: extra.requestId=1 extra.signal.aborted=true

Over stdio (a server spawned with StdioServerTransport, a client with StdioClientTransport) — 1.32.1 shown; 1.31.0 gives the same four results:

[sdk 1.32.1] stdio, AbortSignal, elicitation is the server's FIRST request
    client handler: extra.requestId=0 extra.signal.aborted=false
[sdk 1.32.1] stdio, AbortSignal, elicitation is the server's SECOND request (ping first)
    client handler: extra.requestId=1 extra.signal.aborted=true
[sdk 1.32.1] stdio, timeout: 300, elicitation is the server's FIRST request
    client handler: extra.requestId=0 extra.signal.aborted=false
[sdk 1.32.1] stdio, timeout: 300, elicitation is the server's SECOND request (ping first)
    client handler: extra.requestId=1 extra.signal.aborted=true

The server always puts notifications/cancelled with the right requestId (0 or 1) on the wire, and its own promise rejects; only the receiving side drops id 0.

Root cause: src/shared/protocol.ts on v1.x (dist/esm/shared/protocol.js:174 in 1.32.1):

private async _oncancel(notification: CancelledNotification): Promise<void> {
    if (!notification.params.requestId) {
        return;
    }

The same truthiness pattern is in the notification debounce guard (!options?.relatedRequestId, src/shared/protocol.ts:1374 on v1.x — #2117).

Suggested fix: cherry-pick #2654 (03842cd9) onto v1.x: _oncancel checks notification.params.requestId === undefined, and the debounce guard options?.relatedRequestId === undefined. Changing only the _oncancel line to === undefined in 1.32.1's dist turns all four cases above to aborted=true. #2668 (no notifications/cancelled for initialize) may be worth bringing to v1.x as well.

Workaround: send a throwaway request first (for example server.server.ping()), so that no cancellable request gets id 0.

Related: #2115, #2283, #2117, #2654, #2668.

主要语言
TypeScript
星标
13.6k
派生
2.5k
平均合并
1 天 2 小时
30 天内合并 PR
51

环境准备

  • 没有 Dockerfile 或 Docker Compose 文件
  • 没有 Pull Request 模板
  • 阅读贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

modelcontextprotocol/typescript-sdk 的其他 Issue

查看 modelcontextprotocol/typescript-sdk 的全部 Issue

相似的 Issue

更多 TypeScript Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。