[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)
Maintainer thường phản hồi trong vòng 1 ngày
Đá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
- Lĩnh vực
- backend-api-design, testing
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ả
#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
- Không có Dockerfile hay tệp Docker Compose
- Không có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- 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.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của modelcontextprotocol/typescript-sdk
-
v1 v2
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
modelcontextprotocol/typescript-sdk#3045 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Stateless 405 response omits the Allow headerCó thể đã có người làm @Hugo-DDT đã nhận 7 ngày trước. Đang mởv2
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
modelcontextprotocol/typescript-sdk#2970 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
[v2] @modelcontextprotocol/server inlines fast-uri 3.1.0, which has 9 published advisoriesCó thể đã có người làm @Andiii208 đã nhận 9 ngày trước. Đang mởv2
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
modelcontextprotocol/typescript-sdk#2966 · 3 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
v1 v2
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
modelcontextprotocol/typescript-sdk#2946 · 4 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
[v2] URI template reserved expansions encode existing %HH sequences againCó thể đã có người làm @takagibit18 đã nhận 16 ngày trước. Đang mởv1 v2
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
modelcontextprotocol/typescript-sdk#2920 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của modelcontextprotocol/typescript-sdk
Issue tương tự
-
documentation
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
components-web-app/docs#260 ·
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 65/100
521xueweihan/HelloGitHub#4025 ·
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 75/100
ctrf-io/playwright-ctrf-json-reporter#117 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
voidzero-dev/void#478 ·
Maintainer thường phản hồi trong vòng 1 ngày