Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': patch
---

Fix `sendToolListChanged()`/`sendResourceListChanged()`/`sendPromptListChanged()` silently dropping their notification on a stateless Streamable HTTP transport when called from inside a request handler. These methods send with no `relatedRequestId`, so the transport routed them at the standalone GET SSE stream — which stateless transports never open, so the message had nowhere to go. Notifications sent while a handler is in flight are now automatically tagged with that handler's request id (unless the caller supplied its own `relatedRequestId`), so they ride the request's own response stream instead.
46 changes: 42 additions & 4 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,33 @@ export abstract class Protocol<ContextT extends BaseContext> {
return undefined;
}

/**
* Runs `fn` as the currently in-flight handler for `requestId`, so that a
* notification sent from within it (directly via `this.notification()`,
* not through the per-request `ctx.mcpReq.notify` closure, which already
* stamps `relatedRequestId` unconditionally) can be tagged with the
* request it was triggered by instead of defaulting to no related request
* at all. The base implementation is a plain pass-through: `Protocol` has
* no notion of concurrent in-flight handlers and stays runtime-neutral
* (no `node:async_hooks` import here). `Server` overrides this with an
* `AsyncLocalStorage`-backed implementation.
*/
protected _runHandlerInContext<T>(_requestId: RequestId, fn: () => T | Promise<T>): T | Promise<T> {
return fn();
}

/**
* The request id `_runHandlerInContext` is currently running a handler
* for, or `undefined` outside of any such context. Consulted by
* {@linkcode _notificationViaCodec} to default `relatedRequestId` when the
* caller didn't supply one. The base implementation always returns
* `undefined`, matching the pass-through default of
* {@linkcode _runHandlerInContext}.
*/
protected _currentInflightRequestId(): RequestId | undefined {
return undefined;
}

/**
* Attach this instance's outbound `_meta` envelope (when one is configured)
* to a request or notification. A no-op when the seam returns `undefined`
Expand Down Expand Up @@ -1095,7 +1122,7 @@ export abstract class Protocol<ContextT extends BaseContext> {

// Starting with Promise.resolve() puts any synchronous errors into the monad as well.
Promise.resolve()
.then(() => handler(request, ctx))
.then(() => this._runHandlerInContext(request.id, () => handler(request, ctx)))
.then(
async result => {
if (abortController.signal.aborted) {
Expand Down Expand Up @@ -1607,10 +1634,21 @@ export abstract class Protocol<ContextT extends BaseContext> {

const jsonrpcNotification = this._envelopeOutbound({ jsonrpc: '2.0' as const, ...notification });

// A caller-supplied `relatedRequestId` always wins; otherwise, if this
// notification is being sent while a handler is in flight (tracked via
// `_runHandlerInContext`/`_currentInflightRequestId` — see `Server`'s
// `AsyncLocalStorage`-backed override), default to that request's id.
// This is what lets `sendToolListChanged()` and friends — which pass no
// options at all — still land on the current request's own response
// stream instead of unconditionally targeting the standalone stream,
// which may not exist (e.g. a stateless transport).
const relatedRequestId = options?.relatedRequestId ?? this._currentInflightRequestId();
const effectiveOptions = relatedRequestId === undefined ? options : { ...options, relatedRequestId };

const debouncedMethods = this._options?.debouncedNotificationMethods ?? [];
// A notification can only be debounced if it's in the list AND it's "simple"
// (i.e., has no parameters and no related request ID that could be lost).
const canDebounce = debouncedMethods.includes(notification.method) && !notification.params && !options?.relatedRequestId;
const canDebounce = debouncedMethods.includes(notification.method) && !notification.params && !effectiveOptions?.relatedRequestId;

if (canDebounce) {
// If a notification of this type is already scheduled, do nothing.
Expand All @@ -1634,14 +1672,14 @@ export abstract class Protocol<ContextT extends BaseContext> {

// Send the notification, but don't await it here to avoid blocking.
// Handle potential errors with a .catch().
this._transport?.send(jsonrpcNotification, options).catch(error => this._onerror(error));
this._transport?.send(jsonrpcNotification, effectiveOptions).catch(error => this._onerror(error));
});

// Return immediately.
return;
}

await this._transport.send(jsonrpcNotification, options);
await this._transport.send(jsonrpcNotification, effectiveOptions);
}

/**
Expand Down
21 changes: 20 additions & 1 deletion packages/server/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
NotificationMethod,
NotificationOptions,
ProtocolOptions,
RequestId,
RequestMethod,
RequestOptions,
ResourceUpdatedNotification,
Expand Down Expand Up @@ -61,7 +62,7 @@ import {
SdkErrorCode,
withRequestStateValue
} from '@modelcontextprotocol/core-internal';
import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims';
import { AsyncLocalStorage, DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims';

import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimOptions } from './legacyInputRequiredShim';

Expand Down Expand Up @@ -276,6 +277,24 @@ export class Server extends Protocol<ServerContext> {
private _requestStateVerify?: (state: string, ctx: ServerContext) => unknown | Promise<unknown>;
private _inputRequiredServing: { maxRounds: number; roundTimeoutMs: number; legacyShim: boolean };
private _legacyShim?: LegacyInputRequiredShim;
/** Tracks the request id a handler is currently running for — see `_runHandlerInContext`. */
private _inflightRequest = new AsyncLocalStorage<{ requestId: RequestId }>();

/**
* Overrides {@linkcode Protocol._runHandlerInContext}: runs the handler
* inside an `AsyncLocalStorage` context carrying `requestId`, so a
* notification sent from within it (e.g. `sendToolListChanged()`, which
* passes no `relatedRequestId` of its own) can still be tagged with the
* request that triggered it — see `_currentInflightRequestId` below.
*/
protected override _runHandlerInContext<T>(requestId: RequestId, fn: () => T | Promise<T>): T | Promise<T> {
return this._inflightRequest.run({ requestId }, fn);
}

/** Overrides {@linkcode Protocol._currentInflightRequestId}. */
protected override _currentInflightRequestId(): RequestId | undefined {
return this._inflightRequest.getStore()?.requestId;
}

/** Lazily-built legacy shim; the loop lives in legacyInputRequiredShim.ts behind a narrow host contract. */
private _legacyInputRequiredShim(): LegacyInputRequiredShim {
Expand Down
21 changes: 21 additions & 0 deletions packages/server/src/shimsBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,24 @@ export const process = {
return notSupported();
}
};

/**
* Single-slot fallback — browsers have no `node:async_hooks`. See the
* identical implementation in `shimsWorkerd.ts` for the scope/limitations of
* this fallback (synchronous-scope only, unlike the real `AsyncLocalStorage`).
*/
export class AsyncLocalStorage<T> {
private _store: T | undefined;
getStore(): T | undefined {
return this._store;
}
run<A extends unknown[], R>(store: T, cb: (...args: A) => R, ...args: A): R {
const prev = this._store;
this._store = store;
try {
return cb(...args);
} finally {
this._store = prev;
}
}
}
1 change: 1 addition & 0 deletions packages/server/src/shimsNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
* This file is selected via package.json export conditions when running in Node.js.
*/
export { AjvJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv';
export { AsyncLocalStorage } from 'node:async_hooks';
export { default as process } from 'node:process';
25 changes: 25 additions & 0 deletions packages/server/src/shimsWorkerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,28 @@ export const process = {
return notSupported();
}
};

/**
* Single-slot fallback for environments without `node:async_hooks` (workerd
* without the `nodejs_compat` flag). Unlike the real `AsyncLocalStorage`, the
* store is only guaranteed live for the SYNCHRONOUS portion of `run()`'s
* callback — it is cleared as soon as that call returns, not after any
* promise it returns settles. This still covers the common case (a handler
* that triggers a related notification before its first `await`); a handler
* that awaits first and only then triggers one loses the association here.
*/
export class AsyncLocalStorage<T> {
private _store: T | undefined;
getStore(): T | undefined {
return this._store;
}
run<A extends unknown[], R>(store: T, cb: (...args: A) => R, ...args: A): R {
const prev = this._store;
this._store = store;
try {
return cb(...args);
} finally {
this._store = prev;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Regression coverage for #2232: `sendToolListChanged()` (and the equivalent
* resource/prompt notifications) carry no `relatedRequestId`, so on a
* stateless Streamable HTTP transport — which has no standalone GET SSE
* stream for the transport to fall back to — a list-changed notification
* fired from inside a request handler has nowhere to go and is silently
* dropped, even though the response stream for the *current* request is
* right there and able to carry it.
*/
import type { CallToolResult } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import * as z from 'zod/v4';

import { McpServer } from '../../src/server/mcp';
import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp';

async function readFullSSEBody(response: Response): Promise<string> {
const reader = response.body?.getReader();
if (!reader) return '';
const decoder = new TextDecoder();
let body = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
body += decoder.decode(value, { stream: true });
}
return body;
}

function buildServer(): McpServer {
const mcpServer = new McpServer(
{ name: 'stateless-list-changed-test', version: '1.0.0' },
{ capabilities: { tools: { listChanged: true } } }
);

const hidden = mcpServer.registerTool(
'hidden-tool',
{ description: 'Starts disabled', inputSchema: z.object({}) },
async (): Promise<CallToolResult> => ({ content: [{ type: 'text', text: 'hidden' }] })
);
hidden.disable();

mcpServer.registerTool(
'enable-hidden',
{ description: 'Enables hidden-tool from inside its own handler', inputSchema: z.object({}) },
async (): Promise<CallToolResult> => {
hidden.enable();
return { content: [{ type: 'text', text: 'enabled hidden-tool' }] };
}
);

return mcpServer;
}

function postRequest(body: unknown): Request {
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
},
body: JSON.stringify(body)
});
}

describe('stateless Streamable HTTP — tools/list_changed emitted from inside a tools/call handler', () => {
it('delivers notifications/tools/list_changed on the same response stream as the tools/call result', async () => {
const mcpServer = buildServer();
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await mcpServer.connect(transport);

await transport.handleRequest(
postRequest({
jsonrpc: '2.0',
id: 'init-1',
method: 'initialize',
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test-client', version: '1.0' } }
})
);

const response = await transport.handleRequest(
postRequest({
jsonrpc: '2.0',
id: 'call-1',
method: 'tools/call',
params: { name: 'enable-hidden', arguments: {} }
})
);

expect(response.status).toBe(200);
const body = await readFullSSEBody(response);

// The tool call itself always succeeds — this is not in question.
expect(body).toContain('enabled hidden-tool');

// This is the bug: on a stateless transport there is no standalone SSE
// stream for a `relatedRequestId`-less notification to land on, so it
// is silently dropped instead of riding the current request's stream.
expect(body).toContain('notifications/tools/list_changed');

await transport.close();
await mcpServer.close();
});
});
Loading