diff --git a/.changeset/fix-inline-list-changed-notifications.md b/.changeset/fix-inline-list-changed-notifications.md new file mode 100644 index 000000000..9d4f6ca8c --- /dev/null +++ b/.changeset/fix-inline-list-changed-notifications.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Allow `sendToolListChanged`, `sendResourceListChanged`, and `sendPromptListChanged` to forward notification options such as `relatedRequestId`. This lets streamable HTTP servers deliver those notifications inline on the active POST SSE response instead of silently dropping them +when no standalone GET SSE channel is open. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 4d9f81c50..db0fb73d5 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -13,6 +13,7 @@ import type { ListResourcesResult, ListToolsResult, LoggingMessageNotification, + NotificationOptions, Prompt, PromptReference, ReadResourceResult, @@ -973,27 +974,27 @@ export class McpServer { /** * Sends a resource list changed event to the client, if connected. */ - sendResourceListChanged() { + sendResourceListChanged(options?: NotificationOptions) { if (this.isConnected()) { - this.server.sendResourceListChanged(); + this.server.sendResourceListChanged(options); } } /** * Sends a tool list changed event to the client, if connected. */ - sendToolListChanged() { + sendToolListChanged(options?: NotificationOptions) { if (this.isConnected()) { - this.server.sendToolListChanged(); + this.server.sendToolListChanged(options); } } /** * Sends a prompt list changed event to the client, if connected. */ - sendPromptListChanged() { + sendPromptListChanged(options?: NotificationOptions) { if (this.isConnected()) { - this.server.sendPromptListChanged(); + this.server.sendPromptListChanged(options); } } } diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index f1a1851f4..a2a758719 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -660,17 +660,20 @@ export class Server extends Protocol { }); } - async sendResourceListChanged() { - return this.notification({ - method: 'notifications/resources/list_changed' - }); + async sendResourceListChanged(options?: NotificationOptions) { + return this.notification( + { + method: 'notifications/resources/list_changed' + }, + options + ); } - async sendToolListChanged() { - return this.notification({ method: 'notifications/tools/list_changed' }); + async sendToolListChanged(options?: NotificationOptions) { + return this.notification({ method: 'notifications/tools/list_changed' }, options); } - async sendPromptListChanged() { - return this.notification({ method: 'notifications/prompts/list_changed' }); + async sendPromptListChanged(options?: NotificationOptions) { + return this.notification({ method: 'notifications/prompts/list_changed' }, options); } } diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index aa8ede227..d5a7ee9a9 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -107,6 +107,14 @@ function parseSSEData(text: string): unknown { return JSON.parse(dataLine.slice(5).trim()); } +function parseSSEEvents(text: string): unknown[] { + return text + .split('\n\n') + .map(chunk => chunk.trim()) + .filter(chunk => chunk.includes('data:')) + .map(parseSSEData); +} + function expectErrorResponse(data: unknown, expectedCode: number, expectedMessagePattern: RegExp): void { expect(data).toMatchObject({ jsonrpc: '2.0', @@ -271,6 +279,63 @@ describe('Zod v4', () => { }); }); + it('should inline tool list change notifications on the active POST SSE stream', async () => { + sessionId = await initializeServer(); + + mcpServer.registerTool( + 'unlock-tools', + { + description: 'Unlocks additional tools', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + mcpServer.registerTool( + 'secret-tool', + { + description: 'A newly unlocked tool', + inputSchema: z.object({}) + }, + async (): Promise => ({ + content: [{ type: 'text', text: 'Unlocked tool' }] + }) + ); + + await mcpServer.sendToolListChanged({ relatedRequestId: ctx.mcpReq.id }); + + return { content: [{ type: 'text', text: 'Unlocked tools' }] }; + } + ); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'unlock-tools', + arguments: {} + }, + id: 'call-1' + }; + + const request = createRequest('POST', toolCallMessage, { sessionId }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + + const events = parseSSEEvents(await response.text()); + + expect(events).toContainEqual({ + jsonrpc: '2.0', + method: 'notifications/tools/list_changed' + }); + expect(events).toContainEqual({ + jsonrpc: '2.0', + result: { + content: [{ type: 'text', text: 'Unlocked tools' }] + }, + id: 'call-1' + }); + }); + it('should reject requests without a valid session ID', async () => { const request = createRequest('POST', TEST_MESSAGES.toolsList); const response = await transport.handleRequest(request);