Hacktoberfest 2026 : les issues que les mainteneurs ont marquées pour octobre, ouvertes et accessibles aux débutants. Parcourir les issues 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)

Ouverte Adaptée aux débutants
#3,043 1 commentaire 0 réactions 0 personnes assignées Voir sur GitHub

Les mainteneurs répondent en général sous 1 jour

@CloudCorpRecords y travaille déjà.

Depuis le 17/10/2026.

  • #3044 par @CloudCorpRecords — ouverte

Évaluation

Difficulté
2/5
Temps estimé
1-3 heures
Accessibilité débutants
77/100
Type d'issue
Bug
Clarté
Clairement spécifiée
Activité
Active
Stack technique
node.js, typescript

Piste de recherche

Lis src/shared/protocol.ts, en particulier _oncancel et la protection de debounce des notifications ; l’issue identifie les deux vérifications défectueuses et le correctif fusionné dans #2654. Compare le code de v1.x avec ce correctif, puis exécute ou adapte la reproduction pour les ID de requête 0 et 1 avec abort et timeout. Le travail est terminé lorsque l’annulation de l’ID 0 interrompt le signal du handler et que le comportement existant pour l’ID 1 fonctionne toujours.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Description

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.

Langage dominant
TypeScript
Étoiles
13.7k
Forks
2.3k
Merge moyen
1 j 2 h
PR mergées (30 j)
60

Préparer son environnement

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Autres issues de modelcontextprotocol/typescript-sdk

Toutes les issues de modelcontextprotocol/typescript-sdk

Issues similaires

Plus d'issues TypeScript

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.