Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

[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)

Đang mở Phù hợp với người mới
#3,043 1 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

@CloudCorpRecords đang làm issue này rồi.

Từ ngày 17/10/2026.

  • #3044 của @CloudCorpRecords — đang mở

Đánh giá

Độ khó
2/5
Thời gian dự kiến
1-3 giờ
Mức phù hợp với người mới
77/100
Loại issue
Lỗi
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
node.js, typescript

Hướng nghiên cứu

Đọc src/shared/protocol.ts, đặc biệt là _oncancel và chốt debounce thông báo; issue chỉ ra cả hai phép kiểm tra sai và bản sửa đã được gộp trong #2654. So sánh mã v1.x với bản sửa đó, sau đó chạy hoặc điều chỉnh cách tái hiện cho ID yêu cầu 0 và 1 bằng abort và timeout. Hoàn tất khi việc hủy ID 0 abort tín hiệu của handler và hành vi hiện có cho ID 1 vẫn hoạt động.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

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.

Ngôn ngữ chính
TypeScript
Star
13.6k
Fork
2.3k
Merge trung bình
1 ngày 2 giờ
Pull request đã merge (30 ngày)
51

Chuẩn bị môi trường

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của modelcontextprotocol/typescript-sdk

Tất cả issue của modelcontextprotocol/typescript-sdk

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.