diff --git a/package-lock.json b/package-lock.json index 26261a7ade..2b208d8d4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -169,6 +169,39 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", + "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0-beta.5.tgz", + "integrity": "sha512-flrOzx5qFLBM9I8IYRGfYUY02erFmxOvYQ9sEE6iSj/ZZpRYKorXWscM/qrfW9rwlcGLyymHhnP2AaDbMB1lIA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0-beta.5", + "hono": "^4.11.4" + }, + "peerDependenciesMeta": { + "hono": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -209,6 +242,19 @@ } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", + "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0-beta.5", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/server-everything": { "resolved": "src/everything", "link": true @@ -217,6 +263,33 @@ "resolved": "src/filesystem", "link": true }, + "node_modules/@modelcontextprotocol/server-legacy": { + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", + "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", + "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0-beta.5", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "express-rate-limit": "^8.2.1", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "express": "^4.18.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/server-memory": { "resolved": "src/memory", "link": true @@ -3808,7 +3881,10 @@ "version": "2.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/node": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "cors": "^2.8.5", "express": "^5.2.1", "jszip": "^3.10.1", diff --git a/src/everything/README.md b/src/everything/README.md index a5943c6fce..5d2820a3a8 100644 --- a/src/everything/README.md +++ b/src/everything/README.md @@ -9,6 +9,24 @@ This MCP server attempts to exercise all the features of the MCP protocol. It is not intended to be a useful server, but rather a test server for builders of MCP clients. It implements prompts, tools, resources, sampling, and more to showcase MCP capabilities. +## Protocol support + +The server is **dual-era** — one codebase serving both protocol generations, so a client +can be exercised against either without changing servers: + +| Era | Revisions | Negotiated by | +| ---------- | ---------------------------- | --------------------------------------------------- | +| **legacy** | `2024-10-07` … `2025-11-25` | the `initialize` handshake | +| **modern** | `2026-07-28` and later | a [`server/discover`](https://modelcontextprotocol.io/specification/draft/server/discover) probe | + +Both `stdio` and `streamableHttp` serve either era; `streamableHttp` does it from a single +endpoint. The deprecated `sse` transport is legacy-only. See +[Architecture](docs/architecture.md) for what actually differs between them, and +[Server Features](docs/features.md) for how the tools behave on each. + +Requires **Node.js >= 20** and is ESM-only, inherited from the v2 SDK +(`@modelcontextprotocol/server`). + ## Tools, Resources, Prompts, and Other Features A complete list of the registered MCP primitives and other protocol features demonstrated can be found in the [Server Features](docs/features.md) document. diff --git a/src/everything/__tests__/prompts.test.ts b/src/everything/__tests__/prompts.test.ts index bff176ac47..2e5fdbb340 100644 --- a/src/everything/__tests__/prompts.test.ts +++ b/src/everything/__tests__/prompts.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpServer } from '@modelcontextprotocol/server'; import { registerSimplePrompt } from '../prompts/simple.js'; import { registerArgumentsPrompt } from '../prompts/args.js'; import { registerPromptWithCompletions } from '../prompts/completions.js'; diff --git a/src/everything/__tests__/registrations.test.ts b/src/everything/__tests__/registrations.test.ts index 421d759e07..f16b920aad 100644 --- a/src/everything/__tests__/registrations.test.ts +++ b/src/everything/__tests__/registrations.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from 'vitest'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { describe, it, expect, vi } from "vitest"; +import { McpServer } from "@modelcontextprotocol/server"; // Create mock server function createMockServer() { @@ -16,99 +16,60 @@ function createMockServer() { } as unknown as McpServer; } -describe('Registration Index Files', () => { - describe('tools/index.ts', () => { - it('should register all standard tools', async () => { - const { registerTools } = await import('../tools/index.js'); +describe("Registration Index Files", () => { + describe("tools/index.ts", () => { + it("should register all tools unconditionally", async () => { + const { registerTools } = await import("../tools/index.js"); const mockServer = createMockServer(); registerTools(mockServer); - // Should register 12 standard tools (non-conditional) - expect(mockServer.registerTool).toHaveBeenCalledTimes(12); + // Every tool registers up front. There is no capability-gated second + // pass any more: tools needing elicitation / sampling / roots return + // `inputRequired(...)`, and the SDK refuses the embedded request with + // `-32021` at dispatch when the caller lacks the capability -- on both + // eras. Note the mock advertises NO client capabilities, and the + // capability-gated tools are still expected below. + expect(mockServer.registerTool).toHaveBeenCalledTimes(17); // Verify specific tools are registered const registeredTools = (mockServer.registerTool as any).mock.calls.map( (call: any[]) => call[0] ); - expect(registeredTools).toContain('echo'); - expect(registeredTools).toContain('get-sum'); - expect(registeredTools).toContain('get-env'); - expect(registeredTools).toContain('get-tiny-image'); - expect(registeredTools).toContain('get-structured-content'); - expect(registeredTools).toContain('get-annotated-message'); - expect(registeredTools).toContain('trigger-long-running-operation'); - expect(registeredTools).toContain('get-resource-links'); - expect(registeredTools).toContain('get-resource-reference'); - expect(registeredTools).toContain('gzip-file-as-resource'); - expect(registeredTools).toContain('toggle-simulated-logging'); - expect(registeredTools).toContain('toggle-subscriber-updates'); + expect(registeredTools).toContain("echo"); + expect(registeredTools).toContain("get-sum"); + expect(registeredTools).toContain("get-env"); + expect(registeredTools).toContain("get-tiny-image"); + expect(registeredTools).toContain("get-structured-content"); + expect(registeredTools).toContain("get-annotated-message"); + expect(registeredTools).toContain("trigger-long-running-operation"); + expect(registeredTools).toContain("get-resource-links"); + expect(registeredTools).toContain("get-resource-reference"); + expect(registeredTools).toContain("gzip-file-as-resource"); + expect(registeredTools).toContain("toggle-simulated-logging"); + expect(registeredTools).toContain("toggle-subscriber-updates"); + + // Formerly "conditional" tools -- now registered up front. + expect(registeredTools).toContain("get-roots-list"); + expect(registeredTools).toContain("trigger-elicitation-request"); + expect(registeredTools).toContain("trigger-url-elicitation"); + expect(registeredTools).toContain("trigger-sampling-request"); + + // Formerly registered through the experimental tasks API, now an + // ordinary multi-round-trip tool. + expect(registeredTools).toContain("simulate-research-query"); }); - it('should register conditional tools based on capabilities', async () => { - const { registerConditionalTools } = await import('../tools/index.js'); - - // Server with all capabilities including experimental tasks API - const mockServerWithCapabilities = { - registerTool: vi.fn(), - server: { - getClientCapabilities: vi.fn(() => ({ - roots: {}, - elicitation: { url: {} }, - sampling: {}, - })), - }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, - } as unknown as McpServer; - - registerConditionalTools(mockServerWithCapabilities); - - // Should register 4 conditional tools via registerTool when all capabilities - // are present. Task-based tools register via registerToolTask (counted separately), - // so they are not included in this registerTool count. - expect(mockServerWithCapabilities.registerTool).toHaveBeenCalledTimes(4); - - const registeredTools = ( - mockServerWithCapabilities.registerTool as any - ).mock.calls.map((call: any[]) => call[0]); - expect(registeredTools).toContain('get-roots-list'); - expect(registeredTools).toContain('trigger-elicitation-request'); - expect(registeredTools).toContain('trigger-url-elicitation'); - expect(registeredTools).toContain('trigger-sampling-request'); - - // Task-based tools are registered via experimental.tasks.registerToolTask - expect(mockServerWithCapabilities.experimental.tasks.registerToolTask).toHaveBeenCalled(); - }); + it("should not expose a separate conditional registration pass", async () => { + const toolsIndex = await import("../tools/index.js"); - it('should not register conditional tools when capabilities missing', async () => { - const { registerConditionalTools } = await import('../tools/index.js'); - - const mockServerNoCapabilities = { - registerTool: vi.fn(), - server: { - getClientCapabilities: vi.fn(() => ({})), - }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, - } as unknown as McpServer; - - registerConditionalTools(mockServerNoCapabilities); - - // Should not register any capability-gated tools when capabilities are missing - expect(mockServerNoCapabilities.registerTool).not.toHaveBeenCalled(); + expect("registerConditionalTools" in toolsIndex).toBe(false); }); }); - describe('prompts/index.ts', () => { - it('should register all prompts', async () => { - const { registerPrompts } = await import('../prompts/index.js'); + describe("prompts/index.ts", () => { + it("should register all prompts", async () => { + const { registerPrompts } = await import("../prompts/index.js"); const mockServer = createMockServer(); registerPrompts(mockServer); @@ -116,39 +77,39 @@ describe('Registration Index Files', () => { // Should register 4 prompts expect(mockServer.registerPrompt).toHaveBeenCalledTimes(4); - const registeredPrompts = (mockServer.registerPrompt as any).mock.calls.map( - (call: any[]) => call[0] - ); - expect(registeredPrompts).toContain('simple-prompt'); - expect(registeredPrompts).toContain('args-prompt'); - expect(registeredPrompts).toContain('completable-prompt'); - expect(registeredPrompts).toContain('resource-prompt'); + const registeredPrompts = ( + mockServer.registerPrompt as any + ).mock.calls.map((call: any[]) => call[0]); + expect(registeredPrompts).toContain("simple-prompt"); + expect(registeredPrompts).toContain("args-prompt"); + expect(registeredPrompts).toContain("completable-prompt"); + expect(registeredPrompts).toContain("resource-prompt"); }); }); - describe('resources/index.ts', () => { - it('should register resource templates', async () => { - const { registerResources } = await import('../resources/index.js'); + describe("resources/index.ts", () => { + it("should register resource templates", async () => { + const { registerResources } = await import("../resources/index.js"); const mockServer = createMockServer(); registerResources(mockServer); // Should register at least the 2 resource templates (text and blob) plus file resources expect(mockServer.registerResource).toHaveBeenCalled(); - const registeredResources = (mockServer.registerResource as any).mock.calls.map( - (call: any[]) => call[0] - ); - expect(registeredResources).toContain('Dynamic Text Resource'); - expect(registeredResources).toContain('Dynamic Blob Resource'); + const registeredResources = ( + mockServer.registerResource as any + ).mock.calls.map((call: any[]) => call[0]); + expect(registeredResources).toContain("Dynamic Text Resource"); + expect(registeredResources).toContain("Dynamic Blob Resource"); }); - it('should read instructions from file', async () => { - const { readInstructions } = await import('../resources/index.js'); + it("should read instructions from file", async () => { + const { readInstructions } = await import("../resources/index.js"); const instructions = readInstructions(); // Should return a string (either content or error message) - expect(typeof instructions).toBe('string'); + expect(typeof instructions).toBe("string"); expect(instructions.length).toBeGreaterThan(0); }); }); diff --git a/src/everything/__tests__/resources.test.ts b/src/everything/__tests__/resources.test.ts index a22b904175..f7b4037b65 100644 --- a/src/everything/__tests__/resources.test.ts +++ b/src/everything/__tests__/resources.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import { textResource, blobResource, diff --git a/src/everything/__tests__/server.test.ts b/src/everything/__tests__/server.test.ts index e7985dd982..dd02dfe641 100644 --- a/src/everything/__tests__/server.test.ts +++ b/src/everything/__tests__/server.test.ts @@ -1,41 +1,49 @@ -import { describe, it, expect, vi } from 'vitest'; -import { createServer } from '../server/index.js'; +import { describe, it, expect } from "vitest"; +import { createServer, cleanupSession } from "../server/index.js"; -describe('Server Factory', () => { - describe('createServer', () => { - it('should return a ServerFactoryResponse object', () => { - const result = createServer(); +describe("Server Factory", () => { + describe("createServer", () => { + it("should return an McpServer instance", () => { + const server = createServer(); - expect(result).toHaveProperty('server'); - expect(result).toHaveProperty('cleanup'); + expect(server).toBeDefined(); + expect(server.server).toBeDefined(); + }); + + it("should expose a session cleanup function", () => { + expect(typeof cleanupSession).toBe("function"); + expect(() => cleanupSession()).not.toThrow(); }); - it('should return a cleanup function', () => { - const { cleanup } = createServer(); + it("should set an oninitialized handler on a legacy-era instance", () => { + // The handshake hook only exists on the legacy era: 2026-07-28 has no + // `initialize`, so roots are pulled by the `get-roots-list` tool instead. + const server = createServer({ era: "legacy" }); - expect(typeof cleanup).toBe('function'); + expect(server.server.oninitialized).toBeDefined(); }); - it('should create an McpServer instance', () => { - const { server } = createServer(); + it("should NOT set an oninitialized handler on a modern-era instance", () => { + const server = createServer({ era: "modern" }); - expect(server).toBeDefined(); - expect(server.server).toBeDefined(); + expect(server.server.oninitialized).toBeUndefined(); }); - it('should have an oninitialized handler set', () => { - const { server } = createServer(); + it("should default to the legacy handshake hook when no context is given", () => { + // A bare `createServer()` (tests, and the SSE transport before it passes + // its own context) must not lose the legacy-era roots sync. + const server = createServer(); expect(server.server.oninitialized).toBeDefined(); }); - it('should allow multiple servers to be created', () => { - const result1 = createServer(); - const result2 = createServer(); + it("should allow multiple servers to be created", () => { + const server1 = createServer(); + const server2 = createServer(); - expect(result1.server).toBeDefined(); - expect(result2.server).toBeDefined(); - expect(result1.server).not.toBe(result2.server); + expect(server1).toBeDefined(); + expect(server2).toBeDefined(); + expect(server1).not.toBe(server2); }); }); }); diff --git a/src/everything/__tests__/tools.test.ts b/src/everything/__tests__/tools.test.ts index a50bbd6592..1b6db4d4bb 100644 --- a/src/everything/__tests__/tools.test.ts +++ b/src/everything/__tests__/tools.test.ts @@ -1,25 +1,25 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { registerEchoTool, EchoSchema } from '../tools/echo.js'; -import { registerGetSumTool } from '../tools/get-sum.js'; -import { registerGetEnvTool } from '../tools/get-env.js'; -import { registerGetTinyImageTool, MCP_TINY_IMAGE } from '../tools/get-tiny-image.js'; -import { registerGetStructuredContentTool } from '../tools/get-structured-content.js'; -import { registerGetAnnotatedMessageTool } from '../tools/get-annotated-message.js'; -import { registerTriggerLongRunningOperationTool } from '../tools/trigger-long-running-operation.js'; -import { registerGetResourceLinksTool } from '../tools/get-resource-links.js'; -import { registerGetResourceReferenceTool } from '../tools/get-resource-reference.js'; -import { registerToggleSimulatedLoggingTool } from '../tools/toggle-simulated-logging.js'; -import { registerToggleSubscriberUpdatesTool } from '../tools/toggle-subscriber-updates.js'; -import { registerTriggerSamplingRequestTool } from '../tools/trigger-sampling-request.js'; -import { registerTriggerElicitationRequestTool } from '../tools/trigger-elicitation-request.js'; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { McpServer } from "@modelcontextprotocol/server"; +import { registerEchoTool, EchoSchema } from "../tools/echo.js"; +import { registerGetSumTool } from "../tools/get-sum.js"; +import { registerGetEnvTool } from "../tools/get-env.js"; import { - registerTriggerUrlElicitationTool, - __resetIssuedErrorPathElicitations, -} from '../tools/trigger-url-elicitation.js'; -import { registerGetRootsListTool } from '../tools/get-roots-list.js'; -import { registerGZipFileAsResourceTool } from '../tools/gzip-file-as-resource.js'; -import { registerSimulateResearchQueryTool } from '../tools/simulate-research-query.js'; + registerGetTinyImageTool, + MCP_TINY_IMAGE, +} from "../tools/get-tiny-image.js"; +import { registerGetStructuredContentTool } from "../tools/get-structured-content.js"; +import { registerGetAnnotatedMessageTool } from "../tools/get-annotated-message.js"; +import { registerTriggerLongRunningOperationTool } from "../tools/trigger-long-running-operation.js"; +import { registerGetResourceLinksTool } from "../tools/get-resource-links.js"; +import { registerGetResourceReferenceTool } from "../tools/get-resource-reference.js"; +import { registerToggleSimulatedLoggingTool } from "../tools/toggle-simulated-logging.js"; +import { registerToggleSubscriberUpdatesTool } from "../tools/toggle-subscriber-updates.js"; +import { registerTriggerSamplingRequestTool } from "../tools/trigger-sampling-request.js"; +import { registerTriggerElicitationRequestTool } from "../tools/trigger-elicitation-request.js"; +import { registerTriggerUrlElicitationTool } from "../tools/trigger-url-elicitation.js"; +import { registerGetRootsListTool } from "../tools/get-roots-list.js"; +import { registerGZipFileAsResourceTool } from "../tools/gzip-file-as-resource.js"; +import { registerSimulateResearchQueryTool } from "../tools/simulate-research-query.js"; // Helper to capture registered tool handlers function createMockServer() { @@ -42,369 +42,396 @@ function createMockServer() { return { mockServer, handlers, configs }; } -describe('Tools', () => { - describe('echo', () => { - it('should echo back the message', async () => { +describe("Tools", () => { + describe("echo", () => { + it("should echo back the message", async () => { const { mockServer, handlers } = createMockServer(); registerEchoTool(mockServer); - const handler = handlers.get('echo')!; - const result = await handler({ message: 'Hello, World!' }); + const handler = handlers.get("echo")!; + const result = await handler({ message: "Hello, World!" }); expect(result).toEqual({ - content: [{ type: 'text', text: 'Echo: Hello, World!' }], + content: [{ type: "text", text: "Echo: Hello, World!" }], }); }); - it('should handle empty message', async () => { + it("should handle empty message", async () => { const { mockServer, handlers } = createMockServer(); registerEchoTool(mockServer); - const handler = handlers.get('echo')!; - const result = await handler({ message: '' }); + const handler = handlers.get("echo")!; + const result = await handler({ message: "" }); expect(result).toEqual({ - content: [{ type: 'text', text: 'Echo: ' }], + content: [{ type: "text", text: "Echo: " }], }); }); - it('should reject invalid input', async () => { + it("should reject invalid input", async () => { const { mockServer, handlers } = createMockServer(); registerEchoTool(mockServer); - const handler = handlers.get('echo')!; + const handler = handlers.get("echo")!; await expect(handler({})).rejects.toThrow(); await expect(handler({ message: 123 })).rejects.toThrow(); }); }); - describe('EchoSchema', () => { - it('should validate correct input', () => { - const result = EchoSchema.parse({ message: 'test' }); - expect(result).toEqual({ message: 'test' }); + describe("EchoSchema", () => { + it("should validate correct input", () => { + const result = EchoSchema.parse({ message: "test" }); + expect(result).toEqual({ message: "test" }); }); - it('should reject missing message', () => { + it("should reject missing message", () => { expect(() => EchoSchema.parse({})).toThrow(); }); - it('should reject non-string message', () => { + it("should reject non-string message", () => { expect(() => EchoSchema.parse({ message: 123 })).toThrow(); }); }); - describe('get-sum', () => { - it('should calculate sum of two positive numbers', async () => { + describe("get-sum", () => { + it("should calculate sum of two positive numbers", async () => { const { mockServer, handlers } = createMockServer(); registerGetSumTool(mockServer); - const handler = handlers.get('get-sum')!; + const handler = handlers.get("get-sum")!; const result = await handler({ a: 5, b: 3 }); expect(result).toEqual({ - content: [{ type: 'text', text: 'The sum of 5 and 3 is 8.' }], + content: [{ type: "text", text: "The sum of 5 and 3 is 8." }], }); }); - it('should calculate sum with negative numbers', async () => { + it("should calculate sum with negative numbers", async () => { const { mockServer, handlers } = createMockServer(); registerGetSumTool(mockServer); - const handler = handlers.get('get-sum')!; + const handler = handlers.get("get-sum")!; const result = await handler({ a: -5, b: 3 }); expect(result).toEqual({ - content: [{ type: 'text', text: 'The sum of -5 and 3 is -2.' }], + content: [{ type: "text", text: "The sum of -5 and 3 is -2." }], }); }); - it('should calculate sum with zero', async () => { + it("should calculate sum with zero", async () => { const { mockServer, handlers } = createMockServer(); registerGetSumTool(mockServer); - const handler = handlers.get('get-sum')!; + const handler = handlers.get("get-sum")!; const result = await handler({ a: 0, b: 0 }); expect(result).toEqual({ - content: [{ type: 'text', text: 'The sum of 0 and 0 is 0.' }], + content: [{ type: "text", text: "The sum of 0 and 0 is 0." }], }); }); - it('should handle floating point numbers', async () => { + it("should handle floating point numbers", async () => { const { mockServer, handlers } = createMockServer(); registerGetSumTool(mockServer); - const handler = handlers.get('get-sum')!; + const handler = handlers.get("get-sum")!; const result = await handler({ a: 1.5, b: 2.5 }); expect(result).toEqual({ - content: [{ type: 'text', text: 'The sum of 1.5 and 2.5 is 4.' }], + content: [{ type: "text", text: "The sum of 1.5 and 2.5 is 4." }], }); }); - it('should reject invalid input', async () => { + it("should reject invalid input", async () => { const { mockServer, handlers } = createMockServer(); registerGetSumTool(mockServer); - const handler = handlers.get('get-sum')!; + const handler = handlers.get("get-sum")!; await expect(handler({})).rejects.toThrow(); - await expect(handler({ a: 'not a number', b: 5 })).rejects.toThrow(); + await expect(handler({ a: "not a number", b: 5 })).rejects.toThrow(); await expect(handler({ a: 5 })).rejects.toThrow(); }); }); - describe('get-env', () => { - it('should return all environment variables as JSON', async () => { + describe("get-env", () => { + it("should return all environment variables as JSON", async () => { const { mockServer, handlers } = createMockServer(); registerGetEnvTool(mockServer); - const handler = handlers.get('get-env')!; - process.env.TEST_VAR_EVERYTHING = 'test_value'; + const handler = handlers.get("get-env")!; + process.env.TEST_VAR_EVERYTHING = "test_value"; const result = await handler({}); expect(result.content).toHaveLength(1); - expect(result.content[0].type).toBe('text'); + expect(result.content[0].type).toBe("text"); const envJson = JSON.parse(result.content[0].text); - expect(envJson.TEST_VAR_EVERYTHING).toBe('test_value'); + expect(envJson.TEST_VAR_EVERYTHING).toBe("test_value"); delete process.env.TEST_VAR_EVERYTHING; }); - it('should return valid JSON', async () => { + it("should return valid JSON", async () => { const { mockServer, handlers } = createMockServer(); registerGetEnvTool(mockServer); - const handler = handlers.get('get-env')!; + const handler = handlers.get("get-env")!; const result = await handler({}); expect(() => JSON.parse(result.content[0].text)).not.toThrow(); }); }); - describe('get-tiny-image', () => { - it('should return image content with text descriptions', async () => { + describe("get-tiny-image", () => { + it("should return image content with text descriptions", async () => { const { mockServer, handlers } = createMockServer(); registerGetTinyImageTool(mockServer); - const handler = handlers.get('get-tiny-image')!; + const handler = handlers.get("get-tiny-image")!; const result = await handler({}); expect(result.content).toHaveLength(3); expect(result.content[0]).toEqual({ - type: 'text', + type: "text", text: "Here's the image you requested:", }); expect(result.content[1]).toEqual({ - type: 'image', + type: "image", data: MCP_TINY_IMAGE, - mimeType: 'image/png', + mimeType: "image/png", }); expect(result.content[2]).toEqual({ - type: 'text', - text: 'The image above is the MCP logo.', + type: "text", + text: "The image above is the MCP logo.", }); }); - it('should return valid base64 image data', async () => { + it("should return valid base64 image data", async () => { const { mockServer, handlers } = createMockServer(); registerGetTinyImageTool(mockServer); - const handler = handlers.get('get-tiny-image')!; + const handler = handlers.get("get-tiny-image")!; const result = await handler({}); const imageContent = result.content[1]; - expect(imageContent.type).toBe('image'); - expect(imageContent.mimeType).toBe('image/png'); + expect(imageContent.type).toBe("image"); + expect(imageContent.mimeType).toBe("image/png"); // Verify it's valid base64 - expect(() => Buffer.from(imageContent.data, 'base64')).not.toThrow(); + expect(() => Buffer.from(imageContent.data, "base64")).not.toThrow(); }); }); - describe('get-structured-content', () => { - it('should return weather for New York', async () => { + describe("get-structured-content", () => { + it("should return weather for New York", async () => { const { mockServer, handlers } = createMockServer(); registerGetStructuredContentTool(mockServer); - const handler = handlers.get('get-structured-content')!; - const result = await handler({ location: 'New York' }); + const handler = handlers.get("get-structured-content")!; + const result = await handler({ location: "New York" }); expect(result.structuredContent).toEqual({ temperature: 33, - conditions: 'Cloudy', + conditions: "Cloudy", humidity: 82, }); - expect(result.content[0].type).toBe('text'); - expect(JSON.parse(result.content[0].text)).toEqual(result.structuredContent); + expect(result.content[0].type).toBe("text"); + expect(JSON.parse(result.content[0].text)).toEqual( + result.structuredContent + ); }); - it('should return weather for Chicago', async () => { + it("should return weather for Chicago", async () => { const { mockServer, handlers } = createMockServer(); registerGetStructuredContentTool(mockServer); - const handler = handlers.get('get-structured-content')!; - const result = await handler({ location: 'Chicago' }); + const handler = handlers.get("get-structured-content")!; + const result = await handler({ location: "Chicago" }); expect(result.structuredContent).toEqual({ temperature: 36, - conditions: 'Light rain / drizzle', + conditions: "Light rain / drizzle", humidity: 82, }); }); - it('should return weather for Los Angeles', async () => { + it("should return weather for Los Angeles", async () => { const { mockServer, handlers } = createMockServer(); registerGetStructuredContentTool(mockServer); - const handler = handlers.get('get-structured-content')!; - const result = await handler({ location: 'Los Angeles' }); + const handler = handlers.get("get-structured-content")!; + const result = await handler({ location: "Los Angeles" }); expect(result.structuredContent).toEqual({ temperature: 73, - conditions: 'Sunny / Clear', + conditions: "Sunny / Clear", humidity: 48, }); }); }); - describe('get-annotated-message', () => { - it('should return error message with high priority', async () => { + describe("get-annotated-message", () => { + it("should return error message with high priority", async () => { const { mockServer, handlers } = createMockServer(); registerGetAnnotatedMessageTool(mockServer); - const handler = handlers.get('get-annotated-message')!; - const result = await handler({ messageType: 'error', includeImage: false }); + const handler = handlers.get("get-annotated-message")!; + const result = await handler({ + messageType: "error", + includeImage: false, + }); expect(result.content).toHaveLength(1); - expect(result.content[0].text).toBe('Error: Operation failed'); + expect(result.content[0].text).toBe("Error: Operation failed"); expect(result.content[0].annotations).toEqual({ priority: 1.0, - audience: ['user', 'assistant'], + audience: ["user", "assistant"], }); }); - it('should return success message with medium priority', async () => { + it("should return success message with medium priority", async () => { const { mockServer, handlers } = createMockServer(); registerGetAnnotatedMessageTool(mockServer); - const handler = handlers.get('get-annotated-message')!; - const result = await handler({ messageType: 'success', includeImage: false }); + const handler = handlers.get("get-annotated-message")!; + const result = await handler({ + messageType: "success", + includeImage: false, + }); - expect(result.content[0].text).toBe('Operation completed successfully'); + expect(result.content[0].text).toBe("Operation completed successfully"); expect(result.content[0].annotations.priority).toBe(0.7); - expect(result.content[0].annotations.audience).toEqual(['user']); + expect(result.content[0].annotations.audience).toEqual(["user"]); }); - it('should return debug message with low priority', async () => { + it("should return debug message with low priority", async () => { const { mockServer, handlers } = createMockServer(); registerGetAnnotatedMessageTool(mockServer); - const handler = handlers.get('get-annotated-message')!; - const result = await handler({ messageType: 'debug', includeImage: false }); + const handler = handlers.get("get-annotated-message")!; + const result = await handler({ + messageType: "debug", + includeImage: false, + }); - expect(result.content[0].text).toContain('Debug:'); + expect(result.content[0].text).toContain("Debug:"); expect(result.content[0].annotations.priority).toBe(0.3); - expect(result.content[0].annotations.audience).toEqual(['assistant']); + expect(result.content[0].annotations.audience).toEqual(["assistant"]); }); - it('should include annotated image when requested', async () => { + it("should include annotated image when requested", async () => { const { mockServer, handlers } = createMockServer(); registerGetAnnotatedMessageTool(mockServer); - const handler = handlers.get('get-annotated-message')!; - const result = await handler({ messageType: 'success', includeImage: true }); + const handler = handlers.get("get-annotated-message")!; + const result = await handler({ + messageType: "success", + includeImage: true, + }); expect(result.content).toHaveLength(2); - expect(result.content[1].type).toBe('image'); + expect(result.content[1].type).toBe("image"); expect(result.content[1].annotations).toEqual({ priority: 0.5, - audience: ['user'], + audience: ["user"], }); }); }); - describe('trigger-long-running-operation', () => { - it('should complete operation and return result', async () => { + describe("trigger-long-running-operation", () => { + it("should complete operation and return result", async () => { const { mockServer, handlers } = createMockServer(); registerTriggerLongRunningOperationTool(mockServer); - const handler = handlers.get('trigger-long-running-operation')!; + const handler = handlers.get("trigger-long-running-operation")!; // Use very short duration for test const result = await handler( { duration: 0.1, steps: 2 }, - { _meta: {}, requestId: 'test-123' } + { mcpReq: { _meta: {}, id: "test-123", notify: vi.fn() } } ); - expect(result.content[0].text).toContain('Long running operation completed'); - expect(result.content[0].text).toContain('Duration: 0.1 seconds'); - expect(result.content[0].text).toContain('Steps: 2'); + expect(result.content[0].text).toContain( + "Long running operation completed" + ); + expect(result.content[0].text).toContain("Duration: 0.1 seconds"); + expect(result.content[0].text).toContain("Steps: 2"); }, 10000); - it('should send progress notifications when progressToken provided', async () => { + it("should send progress notifications when progressToken provided", async () => { const { mockServer, handlers } = createMockServer(); registerTriggerLongRunningOperationTool(mockServer); - const handler = handlers.get('trigger-long-running-operation')!; + // Progress is now emitted through `ctx.mcpReq.notify`, which binds the + // notification to the originating request. On 2026-07-28 request-scoped + // notifications ride that request's own response stream, so there is no + // standalone `server.notification(..., { relatedRequestId })` call any more. + const notify = vi.fn(); + const handler = handlers.get("trigger-long-running-operation")!; await handler( { duration: 0.1, steps: 2 }, - { _meta: { progressToken: 'token-123' }, requestId: 'test-456', sessionId: 'session-1' } + { + sessionId: "session-1", + mcpReq: { + _meta: { progressToken: "token-123" }, + id: "test-456", + notify, + }, + } ); - expect(mockServer.server.notification).toHaveBeenCalledTimes(2); - expect(mockServer.server.notification).toHaveBeenCalledWith( + expect(notify).toHaveBeenCalledTimes(2); + expect(notify).toHaveBeenCalledWith( expect.objectContaining({ - method: 'notifications/progress', + method: "notifications/progress", params: expect.objectContaining({ - progressToken: 'token-123', + progressToken: "token-123", }), - }), - expect.any(Object) + }) ); }, 10000); }); - describe('get-resource-links', () => { - it('should return specified number of resource links', async () => { + describe("get-resource-links", () => { + it("should return specified number of resource links", async () => { const { mockServer, handlers } = createMockServer(); registerGetResourceLinksTool(mockServer); - const handler = handlers.get('get-resource-links')!; + const handler = handlers.get("get-resource-links")!; const result = await handler({ count: 3 }); // 1 intro text + 3 resource links expect(result.content).toHaveLength(4); - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('3 resource links'); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("3 resource links"); // Check resource links for (let i = 1; i < 4; i++) { - expect(result.content[i].type).toBe('resource_link'); + expect(result.content[i].type).toBe("resource_link"); expect(result.content[i].uri).toBeDefined(); expect(result.content[i].name).toBeDefined(); } }); - it('should alternate between text and blob resources', async () => { + it("should alternate between text and blob resources", async () => { const { mockServer, handlers } = createMockServer(); registerGetResourceLinksTool(mockServer); - const handler = handlers.get('get-resource-links')!; + const handler = handlers.get("get-resource-links")!; const result = await handler({ count: 4 }); // Odd IDs (1, 3) are blob, even IDs (2, 4) are text - expect(result.content[1].name).toContain('Blob'); - expect(result.content[2].name).toContain('Text'); - expect(result.content[3].name).toContain('Blob'); - expect(result.content[4].name).toContain('Text'); + expect(result.content[1].name).toContain("Blob"); + expect(result.content[2].name).toContain("Text"); + expect(result.content[3].name).toContain("Blob"); + expect(result.content[4].name).toContain("Text"); }); - it('should use default count of 3', async () => { + it("should use default count of 3", async () => { const { mockServer, handlers } = createMockServer(); registerGetResourceLinksTool(mockServer); - const handler = handlers.get('get-resource-links')!; + const handler = handlers.get("get-resource-links")!; const result = await handler({}); // 1 intro text + 3 resource links (default) @@ -412,735 +439,550 @@ describe('Tools', () => { }); }); - describe('get-resource-reference', () => { - it('should return text resource reference', async () => { + describe("get-resource-reference", () => { + it("should return text resource reference", async () => { const { mockServer, handlers } = createMockServer(); registerGetResourceReferenceTool(mockServer); - const handler = handlers.get('get-resource-reference')!; - const result = await handler({ resourceType: 'Text', resourceId: 1 }); + const handler = handlers.get("get-resource-reference")!; + const result = await handler({ resourceType: "Text", resourceId: 1 }); expect(result.content).toHaveLength(3); - expect(result.content[0].text).toContain('Resource 1'); - expect(result.content[1].type).toBe('resource'); - expect(result.content[1].resource.uri).toContain('text/1'); - expect(result.content[2].text).toContain('URI'); + expect(result.content[0].text).toContain("Resource 1"); + expect(result.content[1].type).toBe("resource"); + expect(result.content[1].resource.uri).toContain("text/1"); + expect(result.content[2].text).toContain("URI"); }); - it('should return blob resource reference', async () => { + it("should return blob resource reference", async () => { const { mockServer, handlers } = createMockServer(); registerGetResourceReferenceTool(mockServer); - const handler = handlers.get('get-resource-reference')!; - const result = await handler({ resourceType: 'Blob', resourceId: 5 }); + const handler = handlers.get("get-resource-reference")!; + const result = await handler({ resourceType: "Blob", resourceId: 5 }); - expect(result.content[1].resource.uri).toContain('blob/5'); + expect(result.content[1].resource.uri).toContain("blob/5"); }); - it('should reject invalid resource type', async () => { + it("should reject invalid resource type", async () => { const { mockServer, handlers } = createMockServer(); registerGetResourceReferenceTool(mockServer); - const handler = handlers.get('get-resource-reference')!; - await expect(handler({ resourceType: 'Invalid', resourceId: 1 })).rejects.toThrow( - 'Invalid resourceType' - ); + const handler = handlers.get("get-resource-reference")!; + await expect( + handler({ resourceType: "Invalid", resourceId: 1 }) + ).rejects.toThrow("Invalid resourceType"); }); - it('should reject invalid resource ID', async () => { + it("should reject invalid resource ID", async () => { const { mockServer, handlers } = createMockServer(); registerGetResourceReferenceTool(mockServer); - const handler = handlers.get('get-resource-reference')!; - await expect(handler({ resourceType: 'Text', resourceId: -1 })).rejects.toThrow( - 'Invalid resourceId' - ); - await expect(handler({ resourceType: 'Text', resourceId: 0 })).rejects.toThrow( - 'Invalid resourceId' - ); - await expect(handler({ resourceType: 'Text', resourceId: 1.5 })).rejects.toThrow( - 'Invalid resourceId' - ); + const handler = handlers.get("get-resource-reference")!; + await expect( + handler({ resourceType: "Text", resourceId: -1 }) + ).rejects.toThrow("Invalid resourceId"); + await expect( + handler({ resourceType: "Text", resourceId: 0 }) + ).rejects.toThrow("Invalid resourceId"); + await expect( + handler({ resourceType: "Text", resourceId: 1.5 }) + ).rejects.toThrow("Invalid resourceId"); }); }); - describe('toggle-simulated-logging', () => { - it('should start logging when not active', async () => { + describe("toggle-simulated-logging", () => { + it("should start logging when not active", async () => { const { mockServer, handlers } = createMockServer(); registerToggleSimulatedLoggingTool(mockServer); - const handler = handlers.get('toggle-simulated-logging')!; - const result = await handler({}, { sessionId: 'test-session-1' }); + const handler = handlers.get("toggle-simulated-logging")!; + const result = await handler({}, { sessionId: "test-session-1" }); - expect(result.content[0].text).toContain('Started'); - expect(result.content[0].text).toContain('test-session-1'); + expect(result.content[0].text).toContain("Started"); + expect(result.content[0].text).toContain("test-session-1"); }); - it('should stop logging when already active', async () => { + it("should stop logging when already active", async () => { const { mockServer, handlers } = createMockServer(); registerToggleSimulatedLoggingTool(mockServer); - const handler = handlers.get('toggle-simulated-logging')!; + const handler = handlers.get("toggle-simulated-logging")!; // First call starts logging - await handler({}, { sessionId: 'test-session-2' }); + await handler({}, { sessionId: "test-session-2" }); // Second call stops logging - const result = await handler({}, { sessionId: 'test-session-2' }); + const result = await handler({}, { sessionId: "test-session-2" }); - expect(result.content[0].text).toContain('Stopped'); - expect(result.content[0].text).toContain('test-session-2'); + expect(result.content[0].text).toContain("Stopped"); + expect(result.content[0].text).toContain("test-session-2"); }); - it('should handle undefined sessionId', async () => { + it("should handle undefined sessionId", async () => { const { mockServer, handlers } = createMockServer(); registerToggleSimulatedLoggingTool(mockServer); - const handler = handlers.get('toggle-simulated-logging')!; + const handler = handlers.get("toggle-simulated-logging")!; const result = await handler({}, {}); - expect(result.content[0].text).toContain('Started'); + expect(result.content[0].text).toContain("Started"); }); }); - describe('toggle-subscriber-updates', () => { - it('should start updates when not active', async () => { + describe("toggle-subscriber-updates", () => { + it("should start updates when not active", async () => { const { mockServer, handlers } = createMockServer(); registerToggleSubscriberUpdatesTool(mockServer); - const handler = handlers.get('toggle-subscriber-updates')!; - const result = await handler({}, { sessionId: 'sub-session-1' }); + const handler = handlers.get("toggle-subscriber-updates")!; + const result = await handler({}, { sessionId: "sub-session-1" }); - expect(result.content[0].text).toContain('Started'); - expect(result.content[0].text).toContain('sub-session-1'); + expect(result.content[0].text).toContain("Started"); + expect(result.content[0].text).toContain("sub-session-1"); }); - it('should stop updates when already active', async () => { + it("should stop updates when already active", async () => { const { mockServer, handlers } = createMockServer(); registerToggleSubscriberUpdatesTool(mockServer); - const handler = handlers.get('toggle-subscriber-updates')!; + const handler = handlers.get("toggle-subscriber-updates")!; // First call starts updates - await handler({}, { sessionId: 'sub-session-2' }); + await handler({}, { sessionId: "sub-session-2" }); // Second call stops updates - const result = await handler({}, { sessionId: 'sub-session-2' }); + const result = await handler({}, { sessionId: "sub-session-2" }); - expect(result.content[0].text).toContain('Stopped'); - expect(result.content[0].text).toContain('sub-session-2'); + expect(result.content[0].text).toContain("Stopped"); + expect(result.content[0].text).toContain("sub-session-2"); }); }); - describe('trigger-sampling-request', () => { - it('should not register when client does not support sampling', () => { - const { mockServer } = createMockServer(); - registerTriggerSamplingRequestTool(mockServer); - - // Tool should not be registered since mock server returns empty capabilities - expect(mockServer.registerTool).not.toHaveBeenCalled(); - }); + // --------------------------------------------------------------------------- + // Multi-round-trip (MRTR) tools. + // + // These tools no longer push server->client requests. They RETURN an + // `input_required` result naming the input they need, and the client retries + // the call carrying `inputResponses`. The same handler serves both protocol + // eras: on 2026-07-28 the client's driver fulfils and retries; on a legacy-era + // connection the SDK's legacy shim issues real server->client requests and + // re-enters the handler with the collected answers. + // + // So each tool is exercised in two rounds: call once with an empty context to + // assert the request it asks for, then call again with `inputResponses` to + // assert how it interprets the answer. + // --------------------------------------------------------------------------- + + /** A first-round context: no answers yet. */ + const firstRound = () => ({ + mcpReq: { _meta: {}, requestState: () => undefined }, + }); - it('should register when client supports sampling', () => { - const handlers: Map = new Map(); - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ sampling: {} })), - }, - } as unknown as McpServer; + /** A re-entry context carrying one round's answers. */ + const withResponses = (inputResponses: Record) => ({ + mcpReq: { _meta: {}, inputResponses, requestState: () => undefined }, + }); + describe("trigger-sampling-request", () => { + it("should register unconditionally, without a sampling capability", () => { + // The old version skipped registration unless the client advertised + // `sampling`. The SDK now refuses the embedded request at dispatch with + // -32021 instead, on both eras, so registration is unconditional. + const { mockServer } = createMockServer(); registerTriggerSamplingRequestTool(mockServer); expect(mockServer.registerTool).toHaveBeenCalledWith( - 'trigger-sampling-request', - expect.objectContaining({ - title: 'Trigger Sampling Request Tool', - description: expect.stringContaining('Sampling'), - }), + "trigger-sampling-request", + expect.objectContaining({ title: "Trigger Sampling Request Tool" }), expect.any(Function) ); }); - it('should send sampling request and return result', async () => { - const handlers: Map = new Map(); - const mockSendRequest = vi.fn().mockResolvedValue({ - model: 'test-model', - content: { type: 'text', text: 'LLM response' }, - }); - - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ sampling: {} })), - }, - } as unknown as McpServer; - + it("should return an input_required result asking for a completion", async () => { + const { mockServer, handlers } = createMockServer(); registerTriggerSamplingRequestTool(mockServer); - const handler = handlers.get('trigger-sampling-request')!; + const handler = handlers.get("trigger-sampling-request")!; const result = await handler( - { prompt: 'Test prompt', maxTokens: 50 }, - { sendRequest: mockSendRequest } + { prompt: "Tell me about MCP", maxTokens: 50 }, + firstRound() ); - expect(mockSendRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'sampling/createMessage', - params: expect.objectContaining({ - maxTokens: 50, - }), - }), - expect.anything() + expect(result.resultType).toBe("input_required"); + const request = result.inputRequests.completion; + expect(request.method).toBe("sampling/createMessage"); + expect(request.params.maxTokens).toBe(50); + expect(request.params.messages[0].content.text).toContain( + "Tell me about MCP" ); - expect(result.content[0].text).toContain('LLM sampling result'); }); - }); - describe('trigger-elicitation-request', () => { - it('should not register when client does not support elicitation', () => { - const { mockServer } = createMockServer(); - registerTriggerElicitationRequestTool(mockServer); + it("should format the completion once the client answers", async () => { + const { mockServer, handlers } = createMockServer(); + registerTriggerSamplingRequestTool(mockServer); - expect(mockServer.registerTool).not.toHaveBeenCalled(); + const handler = handlers.get("trigger-sampling-request")!; + const result = await handler( + { prompt: "Tell me about MCP", maxTokens: 50 }, + withResponses({ + completion: { + role: "assistant", + content: { type: "text", text: "MCP is a protocol." }, + model: "test-model", + }, + }) + ); + + expect(result.resultType).toBeUndefined(); + expect(result.content[0].text).toContain("LLM sampling result"); + expect(result.content[0].text).toContain("MCP is a protocol."); }); - it('should register when client supports elicitation', () => { - const handlers: Map = new Map(); - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: {} })), - }, - } as unknown as McpServer; + it("should error clearly if the wrong kind of answer comes back", async () => { + const { mockServer, handlers } = createMockServer(); + registerTriggerSamplingRequestTool(mockServer); + + const handler = handlers.get("trigger-sampling-request")!; + const result = await handler( + { prompt: "x", maxTokens: 10 }, + withResponses({ completion: { action: "accept", content: {} } }) + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Expected a sampling response"); + }); + }); + describe("trigger-elicitation-request", () => { + it("should register unconditionally, without an elicitation capability", () => { + const { mockServer } = createMockServer(); registerTriggerElicitationRequestTool(mockServer); expect(mockServer.registerTool).toHaveBeenCalledWith( - 'trigger-elicitation-request', - expect.objectContaining({ - title: 'Trigger Elicitation Request Tool', - description: expect.stringContaining('Elicitation'), - }), + "trigger-elicitation-request", + expect.objectContaining({ title: "Trigger Elicitation Request Tool" }), expect.any(Function) ); }); - it('should handle accept action with user content', async () => { - const handlers: Map = new Map(); - const mockSendRequest = vi.fn().mockResolvedValue({ - action: 'accept', - content: { - name: 'John Doe', - check: true, - email: 'john@example.com', - }, - }); - - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: {} })), - }, - } as unknown as McpServer; - + it("should return an input_required result asking for the profile form", async () => { + const { mockServer, handlers } = createMockServer(); registerTriggerElicitationRequestTool(mockServer); - const handler = handlers.get('trigger-elicitation-request')!; - const result = await handler({}, { sendRequest: mockSendRequest }); + const handler = handlers.get("trigger-elicitation-request")!; + const result = await handler({}, firstRound()); - expect(result.content[0].text).toContain('✅'); - expect(result.content[0].text).toContain('provided'); - expect(result.content[1].text).toContain('John Doe'); + expect(result.resultType).toBe("input_required"); + const request = result.inputRequests.profile; + expect(request.method).toBe("elicitation/create"); + expect(request.params.requestedSchema.required).toContain("name"); }); - it('should handle decline action', async () => { - const handlers: Map = new Map(); - const mockSendRequest = vi.fn().mockResolvedValue({ - action: 'decline', - }); - - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: {} })), - }, - } as unknown as McpServer; - + it("should handle accept action with user content", async () => { + const { mockServer, handlers } = createMockServer(); registerTriggerElicitationRequestTool(mockServer); - const handler = handlers.get('trigger-elicitation-request')!; - const result = await handler({}, { sendRequest: mockSendRequest }); + const handler = handlers.get("trigger-elicitation-request")!; + const result = await handler( + {}, + withResponses({ + profile: { + action: "accept", + content: { + name: "John Doe", + check: true, + email: "john@example.com", + }, + }, + }) + ); - expect(result.content[0].text).toContain('❌'); - expect(result.content[0].text).toContain('declined'); + expect(result.content[0].text).toContain("✅"); + expect(result.content[1].text).toContain("Name: John Doe"); + expect(result.content[1].text).toContain("Email: john@example.com"); }); - it('should handle cancel action', async () => { - const handlers: Map = new Map(); - const mockSendRequest = vi.fn().mockResolvedValue({ - action: 'cancel', - }); - - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: {} })), - }, - } as unknown as McpServer; - + it("should handle decline action", async () => { + const { mockServer, handlers } = createMockServer(); registerTriggerElicitationRequestTool(mockServer); - const handler = handlers.get('trigger-elicitation-request')!; - const result = await handler({}, { sendRequest: mockSendRequest }); - - expect(result.content[0].text).toContain('⚠️'); - expect(result.content[0].text).toContain('cancelled'); - }); - }); + const handler = handlers.get("trigger-elicitation-request")!; + const result = await handler( + {}, + withResponses({ profile: { action: "decline" } }) + ); - describe('trigger-url-elicitation', () => { - // The error-path marker is module-level state shared across cases; reset it - // so tests are independent of order and of each other's leftover keys. - beforeEach(() => { - __resetIssuedErrorPathElicitations(); + expect(result.content[0].text).toContain("❌"); + expect(result.content[0].text).toContain("declined"); }); - it('should not register when client does not support URL elicitation', () => { - const handlers: Map = new Map(); - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: { form: {} } })), - }, - } as unknown as McpServer; + it("should handle cancel action", async () => { + const { mockServer, handlers } = createMockServer(); + registerTriggerElicitationRequestTool(mockServer); - registerTriggerUrlElicitationTool(mockServer); + const handler = handlers.get("trigger-elicitation-request")!; + const result = await handler( + {}, + withResponses({ profile: { action: "cancel" } }) + ); - expect(mockServer.registerTool).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain("⚠️"); + expect(result.content[0].text).toContain("cancelled"); }); + }); - it('should register when client supports URL elicitation', () => { - const handlers: Map = new Map(); - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })), - }, - } as unknown as McpServer; - + describe("trigger-url-elicitation", () => { + it("should register unconditionally", () => { + const { mockServer } = createMockServer(); registerTriggerUrlElicitationTool(mockServer); expect(mockServer.registerTool).toHaveBeenCalledWith( - 'trigger-url-elicitation', - expect.objectContaining({ - title: 'Trigger URL Elicitation Tool', - description: expect.stringContaining('URL elicitation'), - }), + "trigger-url-elicitation", + expect.objectContaining({ title: "Trigger URL Elicitation Tool" }), expect.any(Function) ); }); - it('should send URL-mode elicitation request when errorPath is false', async () => { - const handlers: Map = new Map(); - const mockSendRequest = vi.fn().mockResolvedValue({ - action: 'accept', - }); - - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })), - }, - } as unknown as McpServer; - + it("should return an input_required result carrying a URL-mode request", async () => { + const { mockServer, handlers } = createMockServer(); registerTriggerUrlElicitationTool(mockServer); - const handler = handlers.get('trigger-url-elicitation')!; + const handler = handlers.get("trigger-url-elicitation")!; const result = await handler( - { - url: 'https://example.com/verify', - message: 'Open this page to verify your identity', - elicitationId: 'elicitation-123', - errorPath: false, - }, - { sendRequest: mockSendRequest } - ); - - expect(mockSendRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'elicitation/create', - params: expect.objectContaining({ - mode: 'url', - url: 'https://example.com/verify', - message: 'Open this page to verify your identity', - elicitationId: 'elicitation-123', - }), - }), - expect.anything(), - expect.anything() + { url: "https://example.com/auth", message: "Open this" }, + firstRound() ); - expect(result.content[0].text).toContain( - '✅ User completed the URL elicitation flow.' - ); + expect(result.resultType).toBe("input_required"); + const request = result.inputRequests.browserFlow; + expect(request.method).toBe("elicitation/create"); + expect(request.params.mode).toBe("url"); + expect(request.params.url).toBe("https://example.com/auth"); + // The legacy-era `elicitationId` is not part of the 2026 in-band shape; + // the legacy shim synthesizes one when it needs to. + expect(request.params.elicitationId).toBeUndefined(); }); - it('should not register when client has no elicitation capability at all', () => { - const mockServer = { - registerTool: vi.fn(), - server: { - getClientCapabilities: vi.fn(() => ({})), - }, - } as unknown as McpServer; - - registerTriggerUrlElicitationTool(mockServer); - - expect(mockServer.registerTool).not.toHaveBeenCalled(); - }); - - it('should not register when client capabilities are undefined', () => { - const mockServer = { - registerTool: vi.fn(), - server: { - getClientCapabilities: vi.fn(() => undefined), - }, - } as unknown as McpServer; - - registerTriggerUrlElicitationTool(mockServer); - - expect(mockServer.registerTool).not.toHaveBeenCalled(); - }); - - it('should default the elicitationId to a random UUID when omitted', async () => { - const handlers: Map = new Map(); - const mockSendRequest = vi.fn().mockResolvedValue({ - action: 'accept', - }); - - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })), - }, - } as unknown as McpServer; - - registerTriggerUrlElicitationTool(mockServer); - - const handler = handlers.get('trigger-url-elicitation')!; - await handler( - { - url: 'https://example.com/verify', - message: 'Open this page to verify your identity', - errorPath: false, - }, - { sendRequest: mockSendRequest } - ); - - const sentParams = mockSendRequest.mock.calls[0][0].params; - expect(sentParams.elicitationId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ - ); - }); - - it('should report a declined URL elicitation', async () => { - const handlers: Map = new Map(); - const mockSendRequest = vi.fn().mockResolvedValue({ action: 'decline' }); - - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })), - }, - } as unknown as McpServer; - + it("should report completion when the user finishes the flow", async () => { + const { mockServer, handlers } = createMockServer(); registerTriggerUrlElicitationTool(mockServer); - const handler = handlers.get('trigger-url-elicitation')!; + const handler = handlers.get("trigger-url-elicitation")!; const result = await handler( { - url: 'https://example.com/verify', - message: 'Open this page to verify your identity', - elicitationId: 'elicitation-123', - errorPath: false, + url: "https://example.com/auth", + message: "Open this", + elicitationId: "abc", }, - { sendRequest: mockSendRequest } + withResponses({ browserFlow: { action: "accept" } }) ); - expect(result.content[0].text).toContain('❌ User declined to open the URL'); + expect(result.content[0].text).toContain("✅"); + expect(result.content[0].text).toContain("abc"); + expect(result.content[0].text).toContain("https://example.com/auth"); }); - it('should report a cancelled URL elicitation', async () => { - const handlers: Map = new Map(); - const mockSendRequest = vi.fn().mockResolvedValue({ action: 'cancel' }); - - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })), - }, - } as unknown as McpServer; - + it("should report decline and cancel distinctly", async () => { + const { mockServer, handlers } = createMockServer(); registerTriggerUrlElicitationTool(mockServer); - const handler = handlers.get('trigger-url-elicitation')!; - const result = await handler( - { - url: 'https://example.com/verify', - message: 'Open this page to verify your identity', - elicitationId: 'elicitation-123', - errorPath: false, - }, - { sendRequest: mockSendRequest } + const handler = handlers.get("trigger-url-elicitation")!; + const args = { url: "https://example.com/auth", message: "Open this" }; + + const declined = await handler( + args, + withResponses({ browserFlow: { action: "decline" } }) ); + expect(declined.content[0].text).toContain("❌"); - expect(result.content[0].text).toContain( - '⚠️ User cancelled the URL elicitation' + const cancelled = await handler( + args, + withResponses({ browserFlow: { action: "cancel" } }) ); + expect(cancelled.content[0].text).toContain("⚠️"); }); - it('should throw MCP error -32042 with a prerequisite elicitation pointing at a different URL when errorPath is true', async () => { - const handlers: Map = new Map(); - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })), - }, - } as unknown as McpServer; - + it("should no longer accept an errorPath argument", async () => { + // The -32042 error path was legacy-era only and has been removed; the + // schema should reject the argument rather than silently ignore it. + const { mockServer, configs } = createMockServer(); registerTriggerUrlElicitationTool(mockServer); - const handler = handlers.get('trigger-url-elicitation')!; - - expect.assertions(5); - - try { - await handler( - { - url: 'https://example.com/connect', - message: 'Authorization is required to continue.', - elicitationId: 'elicitation-xyz', - errorPath: true, - }, - {} - ); - } catch (error: any) { - expect(error.code).toBe(-32042); - const prerequisite = error.data.elicitations[0]; - expect(prerequisite.mode).toBe('url'); - // The prerequisite must NOT reuse the failing URL, otherwise the client - // would complete it, retry, hit the same error, and loop forever. - expect(prerequisite.url).toBe('https://modelcontextprotocol.io'); - expect(prerequisite.url).not.toBe('https://example.com/connect'); - // It carries its own elicitation id for the prerequisite itself. - expect(typeof prerequisite.elicitationId).toBe('string'); - } + const schema = configs.get("trigger-url-elicitation")!.inputSchema; + expect(Object.keys(schema.shape)).not.toContain("errorPath"); }); + }); - it('should ignore errorPath and take the request path when the same call is retried after the prerequisite', async () => { - const handlers: Map = new Map(); - const mockSendRequest = vi.fn().mockResolvedValue({ action: 'accept' }); - - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })), - }, - } as unknown as McpServer; + describe("get-roots-list", () => { + it("should register unconditionally, without a roots capability", () => { + const { mockServer } = createMockServer(); + registerGetRootsListTool(mockServer); - registerTriggerUrlElicitationTool(mockServer); + expect(mockServer.registerTool).toHaveBeenCalledWith( + "get-roots-list", + expect.objectContaining({ title: "Get Roots List Tool" }), + expect.any(Function) + ); + }); - const handler = handlers.get('trigger-url-elicitation')!; - // A real client retries with the *same* arguments and does not echo the - // prerequisite's elicitationId. Note these args omit elicitationId, so the - // correlation must rely on stable inputs (session + url), not a per-call - // random id. - const args = { - url: 'https://example.com/connect', - message: 'Authorization is required to continue.', - errorPath: true, - }; - const extra = { sessionId: 'session-1', sendRequest: mockSendRequest }; - - // First call: error path issues the prerequisite and throws -32042. - let prerequisiteUrl: string | undefined; - try { - await handler(args, extra); - throw new Error('expected first call to throw'); - } catch (error: any) { - expect(error.code).toBe(-32042); - prerequisiteUrl = error.data.elicitations[0].url; - expect(prerequisiteUrl).toBe('https://modelcontextprotocol.io'); - expect(mockSendRequest).not.toHaveBeenCalled(); - } + it("should ask for roots when none are cached (the 2026-07-28 path)", async () => { + const { mockServer, handlers } = createMockServer(); + registerGetRootsListTool(mockServer); - // Plain retry with identical arguments: errorPath is ignored and the call - // proceeds via the request path instead of throwing the prerequisite again. - const result = await handler({ ...args }, extra); + const handler = handlers.get("get-roots-list")!; + const result = await handler({}, firstRound()); - expect(mockSendRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'elicitation/create', - params: expect.objectContaining({ - mode: 'url', - url: 'https://example.com/connect', - }), - }), - expect.anything(), - expect.anything() - ); - expect(result.content[0].text).toContain( - '✅ User completed the URL elicitation flow.' - ); + expect(result.resultType).toBe("input_required"); + expect(result.inputRequests.roots.method).toBe("roots/list"); }); - }); - describe('get-roots-list', () => { - it('should not register when client does not support roots', () => { - const { mockServer } = createMockServer(); + it("should format the roots the client returns", async () => { + const { mockServer, handlers } = createMockServer(); registerGetRootsListTool(mockServer); - expect(mockServer.registerTool).not.toHaveBeenCalled(); - }); + const handler = handlers.get("get-roots-list")!; + const result = await handler( + {}, + withResponses({ + roots: { + roots: [ + { uri: "file:///workspace", name: "Workspace" }, + { uri: "file:///other" }, + ], + }, + }) + ); - it('should register when client supports roots', () => { - const handlers: Map = new Map(); - const mockServer = { - registerTool: vi.fn((name: string, config: any, handler: Function) => { - handlers.set(name, handler); - }), - server: { - getClientCapabilities: vi.fn(() => ({ roots: {} })), - }, - } as unknown as McpServer; + expect(result.content[0].text).toContain("Current MCP Roots (2 total)"); + expect(result.content[0].text).toContain("1. Workspace"); + expect(result.content[0].text).toContain("file:///workspace"); + expect(result.content[0].text).toContain("2. Unnamed Root"); + }); + it("should explain when the client returns an empty roots list", async () => { + const { mockServer, handlers } = createMockServer(); registerGetRootsListTool(mockServer); - expect(mockServer.registerTool).toHaveBeenCalledWith( - 'get-roots-list', - expect.objectContaining({ - title: 'Get Roots List Tool', - description: expect.stringContaining('roots'), - }), - expect.any(Function) + const handler = handlers.get("get-roots-list")!; + const result = await handler({}, withResponses({ roots: { roots: [] } })); + + expect(result.content[0].text).toContain( + "no roots are currently configured" ); }); }); - describe('simulate-research-query', () => { - function createMockServerWithTasks() { - const taskHandlers: Record = {}; - const mockServer = { - experimental: { - tasks: { - registerToolTask: vi.fn((_name: string, _config: any, handler: any) => { - Object.assign(taskHandlers, handler); - }), + describe("simulate-research-query", () => { + it("should run straight through for an unambiguous query", async () => { + const { mockServer, handlers } = createMockServer(); + registerSimulateResearchQueryTool(mockServer); + + const notify = vi.fn(); + const handler = handlers.get("simulate-research-query")!; + const result = await handler( + { topic: "MCP", ambiguous: false }, + { + mcpReq: { + _meta: { progressToken: "tok" }, + notify, + requestState: () => undefined, }, - }, - server: { getClientCapabilities: vi.fn(() => ({ elicitation: {} })) }, - } as unknown as McpServer; - return { mockServer, taskHandlers }; - } - - function createMockTaskStore(taskId: string) { - return { - createTask: vi.fn().mockResolvedValue({ - taskId, - status: 'working', - createdAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString(), - ttl: 300000, - pollInterval: 1000, - }), - updateTaskStatus: vi.fn().mockResolvedValue(undefined), - storeTaskResult: vi.fn().mockResolvedValue(undefined), - getTask: vi.fn(), - getTaskResult: vi.fn(), - }; - } + } + ); - it('should pass relatedTask to sendRequest when elicitation is triggered', async () => { - vi.useFakeTimers(); + expect(result.resultType).toBeUndefined(); + expect(result.content[0].text).toContain("Research Report: MCP"); + // One progress notification per stage. + expect(notify).toHaveBeenCalledTimes(4); + }, 20000); - const { mockServer, taskHandlers } = createMockServerWithTasks(); + it("should pause for clarification when the query is ambiguous", async () => { + const { mockServer, handlers } = createMockServer(); registerSimulateResearchQueryTool(mockServer); - const mockTaskStore = createMockTaskStore('task-abc'); - const mockSendRequest = vi.fn().mockResolvedValue({ - action: 'accept', - content: { interpretation: 'technical' }, - }); - - await taskHandlers.createTask( - { topic: 'python', ambiguous: true }, - { taskStore: mockTaskStore, sendRequest: mockSendRequest } + const handler = handlers.get("simulate-research-query")!; + const result = await handler( + { topic: "python", ambiguous: true }, + { + mcpReq: { _meta: {}, notify: vi.fn(), requestState: () => undefined }, + } ); - await vi.runAllTimersAsync(); - vi.useRealTimers(); + expect(result.resultType).toBe("input_required"); + const request = result.inputRequests.clarification; + expect(request.method).toBe("elicitation/create"); + // Interpretations are topic-aware. + const options = + request.params.requestedSchema.properties.interpretation.oneOf; + expect(options.map((o: any) => o.const)).toContain("snake"); + + // The topic must be carried forward: `inputResponses` are per round, so + // nothing else survives the trip through the client. + expect(typeof result.requestState).toBe("string"); + expect(result.requestState.length).toBeGreaterThan(0); + }, 20000); + + it("should resume with the clarification and finish the report", async () => { + const { mockServer, handlers } = createMockServer(); + registerSimulateResearchQueryTool(mockServer); - expect(mockSendRequest).toHaveBeenCalledWith( - expect.objectContaining({ method: 'elicitation/create' }), - expect.anything(), - expect.objectContaining({ relatedTask: { taskId: 'task-abc' } }) + const handler = handlers.get("simulate-research-query")!; + const result = await handler( + { topic: "python", ambiguous: true }, + { + mcpReq: { + _meta: {}, + notify: vi.fn(), + // The seam verifies and decodes `requestState` before the handler + // runs, so the handler sees the decoded payload. + requestState: () => ({ + step: "awaiting-clarification", + topic: "python", + }), + inputResponses: { + clarification: { + action: "accept", + content: { interpretation: "snake" }, + }, + }, + }, + } ); - }); - it('should complete without elicitation for non-ambiguous query', async () => { - vi.useFakeTimers(); + expect(result.resultType).toBeUndefined(); + expect(result.content[0].text).toContain( + "Research Report: python (snake)" + ); + expect(result.content[0].text).toContain("Clarification**: snake"); + }, 20000); - const { mockServer, taskHandlers } = createMockServerWithTasks(); + it("should fall back to a default when the user declines to clarify", async () => { + const { mockServer, handlers } = createMockServer(); registerSimulateResearchQueryTool(mockServer); - const mockTaskStore = createMockTaskStore('task-def'); - const mockSendRequest = vi.fn(); - - await taskHandlers.createTask( - { topic: 'python', ambiguous: false }, - { taskStore: mockTaskStore, sendRequest: mockSendRequest } + const handler = handlers.get("simulate-research-query")!; + const result = await handler( + { topic: "python", ambiguous: true }, + { + mcpReq: { + _meta: {}, + notify: vi.fn(), + requestState: () => ({ + step: "awaiting-clarification", + topic: "python", + }), + inputResponses: { clarification: { action: "decline" } }, + }, + } ); - await vi.runAllTimersAsync(); - vi.useRealTimers(); - - expect(mockSendRequest).not.toHaveBeenCalled(); - expect(mockTaskStore.storeTaskResult).toHaveBeenCalledWith( - 'task-def', 'completed', expect.anything() - ); - }); + expect(result.content[0].text).toContain("default interpretation"); + }, 20000); }); - describe('gzip-file-as-resource', () => { - it('should compress data URI and return resource link', async () => { + describe("gzip-file-as-resource", () => { + it("should compress data URI and return resource link", async () => { const registeredResources: any[] = []; const mockServer = { registerTool: vi.fn(), @@ -1160,18 +1002,22 @@ describe('Tools', () => { registerGZipFileAsResourceTool(mockServer); // Create a data URI with test content - const testContent = 'Hello, World!'; - const dataUri = `data:text/plain;base64,${Buffer.from(testContent).toString('base64')}`; - - const result = await handler!( - { name: 'test.txt.gz', data: dataUri, outputType: 'resourceLink' } - ); + const testContent = "Hello, World!"; + const dataUri = `data:text/plain;base64,${Buffer.from( + testContent + ).toString("base64")}`; + + const result = await handler!({ + name: "test.txt.gz", + data: dataUri, + outputType: "resourceLink", + }); - expect(result.content[0].type).toBe('resource_link'); - expect(result.content[0].uri).toContain('test.txt.gz'); + expect(result.content[0].type).toBe("resource_link"); + expect(result.content[0].uri).toContain("test.txt.gz"); }); - it('should return resource directly when outputType is resource', async () => { + it("should return resource directly when outputType is resource", async () => { const mockServer = { registerTool: vi.fn(), registerResource: vi.fn(), @@ -1186,19 +1032,23 @@ describe('Tools', () => { registerGZipFileAsResourceTool(mockServer); - const testContent = 'Test content for compression'; - const dataUri = `data:text/plain;base64,${Buffer.from(testContent).toString('base64')}`; + const testContent = "Test content for compression"; + const dataUri = `data:text/plain;base64,${Buffer.from( + testContent + ).toString("base64")}`; - const result = await handler!( - { name: 'output.gz', data: dataUri, outputType: 'resource' } - ); + const result = await handler!({ + name: "output.gz", + data: dataUri, + outputType: "resource", + }); - expect(result.content[0].type).toBe('resource'); - expect(result.content[0].resource.mimeType).toBe('application/gzip'); + expect(result.content[0].type).toBe("resource"); + expect(result.content[0].resource.mimeType).toBe("application/gzip"); expect(result.content[0].resource.blob).toBeDefined(); }); - it('should reject unsupported URL protocols', async () => { + it("should reject unsupported URL protocols", async () => { const mockServer = { registerTool: vi.fn(), registerResource: vi.fn(), @@ -1214,8 +1064,12 @@ describe('Tools', () => { registerGZipFileAsResourceTool(mockServer); await expect( - handler!({ name: 'test.gz', data: 'ftp://example.com/file.txt', outputType: 'resource' }) - ).rejects.toThrow('Unsupported URL protocol'); + handler!({ + name: "test.gz", + data: "ftp://example.com/file.txt", + outputType: "resource", + }) + ).rejects.toThrow("Unsupported URL protocol"); }); }); }); diff --git a/src/everything/docs/architecture.md b/src/everything/docs/architecture.md index 728cfd4010..8900d3020c 100644 --- a/src/everything/docs/architecture.md +++ b/src/everything/docs/architecture.md @@ -16,16 +16,36 @@ It explains how the server starts, how transports are wired, where tools, prompt A minimal, modular MCP server showcasing core Model Context Protocol features. It exposes simple tools, prompts, and resources, and can be run over multiple transports (STDIO, SSE, and Streamable HTTP). +### Protocol eras + +The server is **dual-era**: it serves both the legacy protocol revisions (`2024-10-07` +through `2025-11-25`) and the modern ones (`2026-07-28` onward) from a single codebase. +These are the SDK's own names, and the literal values of `ctx.era`. + +The two differ structurally, not just by date. Legacy has an `initialize` handshake, +sessions, a server-to-client request channel, and unsolicited notifications. Modern has +none of those: every request carries its own protocol version, client identity, and +capabilities in `_meta`; servers obtain client input by _returning_ `inputRequired(...)`; +and change notifications flow over a `subscriptions/listen` stream the client opened. + ### Design -A small “server factory” constructs the MCP server and registers features. -Transports are separate entry points that create/connect the server and handle network concerns. -Tools, prompts, and resources are organized in their own submodules. +A small “server factory” constructs the MCP server and registers features. Transports are +separate entry points that hand that factory to an SDK serving entry — `serveStdio` or +`createMcpHandler` — which decides the era and calls the factory to build an instance per +connection (stdio) or per request (HTTP). + +Tools, prompts, and resources are organized in their own submodules and are written **once**, +era-agnostically. The SDK adapts them to whichever era is in play, so nothing under +`tools/` branches on `ctx.era`. The handful of things that genuinely cannot exist on one +era or the other are handled in the factory and transports. ### Multi‑client -The server supports multiple concurrent clients. Tracking per session data is demonstrated with -resource subscriptions and simulated logging. +The server supports multiple concurrent clients. On connections that have sessions (stdio, +SSE, legacy Streamable HTTP), per-session data is tracked and demonstrated with resource +subscriptions and simulated logging. Modern-era serving is per request and holds nothing +between exchanges — cross-call state travels in explicit, server-minted handles instead. ## Build and Distribution diff --git a/src/everything/docs/extension.md b/src/everything/docs/extension.md index 1d77730448..de002a18ed 100644 --- a/src/everything/docs/extension.md +++ b/src/everything/docs/extension.md @@ -11,6 +11,45 @@ - Create a new file under `tools/` with your `registerXTool(server)` function that registers the tool via `server.registerTool(...)`. - Export and call it from `tools/index.ts` inside `registerTools(server)`. +- Register **unconditionally**. Do not gate registration on client capabilities: on + 2026-07-28 the factory never learns them (they arrive per request in `_meta`), and + `tools/list` may not vary per connection. The SDK refuses a caller lacking a required + capability at dispatch with `-32021`. + +### Tools that need input from the client + +Never push a server-to-client request — that channel does not exist on 2026-07-28. +**Return** `inputRequired(...)` and let the client answer: + +```ts +const answer = inputResponse(ctx.mcpReq.inputResponses, "confirm"); +if (answer.kind === "missing") { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ message: "Proceed?", requestedSchema: { … } }), + }, + }); +} +// re-entry: answer.kind is "elicit" | "sampling" | "roots" +``` + +Write this once — the SDK's legacy shim serves the same return to legacy-era clients as +real server-to-client requests. Builders: `inputRequired.elicit`, +`.elicitUrl`, `.createMessage`, `.listRoots`. Read answers with `inputResponse(...)` for +the discriminated view, or `acceptedContent(responses, key, schema)` for validated, +typed elicitation content. + +If your flow needs **more than one round**, remember `inputResponses` are per round: +carry anything else forward in `requestState`, minted with the shared codec in +`server/request-state.ts`. See `tools/simulate-research-query.ts` for the phase-switch +pattern. + +### Notifications from a tool + +Use `ctx.mcpReq.notify(...)` for anything scoped to the current request (progress, +logs) so it rides that request's own response stream. For change notifications +(`*ListChanged`, `resources/updated`), publish through `getNotifier(server)` rather than +the instance, so it works under both serving entries. ## Adding Prompts diff --git a/src/everything/docs/features.md b/src/everything/docs/features.md index a5429ac600..64dfb8ff44 100644 --- a/src/everything/docs/features.md +++ b/src/everything/docs/features.md @@ -14,20 +14,18 @@ - `get-env` (tools/get-env.ts): Returns all environment variables from the running process as pretty-printed JSON text. - `get-resource-links` (tools/get-resource-links.ts): Returns an intro `text` block followed by multiple `resource_link` items. For a requested `count` (1–10), alternates between dynamic Text and Blob resources using URIs from `resources/templates.ts`. - `get-resource-reference` (tools/get-resource-reference.ts): Accepts `resourceType` (`text` or `blob`) and `resourceId` (positive integer). Returns a concrete `resource` content block (with its `uri`, `mimeType`, and data) with surrounding explanatory `text`. -- `get-roots-list` (tools/get-roots-list.ts): Returns the last list of roots sent by the client. +- `get-roots-list` (tools/get-roots-list.ts): Reports the client's workspace roots. On the legacy era the server has usually already pulled them after the handshake and answers from cache; on 2026-07-28 there is nothing cached, so it asks for them via `inputRequired` and the client retries with the listing attached. - `gzip-file-as-resource` (tools/gzip-file-as-resource.ts): Accepts a `name` and `data` (URL or data URI), fetches the data subject to size/time/domain constraints, compresses it, registers it as a session resource at `demo://resource/session/` with `mimeType: application/gzip`, and returns either a `resource_link` (default) or an inline `resource` depending on `outputType`. - `get-structured-content` (tools/get-structured-content.ts): Demonstrates structured responses. Accepts `location` input and returns both backward‑compatible `content` (a `text` block containing JSON) and `structuredContent` validated by an `outputSchema` (temperature, conditions, humidity). - `get-sum` (tools/get-sum.ts): For two numbers `a` and `b` calculates and returns their sum. Uses Zod to validate inputs. - `get-tiny-image` (tools/get-tiny-image.ts): Returns a tiny PNG MCP logo as an `image` content item with brief descriptive text before and after. - `trigger-long-running-operation` (tools/trigger-long-running-operation.ts): Simulates a multi-step operation over a given `duration` and number of `steps`; reports progress via `notifications/progress` when a `progressToken` is provided by the client. -- `toggle-simulated-logging` (tools/toggle-simulated-logging.ts): Starts or stops simulated, random‑leveled logging for the invoking session. Respects the client’s selected minimum logging level. +- `toggle-simulated-logging` (tools/toggle-simulated-logging.ts): Starts or stops simulated, random‑leveled logging for the invoking session. **Legacy era only** — see [Simulated Logging](#simulated-logging). - `toggle-subscriber-updates` (tools/toggle-subscriber-updates.ts): Starts or stops simulated resource update notifications for URIs the invoking session has subscribed to. -- `trigger-elicitation-request` (tools/trigger-elicitation-request.ts): Issues an `elicitation/create` request using form-mode fields (strings, numbers, booleans, enums, and format validation) and returns the resulting action/content. -- `trigger-url-elicitation` (tools/trigger-url-elicitation.ts): Issues an `elicitation/create` request in URL mode (`mode: "url"`) with an `elicitationId`, or throws MCP error `-32042` (`UrlElicitationRequiredError`) when `errorPath=true`. On the error path the prerequisite elicitation it returns points at a different URL than the failing request (`https://modelcontextprotocol.io`); when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path, so the client does not loop on the same error. The retry marker is one-shot per `(session, url, elicitationId)`: it is cleared on the recognized retry, so re-running the error path with identical arguments without an intervening prerequisite is treated as a retry and proceeds. Requires client capability `elicitation.url`. -- `trigger-sampling-request` (tools/trigger-sampling-request.ts): Issues a `sampling/createMessage` request to the client/LLM using provided `prompt` and optional generation controls; returns the LLM's response payload. -- `simulate-research-query` (tools/simulate-research-query.ts): Demonstrates MCP Tasks (SEP-1686) with a simulated multi-stage research operation. Accepts `topic` and `ambiguous` parameters. Returns a task that progresses through stages with status updates. If `ambiguous` is true and client supports elicitation, sends an elicitation request directly to gather clarification before completing. -- `trigger-sampling-request-async` (tools/trigger-sampling-request-async.ts): Demonstrates bidirectional tasks where the server sends a sampling request that the client executes as a background task. Server polls for status and retrieves the LLM result when complete. Requires client to support `tasks.requests.sampling.createMessage`. -- `trigger-elicitation-request-async` (tools/trigger-elicitation-request-async.ts): Demonstrates bidirectional tasks where the server sends an elicitation request that the client executes as a background task. Server polls while waiting for user input. Requires client to support `tasks.requests.elicitation.create`. +- `trigger-elicitation-request` (tools/trigger-elicitation-request.ts): Asks for a form-mode elicitation covering the full range of field types (strings, numbers, booleans, enums, format validation) and reports the resulting action/content. Requires client capability `elicitation`. +- `trigger-url-elicitation` (tools/trigger-url-elicitation.ts): Asks for a URL-mode elicitation (`mode: "url"`), directing the user to a browser flow, then reports whether they completed, declined, or cancelled it. Requires client capability `elicitation.url`. (The v1 `-32042` / `errorPath` variant is gone — see [Multi Round-Trip Requests](#multi-round-trip-requests-sep-2322).) +- `trigger-sampling-request` (tools/trigger-sampling-request.ts): Asks the client/LLM for a completion using the provided `prompt` and optional generation controls; returns the response payload. Requires client capability `sampling`. +- `simulate-research-query` (tools/simulate-research-query.ts): Simulates a multi-stage research operation, reporting progress per stage. Accepts `topic` and `ambiguous`. When `ambiguous` is true it pauses partway and asks which interpretation of the topic you meant, then resumes and produces the report. The only **multi-round** flow in this server: it threads the topic across rounds in an HMAC-sealed `requestState`. ## Prompts @@ -41,65 +39,107 @@ - Dynamic Text: `demo://resource/dynamic/text/{index}` (content generated on the fly) - Dynamic Blob: `demo://resource/dynamic/blob/{index}` (base64 payload generated on the fly) - Static Documents: `demo://resource/static/document/` (serves files from `src/everything/docs/` as static file-based resources) + +### Result caching (2026-07-28) + +The revision requires `ttlMs` and `cacheScope` on cacheable results. Values resolve +most-specific-author-first: fields the handler puts on the result, then a +per-registration `cacheHint`, then the server-level `ServerOptions.cacheHints`, then the +conservative default (`ttlMs: 0`, `cacheScope: "private"`). These fields are emitted only +toward modern-era clients — legacy responses are byte-for-byte unchanged. + +This server exercises two of those layers: + +| Surface | Hint | Why | +| ---------------------------------------------------------- | --------------------------------- | --------------------------------------------------------- | +| `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `server/discover` | server-level, 60s `public` | static for the process lifetime and identical per caller | +| Static Documents (`resources/read`) | per-registration, 1h `public` | ship inside the package; only change when it is rebuilt | +| Dynamic, Session Scoped (`resources/read`) | none — falls through to the default | generated per call, or scoped to one caller | - Session Scoped: `demo://resource/session/` (per-session resources registered dynamically; available only for the lifetime of the session) ## Resource Subscriptions and Notifications - Simulated update notifications are opt‑in and off by default. -- Clients may subscribe/unsubscribe to resource URIs using the MCP `resources/subscribe` and `resources/unsubscribe` requests. -- Use the `toggle-subscriber-updates` tool to start/stop a per‑session interval that emits `notifications/resources/updated { uri }` only for URIs that session has subscribed to. -- Multiple concurrent clients are supported; each client’s subscriptions are tracked per session and notifications are delivered independently via the server instance associated with that session. +- Use the `toggle-subscriber-updates` tool to start/stop an interval that emits + `notifications/resources/updated { uri }`. +- How a client registers interest differs by era, and the server supports both: + - **Legacy era** — `resources/subscribe` / `resources/unsubscribe`. Subscribers are + tracked per session as `Map>`, and updates are delivered through + the server instance bound to that session. + - **2026-07-28** — `resources/subscribe` no longer exists. Clients open a + `subscriptions/listen` stream and name the notification types they want + (`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, + `resourceSubscriptions`). The serving entry answers `subscriptions/listen` itself; the + server publishes onto its bus and the bus does the filtering. +- Publishing is routed through `server/notifier.ts` so tools do not have to care which of + the two is in play. ## Simulated Logging - Simulated logging is available but off by default. -- Use the `toggle-simulated-logging` tool to start/stop periodic log messages of varying levels (debug, info, notice, warning, error, critical, alert, emergency) per session. -- Clients can control the minimum level they receive via the standard MCP `logging/setLevel` request. - -## Tasks (SEP-1686) - -The server advertises support for MCP Tasks, enabling long-running operations with status tracking: - -- **Capabilities advertised**: `tasks.list`, `tasks.cancel`, `tasks.requests.tools.call` -- **Task Store**: Uses `InMemoryTaskStore` from SDK experimental for task lifecycle management -- **Message Queue**: Uses `InMemoryTaskMessageQueue` for task-related messaging - -### Task Lifecycle - -1. Client calls `tools/call` with `task: true` parameter -2. Server returns `CreateTaskResult` with `taskId` instead of immediate result -3. Client polls `tasks/get` to check status and receive `statusMessage` updates -4. When status is `completed`, client calls `tasks/result` to retrieve the final result - -### Task Statuses - -- `working`: Task is actively processing -- `input_required`: Task needs additional input (server sends elicitation request directly) -- `completed`: Task finished successfully -- `failed`: Task encountered an error -- `cancelled`: Task was cancelled by client - -### Demo Tools - -**Server-side tasks (client calls server):** -Use the `simulate-research-query` tool to exercise the full task lifecycle. Set `ambiguous: true` to trigger elicitation - the server will send an `elicitation/create` request directly and await the response before completing. - -**Client-side tasks (server calls client):** -Use `trigger-sampling-request-async` or `trigger-elicitation-request-async` to demonstrate bidirectional tasks where the server sends requests that the client executes as background tasks. These require the client to advertise `tasks.requests.sampling.createMessage` or `tasks.requests.elicitation.create` capabilities respectively. - -### Bidirectional Task Flow - -MCP Tasks are bidirectional - both server and client can be task executors: - -| Direction | Request Type | Task Executor | Demo Tool | -| ---------------- | ------------------------ | ------------- | ----------------------------------- | -| Client -> Server | `tools/call` | Server | `simulate-research-query` | -| Server -> Client | `sampling/createMessage` | Client | `trigger-sampling-request-async` | -| Server -> Client | `elicitation/create` | Client | `trigger-elicitation-request-async` | - -For client-side tasks: - -1. Server sends request with task metadata (e.g., `params.task.ttl`) -2. Client creates task and returns `CreateTaskResult` with `taskId` -3. Server polls `tasks/get` for status updates -4. When complete, server calls `tasks/result` to retrieve the result +- Use the `toggle-simulated-logging` tool to start/stop periodic log messages of varying + levels (debug, info, notice, warning, error, critical, alert, emergency) per session. +- **Legacy era only.** This models a connection-scoped log stream: a background interval + pushing unsolicited `notifications/message` at whatever level the client selected with + `logging/setLevel`. +- 2026-07-28 removed both halves. `logging/setLevel` is gone — the level is now a + per-request `io.modelcontextprotocol/logLevel` key in `_meta`, and a server **MUST NOT** + emit `notifications/message` for a request that did not carry it. There is no + connection-level channel for a background interval to write to, so on a modern + connection the toggle is accepted but no messages arrive. The failure is quiet rather + than loud: the send is filtered out rather than rejected, so nothing destabilises. + +## Multi Round-Trip Requests (SEP-2322) + +The 2026-07-28 revision removed the server-to-client JSON-RPC request channel. A server +that needs input from the client no longer _pushes_ `elicitation/create`, +`sampling/createMessage`, or `roots/list` — it **returns** an `input_required` result +naming what it needs, and the client retries the original call carrying +`inputResponses`. + +### Written once, served to both eras + +Every tool here is written in the 2026 `inputRequired(...)` style, with no branching on +protocol era. On a legacy-era connection the SDK's legacy shim turns the same return into +real server-to-client requests over the live session and re-enters the handler with the +answers collected. The handler cannot tell which era served it. + +| Tool | Asks for | +| ----------------------------- | ------------------------------------------ | +| `trigger-elicitation-request` | form-mode `elicitation/create` | +| `trigger-url-elicitation` | URL-mode `elicitation/create` | +| `trigger-sampling-request` | `sampling/createMessage` | +| `get-roots-list` | `roots/list` | +| `simulate-research-query` | form-mode `elicitation/create`, mid-flight | + +### Multi-round flows and `requestState` + +`inputResponses` are **per round** — a retry carries only that round's answers, never +earlier ones. Anything that must survive the trip through the client goes in +`requestState`, an opaque server-minted string the client echoes back byte-for-byte. + +`simulate-research-query` is the worked example: it pauses at the synthesis stage to ask +which interpretation of an ambiguous topic you meant, carrying the topic forward in +`requestState` so the resumed round can finish the report. + +Because `requestState` round-trips through the client it is **untrusted input** on +re-entry. This server seals it with HMAC-SHA256 via `createRequestStateCodec` +(`server/request-state.ts`), bound to the originating method and authenticated principal, +and verifies it before any handler runs. + +### Capability requirements + +A tool needing a capability the caller never declared is refused at dispatch with +`-32021 MissingRequiredClientCapability`, whose `data.requiredCapabilities` lists what was +missing. This is a spec **MUST**, and it is why tools are registered unconditionally +rather than hidden — `tools/list` may not vary per connection on 2026-07-28. + +### Tasks + +This server no longer demonstrates tasks. The 2025-11-25 experimental tasks API was +removed in SDK v2, and 2026-07-28 moved tasks into an extension +(`io.modelcontextprotocol/tasks`, SEP-2663) that the SDK cannot currently serve: `tasks/*` +are spec method names absent from the modern era's registry, so they are answered `-32601` +even when a handler is registered, and they cannot be re-registered as vendor-prefixed +custom methods. `simulate-research-query` keeps the staged progress and mid-flight +elicitation it always demonstrated; only the task wire shape is gone. diff --git a/src/everything/docs/how-it-works.md b/src/everything/docs/how-it-works.md index 514c6f5663..dda08cce95 100644 --- a/src/everything/docs/how-it-works.md +++ b/src/everything/docs/how-it-works.md @@ -7,26 +7,81 @@ | [Extension Points](extension.md) | How It Works** -# Conditional Tool Registration +# Capability Gating -### Module: `server/index.ts` +### Module: `server/index.ts`, `tools/index.ts` -- Some tools require client support for the capability they demonstrate. These are: - - `get-roots-list` - - `trigger-elicitation-request` - - `trigger-sampling-request` -- Client capabilities aren't known until after initilization handshake is complete. -- Most tools are registered immediately during the Server Factory execution, prior to client connection. -- To defer registration of these commands until client capabilities are known, a `registerConditionalTools(server)` function is invoked from an `onintitialized` handler. +Some tools need a client capability to do their job — `get-roots-list`, +`trigger-elicitation-request`, `trigger-url-elicitation`, `trigger-sampling-request`. + +Previously these were **deferred**: registration waited for an `oninitialized` handler so +that `getClientCapabilities()` could be consulted, and a separate +`registerConditionalTools(server)` pass registered whichever the client could support. + +That is gone. Every tool is now registered up front, unconditionally, for three reasons: + +1. **There is no handshake to wait for.** 2026-07-28 removed `initialize`, so a + modern-era factory never learns capabilities at construction time. The factory context + carries only `era`, `authInfo`, and `requestInfo`. +2. **Capabilities are per request, not per connection.** Every modern request carries its + own `io.modelcontextprotocol/clientCapabilities` in `_meta` — "capabilities relevant to + this request". There is no single client-wide value a tool list could be gated on. +3. **List endpoints may not vary.** The spec removed per-connection variation from + `tools/list`, `resources/list`, and `prompts/list`, and asks for deterministic ordering + so results stay cacheable. A capability-varying list would break that. + +The replacement is a refusal at dispatch, which the spec mandates: + +> A server **MUST NOT** rely on capabilities the client has not declared. If processing a +> request requires a capability the client did not include in +> `io.modelcontextprotocol/clientCapabilities`, the server **MUST** return a +> `MissingRequiredClientCapabilityError` (`-32021`) whose `data.requiredCapabilities` lists +> the missing capabilities. + +The SDK enforces this for us on both eras, because every one of these tools asks for its +input by returning `inputRequired(...)`. A caller that never declared the capability is +refused before the embedded request goes anywhere — as an `isError` tool result on the +legacy era, and as a `-32021` JSON-RPC error on the modern era. + +## Multi Round-Trip Requests + +### Modules: `tools/trigger-*.ts`, `tools/get-roots-list.ts`, `tools/simulate-research-query.ts` + +Handlers never push a server-to-client request. They return +`inputRequired({ inputRequests: { … } })` and are re-entered with `ctx.mcpReq.inputResponses` +once the client answers. Written once, this serves the modern era natively and the legacy +era through the SDK's legacy shim. + +`inputResponses` are per round, so multi-round flows carry what they have learned in +`requestState`. `server/request-state.ts` seals it with HMAC-SHA256 (bound to method and +principal); `ServerOptions.requestState.verify` runs on every round before the handler, +so `ctx.mcpReq.requestState()` hands back an already-verified payload. + +## Change Notification Routing + +### Module: `server/notifier.ts` + +The two serving entries publish differently, and tools should not have to know which is +running: + +- `serveStdio` pins one instance per connection and routes that instance's + `send*ListChanged()` / `sendResourceUpdated()` onto any open `subscriptions/listen` + streams (or emits them unsolicited on the legacy era). Instance methods are correct there. +- `createMcpHandler` builds a fresh instance per request, so an instance has no long-lived + stream. Publishing goes through the handler's `notify` facade over its event bus. + +`getNotifier(server)` returns whichever is right; the Streamable HTTP entry registers the +bus at startup with `setBusNotifier(handler.notify)`. ## Resource Subscriptions ### Module: `resources/subscriptions.ts` - Tracks subscribers per URI: `Map>`. -- Installs handlers via `setSubscriptionHandlers(server)` to process subscribe/unsubscribe requests and keep the map updated. +- Installs handlers via `setSubscriptionHandlers(server)` to process subscribe/unsubscribe requests and keep the map updated. `resources/subscribe` and `resources/unsubscribe` are **legacy-era only** — they are physically absent from the modern era's method registry, so registering them unconditionally is harmless (an inbound `resources/subscribe` on a modern connection is answered `-32601` regardless). Modern clients use `subscriptions/listen`, which the serving entry handles itself. - Updates are started/stopped on demand by the `toggle-subscriber-updates` tool, which calls `beginSimulatedResourceUpdates(server, sessionId)` and `stopSimulatedResourceUpdates(sessionId)`. -- `cleanup(sessionId?)` calls `stopSimulatedResourceUpdates(sessionId)` to clear intervals and remove session‑scoped state. +- Publishing goes through `getNotifier(server)` rather than the instance directly, so updates reach the client on either era — see [Change Notification Routing](#change-notification-routing). +- `cleanupSession(sessionId?)` calls `stopSimulatedResourceUpdates(sessionId)` to clear intervals and remove session‑scoped state. Only meaningful where a session exists; modern-era HTTP serving is per request and holds nothing between exchanges. ## Session‑scoped Resources @@ -41,5 +96,6 @@ ### Module: `server/logging.ts` - Periodically sends randomized log messages at different levels. Messages can include the session ID for clarity during demos. -- Started/stopped on demand via the `toggle-simulated-logging` tool, which calls `beginSimulatedLogging(server, sessionId?)` and `stopSimulatedLogging(sessionId?)`. Note that transport disconnect triggers `cleanup()` which also stops any active intervals. +- Started/stopped on demand via the `toggle-simulated-logging` tool, which calls `beginSimulatedLogging(server, sessionId?)` and `stopSimulatedLogging(sessionId?)`. Note that transport disconnect triggers `cleanupSession()` which also stops any active intervals. - Uses `server.sendLoggingMessage({ level, data }, sessionId?)` so that the client’s configured minimum logging level is respected by the SDK. +- **Legacy era only, and the one feature here that cannot be made era-agnostic.** It models a connection-scoped stream, and 2026-07-28 removed both halves of that: `logging/setLevel` is gone in favour of a per-request `io.modelcontextprotocol/logLevel` in `_meta`, and a server **MUST NOT** emit `notifications/message` for a request that did not carry it. With no connection-level channel, a background interval has nothing to write to, so a modern client receives nothing. `sendLoggingMessage` filters the message out rather than rejecting, so the interval keeps running harmlessly. The era-agnostic equivalent would be a request-scoped burst via `ctx.mcpReq.log()` during the call, which demonstrates something different. diff --git a/src/everything/docs/instructions.md b/src/everything/docs/instructions.md index 5806dc0ba9..1dbf42ac4a 100644 --- a/src/everything/docs/instructions.md +++ b/src/everything/docs/instructions.md @@ -13,9 +13,9 @@ Follow them to use, extend, and troubleshoot the server safely and effectively. ## Constraints & Limitations - `gzip-file-as-resource`: Max fetch size controlled by `GZIP_MAX_FETCH_SIZE` (default 10MB), timeout by `GZIP_MAX_FETCH_TIME_MILLIS` (default 30s), allowed domains by `GZIP_ALLOWED_DOMAINS` -- Session resources are ephemeral and lost when the session ends -- Sampling requests (`trigger-sampling-request`) require client sampling capability -- Elicitation requests (`trigger-elicitation-request`) require client elicitation capability +- Session resources are ephemeral. On connections that have sessions they last for the session; on protocol revision 2026-07-28 there are no sessions, so they last only for the request that created them +- Tools needing input from you — `trigger-sampling-request`, `trigger-elicitation-request`, `trigger-url-elicitation`, `get-roots-list` — ask for it by returning an `input_required` result rather than by pushing a request. If your client did not declare the matching capability, the call is refused with `-32021` naming what was missing +- `toggle-simulated-logging` only delivers messages on pre-2026-07-28 connections; on 2026-07-28 the toggle is accepted but no log messages arrive ## Operational Patterns diff --git a/src/everything/docs/startup.md b/src/everything/docs/startup.md index 1d006589a9..046284a7df 100644 --- a/src/everything/docs/startup.md +++ b/src/everything/docs/startup.md @@ -18,56 +18,74 @@ ## 2. The Transport Manager -- Creates a server instance using `createServer()` from `server/index.ts` - - Connects it to the chosen transport type from the MCP SDK. -- Handles communication according to the MCP specs for the chosen transport. - - **STDIO**: - - One simple, process‑bound connection. - - Calls`clientConnect()` upon connection. - - Closes and calls `cleanup()` on `SIGINT`. - - **SSE**: - - Supports multiple client connections. - - Client transports are mapped to `sessionId`; - - Calls `clientConnect(sessionId)` upon connection. - - Hooks server’s `onclose` to clean and remove session. - - Exposes - - `/sse` **GET** (SSE stream) - - `/message` **POST** (JSON‑RPC messages) - - **Streamable HTTP**: - - Supports multiple client connections. - - Client transports are mapped to `sessionId`; - - Calls `clientConnect(sessionId)` upon connection. - - Exposes `/mcp` for - - **POST** (JSON‑RPC messages) - - **GET** (SSE stream) - - **DELETE** (termination) - - Uses an event store for resumability and stores transports by `sessionId`. - - Calls `cleanup(sessionId)` on **DELETE**. +Each transport hands the **server factory itself** to an SDK serving entry, rather than +constructing one server and connecting it to a transport. The entry decides which protocol +era a given connection or request is on, and calls the factory to build an instance for it. + +- **STDIO** — `serveStdio(ctx => createServer(ctx))` from `@modelcontextprotocol/server/stdio`. + - Serves **both eras**. The opening exchange decides which: a `server/discover` probe + means 2026-07-28, an `initialize` handshake means the legacy era. + - One factory instance is pinned for the connection lifetime. + - Pass `{ legacy: "reject" }` to refuse legacy-era openings. + - Closes the handle and calls `cleanupSession()` on `SIGINT`. +- **Streamable HTTP** — `createMcpHandler(ctx => createServer(ctx))` wrapped with + `toNodeHandler` from `@modelcontextprotocol/node`, mounted at `app.all("/mcp", …)`. + - Serves **both eras from one endpoint and one route**: 2026-07-28 per request, and — on + the default `legacy: "stateless"` posture — legacy traffic per request through the + stateless idiom. The handler classifies each request and routes it internally. + - A fresh instance is built per request. There is no session map, no `mcp-session-id`, + no event store, and no hand-rolled GET/DELETE routes: 2026-07-28 removed + protocol-level sessions and SSE resumability, and replaced the standalone GET + notification stream with `subscriptions/listen`, which the handler answers itself. + - `handler.notify` is registered as the change-notification bus at startup via + `setBusNotifier`. + - `handler.close()` on `SIGINT` aborts in-flight exchanges and sends the graceful-close + result on every open `subscriptions/listen` stream. +- **SSE** — the deprecated HTTP+SSE transport (protocol revision 2024-11-05), served from + `@modelcontextprotocol/server-legacy`. + - **Legacy era only** — it predates Streamable HTTP and has no 2026-07-28 equivalent, so + it builds the factory with an explicit `{ era: "legacy" }` context. + - Supports multiple clients; transports are mapped by `sessionId`. + - Hooks the server's `onclose` to call `cleanupSession(sessionId)` and remove the session. + - Exposes `/sse` **GET** (SSE stream) and `/message` **POST** (JSON‑RPC messages). ## 3. The Server Factory -- Invoke `createServer()` from `server/index.ts` +- `createServer(ctx?)` from `server/index.ts`, called by the serving entry once per + serving unit — one HTTP request under `createMcpHandler`, one connection under + `serveStdio`. +- `ctx` carries the `era` this instance will serve, plus (HTTP only) `authInfo` and + `requestInfo`. It does **not** carry client capabilities: those arrive per request in + `_meta` on the modern era. - Creates a new `McpServer` instance with - - **Capabilities**: - - `tools: {}` - - `logging: {}` - - `prompts: {}` - - `resources: { subscribe: true }` - - **Server Instructions** - - Loaded from the docs folder (`server-instructions.md`). - - **Registrations** - - Registers **tools** via `registerTools(server)`. - - Registers **resources** via `registerResources(server)`. - - Registers **prompts** via `registerPrompts(server)`. - - **Other Request Handlers** - - Sets up resource subscription handlers via `setSubscriptionHandlers(server)`. - - Roots list change handler is added post-connection via - - **Returns** - - The `McpServer` instance - - A `clientConnect(sessionId)` callback that enables post-connection setup - - A `cleanup(sessionId?)` callback that stops any active intervals and removes any session‑scoped state + - **Capabilities**: `tools: { listChanged }`, `prompts: { listChanged }`, + `resources: { subscribe, listChanged }`, `logging: {}`. + - **Server Instructions** — loaded from `docs/instructions.md`. + - **Cache hints** — real `ttlMs` / `cacheScope` for the cacheable list surfaces, instead + of the SDK's conservative `ttlMs: 0` / `private` default. Modern era only; these fields + never appear on legacy responses. + - **`requestState` verification** — `requestStateCodec.verify` is wired into + `ServerOptions.requestState`, so an echoed `requestState` is integrity-checked before + any handler runs. + - **Registrations** — `registerTools(server)`, `registerResources(server)`, + `registerPrompts(server)`. All tools register unconditionally; there is no longer a + capability-gated second pass. + - **Other Request Handlers** — `setSubscriptionHandlers(server)` installs the legacy-era + `resources/subscribe` / `resources/unsubscribe` handlers. + - **Legacy-only hook** — on a legacy-era instance, `oninitialized` schedules `syncRoots` + to pull `roots/list` after the handshake. 2026-07-28 has no handshake and no + server-to-client request channel, so on the modern era the `get-roots-list` tool asks + for roots via `inputRequired` instead. + - **Returns** the `McpServer` instance. Session teardown is a separate module-level + `cleanupSession(sessionId?)` export, since the factory's return value is now the + instance itself. + +## Sessions and Multiple Clients -## Enabling Multiple Clients +The `sse` transport and legacy-era Streamable HTTP connections have sessions, and map +per-client state to a session identifier. -Some of the transport managers defined in the `transports` folder can support multiple clients. -In order to do so, they must map certain data to a session identifier. +2026-07-28 removed protocol-level sessions entirely. Modern-era serving is per request and +holds nothing between exchanges — cross-call state is carried in explicit, server-minted +handles instead (`requestState` for multi-round flows; see +[How It Works](how-it-works.md#multi-round-trip-requests)). diff --git a/src/everything/docs/structure.md b/src/everything/docs/structure.md index bd3d70b95c..d3bd95b1a9 100644 --- a/src/everything/docs/structure.md +++ b/src/everything/docs/structure.md @@ -35,6 +35,8 @@ src/everything ├── server │ ├── index.ts │ ├── logging.ts + │ ├── notifier.ts + │ ├── request-state.ts │ └── roots.ts ├── tools │ ├── index.ts @@ -52,10 +54,8 @@ src/everything │ ├── toggle-simulated-logging.ts │ ├── toggle-subscriber-updates.ts │ ├── trigger-elicitation-request.ts - │ ├── trigger-elicitation-request-async.ts │ ├── trigger-long-running-operation.ts │ ├── trigger-sampling-request.ts - │ ├── trigger-sampling-request-async.ts │ └── trigger-url-elicitation.ts └── transports ├── sse.ts @@ -120,11 +120,16 @@ src/everything ### `server/` - `index.ts` - - Server factory that creates an `McpServer` with declared capabilities, loads server instructions, and registers tools, prompts, and resources. - - Sets resource subscription handlers via `setSubscriptionHandlers(server)`. - - Exposes `{ server, cleanup }` to the chosen transport. Cleanup stops any running intervals in the server when the transport disconnects. + - Server factory `createServer(ctx?)` that creates an `McpServer` with declared capabilities, cache hints, and `requestState` verification; loads server instructions; and registers tools, prompts, and resources. + - Called by the SDK serving entry once per serving unit — one HTTP request under `createMcpHandler`, one connection under `serveStdio`. `ctx` carries the `era` being served, plus (HTTP only) `authInfo` and `requestInfo`. + - Sets resource subscription handlers via `setSubscriptionHandlers(server)`, and on legacy-era instances installs the `oninitialized` hook that pulls the client's roots. + - Returns the `McpServer`. Session teardown is the separate `cleanupSession(sessionId?)` export, which stops any running intervals when a session ends. - `logging.ts` - - Implements simulated logging. Periodically sends randomized log messages at various levels to the connected client session. Started/stopped on demand via a dedicated tool. + - Implements simulated logging. Periodically sends randomized log messages at various levels to the connected client session. Started/stopped on demand via a dedicated tool. Legacy era only — 2026-07-28 has no connection-level notification channel for a background interval to write to. +- `notifier.ts` + - Routes change notifications to whichever publish path the active transport needs: instance methods under `serveStdio` (which routes them onto open subscriptions), or the handler's `subscriptions/listen` bus under `createMcpHandler`, where each request gets a fresh instance with no long-lived stream. Tools call `getNotifier(server)` and stay unaware of the difference. +- `request-state.ts` + - The shared HMAC-SHA256 `requestState` codec for multi-round-trip tools. `requestState` round-trips through the client and is untrusted on re-entry, so it is sealed and bound to the originating method and principal. `verify` is wired into `ServerOptions.requestState` so it runs before any handler. Signed, not encrypted — never put secrets in the payload. ### `tools/` @@ -141,7 +146,7 @@ src/everything - `get-resource-reference.ts` - Registers a `get-resource-reference` tool that returns a reference for a selected dynamic resource. - `get-roots-list.ts` - - Registers a `get-roots-list` tool that returns the last list of roots sent by the client. + - Registers a `get-roots-list` tool that reports the client's workspace roots. On the legacy era the server has usually already pulled and cached them after the handshake; on 2026-07-28 nothing is cached, so the tool asks via `inputRequired.listRoots()` and the client retries with the listing attached. The cache lookup simply misses on the modern era, so one code path serves both. - `gzip-file-as-resource.ts` - Registers a `gzip-file-as-resource` tool that fetches content from a URL or data URI, compresses it, and then either: - returns a `resource_link` to a session-scoped resource (default), or @@ -152,17 +157,13 @@ src/everything - `GZIP_MAX_FETCH_TIME_MILLIS` (ms, default 30000) - `GZIP_ALLOWED_DOMAINS` (comma-separated allowlist; empty means all domains allowed) - `simulate-research-query.ts` - - Registers a `simulate-research-query` task-based tool that demonstrates the MCP Tasks feature (SEP-1686). Simulates a multi-stage research operation with progress updates. If the query is marked as ambiguous and the client supports elicitation, it pauses mid-execution to request clarification via `elicitation/create`. Uses `server.experimental.tasks.registerToolTask()` with `execution: { taskSupport: "required" }`. + - Registers a `simulate-research-query` tool simulating a multi-stage research operation with per-stage progress notifications. When the query is marked ambiguous it pauses mid-execution and asks which interpretation was meant, then resumes and produces the report. The only multi-round flow here: the topic is carried across rounds in an HMAC-sealed `requestState`, since `inputResponses` are per round. - `trigger-elicitation-request.ts` - - Registers a `trigger-elicitation-request` tool that sends an `elicitation/create` request to the client/LLM and returns the elicitation result. + - Registers a `trigger-elicitation-request` tool that asks for a form-mode `elicitation/create` covering the full range of supported field types, and reports the resulting action/content. - `trigger-url-elicitation.ts` - - Registers a `trigger-url-elicitation` tool that either sends an out-of-band URL-mode `elicitation/create` request (`mode: "url"`) including an `elicitationId` (request path) or throws `UrlElicitationRequiredError` (`-32042`) for client-handled URL elicitation (error path). On the error path the carried prerequisite elicitation points at a different URL than the failing one (`https://modelcontextprotocol.io`), and when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path — so the client does not loop on the same error. -- `trigger-elicitation-request-async.ts` - - Registers a `trigger-elicitation-request-async` tool that demonstrates bidirectional MCP tasks for elicitation. Sends an elicitation request with task metadata, then polls the client's `tasks/get` endpoint for completion status before fetching the final result. + - Registers a `trigger-url-elicitation` tool that asks for a URL-mode `elicitation/create` (`mode: "url"`) directing the user to a browser flow, then reports whether they completed, declined, or cancelled it. The v1 `-32042` `UrlElicitationRequiredError` error path is gone: it is legacy-era only (the SDK refuses that throw on a modern request and steers to `inputRequired.elicitUrl(...)`), and its session-keyed retry-suppression `Set` has no modern equivalent since 2026-07-28 has no sessions. - `trigger-sampling-request.ts` - - Registers a `trigger-sampling-request` tool that sends a `sampling/createMessage` request to the client/LLM and returns the sampling result. -- `trigger-sampling-request-async.ts` - - Registers a `trigger-sampling-request-async` tool that demonstrates bidirectional MCP tasks for sampling. Sends a sampling request with task metadata, then polls the client's `tasks/get` endpoint for completion status before fetching the final result. + - Registers a `trigger-sampling-request` tool that asks the client/LLM for a completion and returns the sampling result. - `get-structured-content.ts` - Registers a `get-structured-content` tool that demonstrates structuredContent block responses. - `get-sum.ts` @@ -179,16 +180,15 @@ src/everything ### `transports/` - `stdio.ts` - - Starts a `StdioServerTransport`, created the server via `createServer()`, and connects it. - - Handles `SIGINT` to close cleanly and calls `cleanup()` to remove any live intervals. + - Hands the factory to `serveStdio(ctx => createServer(ctx))`, which serves **both eras** over stdio: the opening exchange selects one (`server/discover` probe → 2026-07-28, `initialize` handshake → legacy), and one instance is pinned for the connection. + - Handles `SIGINT` to close the handle and calls `cleanupSession()` to remove any live intervals. - `sse.ts` + - The deprecated HTTP+SSE transport (protocol revision 2024-11-05), served from `@modelcontextprotocol/server-legacy`. **Legacy era only** — it predates Streamable HTTP and has no 2026-07-28 equivalent, so it builds the factory with an explicit `{ era: "legacy" }` context. - Express server exposing: - `GET /sse` to establish an SSE connection per session. - `POST /message` for client messages. - - Manages multiple connected clients via a transport map. - - Starts an `SSEServerTransport`, created the server via `createServer()`, and connects it to a new transport. - - On server disconnect, calls `cleanup()` to remove any live intervals. + - Manages multiple connected clients via a transport map; on disconnect calls `cleanupSession(sessionId)`. - `streamableHttp.ts` - - Express server exposing a single `/mcp` endpoint for POST (JSON‑RPC), GET (SSE stream), and DELETE (session termination) using `StreamableHTTPServerTransport`. - - Uses an `InMemoryEventStore` for resumable sessions and tracks transports by `sessionId`. - - Connects a fresh server instance on initialization POST and reuses the transport for subsequent requests. + - Hands the factory to `createMcpHandler(ctx => createServer(ctx))`, wrapped with `toNodeHandler` and mounted once as `app.all("/mcp", …)`. Serves **both eras from one endpoint**: 2026-07-28 per request, and legacy traffic per request through the default `legacy: "stateless"` posture. + - No session map, no `mcp-session-id`, no `InMemoryEventStore`, and no hand-written GET/DELETE routes — 2026-07-28 removed protocol-level sessions and SSE resumability, and replaced the standalone GET notification stream with `subscriptions/listen`, which the handler answers itself. + - Registers `handler.notify` as the change-notification bus via `setBusNotifier`, and on `SIGINT` calls `handler.close()` to abort in-flight exchanges and gracefully close open subscription streams. diff --git a/src/everything/package.json b/src/everything/package.json index 4f8b836407..2f3b57ad1d 100644 --- a/src/everything/package.json +++ b/src/everything/package.json @@ -12,6 +12,9 @@ "url": "https://github.com/modelcontextprotocol/servers.git" }, "type": "module", + "engines": { + "node": ">=20" + }, "bin": { "mcp-server-everything": "dist/index.js" }, @@ -30,7 +33,10 @@ "test": "vitest run --coverage" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/node": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "cors": "^2.8.5", "express": "^5.2.1", "jszip": "^3.10.1", diff --git a/src/everything/prompts/args.ts b/src/everything/prompts/args.ts index 7e445a4ce4..e0a2d7e91c 100644 --- a/src/everything/prompts/args.ts +++ b/src/everything/prompts/args.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; /** * Register a prompt with arguments diff --git a/src/everything/prompts/completions.ts b/src/everything/prompts/completions.ts index e47c36e57b..07bbeb89d6 100644 --- a/src/everything/prompts/completions.ts +++ b/src/everything/prompts/completions.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { completable } from "@modelcontextprotocol/server"; /** * Register a prompt with completable arguments diff --git a/src/everything/prompts/index.ts b/src/everything/prompts/index.ts index 6efa7b7297..22d6a22baf 100644 --- a/src/everything/prompts/index.ts +++ b/src/everything/prompts/index.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerSimplePrompt } from "./simple.js"; import { registerArgumentsPrompt } from "./args.js"; import { registerPromptWithCompletions } from "./completions.js"; diff --git a/src/everything/prompts/resource.ts b/src/everything/prompts/resource.ts index 03989aaa25..fd16d5b346 100644 --- a/src/everything/prompts/resource.ts +++ b/src/everything/prompts/resource.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { resourceTypeCompleter, resourceIdForPromptCompleter, diff --git a/src/everything/prompts/simple.ts b/src/everything/prompts/simple.ts index a2a0d2eea6..8e2c5c67cd 100644 --- a/src/everything/prompts/simple.ts +++ b/src/everything/prompts/simple.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; /** * Register a simple prompt with no arguments diff --git a/src/everything/resources/files.ts b/src/everything/resources/files.ts index e38cb59633..f277feb500 100644 --- a/src/everything/resources/files.ts +++ b/src/everything/resources/files.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { readdirSync, readFileSync, statSync } from "fs"; @@ -42,11 +42,30 @@ export const registerFileResources = (server: McpServer) => { const mimeType = getMimeType(name); const description = `Static document file exposed from /docs: ${name}`; - // Register file resource + // Register file resource. + // + // These are the one genuinely static surface this server exposes: the + // files ship inside the package and only change when it is rebuilt. So + // they carry a per-registration `cacheHint`, which wins over the + // server-level `cacheHints` for their own `resources/read` result. + // + // Cache fields resolve most-specific-author-first: fields the handler puts + // on the result, then this `cacheHint`, then `ServerOptions.cacheHints`, + // then the conservative default (`ttlMs: 0`, `cacheScope: "private"`). + // Without this, reads of these files would fall through to that default + // and never be cached. `public` is safe here because the content is + // identical for every caller -- unlike the dynamic and session-scoped + // resources, which deliberately carry no hint. + // + // Emitted only toward modern-era clients; legacy responses are unchanged. server.registerResource( name, uri, - { mimeType, description }, + { + mimeType, + description, + cacheHint: { ttlMs: 3_600_000, cacheScope: "public" }, + }, async (uri) => { const text = readFileSafe(fullPath); return { diff --git a/src/everything/resources/index.ts b/src/everything/resources/index.ts index 30c6f7dcf8..b7ec741376 100644 --- a/src/everything/resources/index.ts +++ b/src/everything/resources/index.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerResourceTemplates } from "./templates.js"; import { registerFileResources } from "./files.js"; import { fileURLToPath } from "url"; diff --git a/src/everything/resources/session.ts b/src/everything/resources/session.ts index 10e0db33c1..f52eb6e5c4 100644 --- a/src/everything/resources/session.ts +++ b/src/everything/resources/session.ts @@ -1,5 +1,5 @@ -import { McpServer, RegisteredResource } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Resource, ResourceLink } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, RegisteredResource } from "@modelcontextprotocol/server"; +import { Resource, ResourceLink } from "@modelcontextprotocol/server"; /** * Tracks registered session resources by URI to allow updating/removing on re-registration. diff --git a/src/everything/resources/subscriptions.ts b/src/everything/resources/subscriptions.ts index 2a5e57460f..43959a7ff8 100644 --- a/src/everything/resources/subscriptions.ts +++ b/src/everything/resources/subscriptions.ts @@ -1,8 +1,5 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - SubscribeRequestSchema, - UnsubscribeRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { getNotifier } from "../server/notifier.js"; // Track subscriber session id lists by URI const subscriptions: Map> = new Map< @@ -36,13 +33,13 @@ const subsUpdateIntervals: Map = export const setSubscriptionHandlers = (server: McpServer) => { // Set the subscription handler server.server.setRequestHandler( - SubscribeRequestSchema, - async (request, extra) => { + "resources/subscribe", + async (request, ctx) => { // Get the URI to subscribe to const { uri } = request.params; // Get the session id (can be undefined for stdio) - const sessionId = extra.sessionId as string; + const sessionId = ctx.sessionId as string; // Acknowledge the subscribe request await server.sendLoggingMessage( @@ -67,13 +64,13 @@ export const setSubscriptionHandlers = (server: McpServer) => { // Set the unsubscription handler server.server.setRequestHandler( - UnsubscribeRequestSchema, - async (request, extra) => { + "resources/unsubscribe", + async (request, ctx) => { // Get the URI to subscribe to const { uri } = request.params; // Get the session id (can be undefined for stdio) - const sessionId = extra.sessionId as string; + const sessionId = ctx.sessionId as string; // Acknowledge the subscribe request await server.sendLoggingMessage( @@ -99,10 +96,16 @@ export const setSubscriptionHandlers = (server: McpServer) => { /** * Sends simulated resource update notifications to the subscribed client. * - * This function iterates through all resource URIs stored in the subscriptions - * and checks if the specified session ID is subscribed to them. If so, it sends - * a notification through the provided server. If the session ID is no longer valid - * (disconnected), it removes the session ID from the list of subscribers. + * Publishing goes through `getNotifier`, not the server instance directly, so + * the update reaches the client on both eras. On stdio the notifier calls the + * pinned instance and `serveStdio` routes it; over Streamable HTTP it publishes + * onto the handler's `subscriptions/listen` bus, which is the only path that + * works when every request is served by a fresh instance. + * + * The legacy-era `resources/subscribe` bookkeeping below is only consulted when a + * session exists. On a 2026-07-28 connection the client's `subscriptions/listen` + * filter decides who receives a `resources/updated`, so the notifier is called + * for every known URI and the bus does the filtering. * * @param {McpServer} server - The server instance used to send notifications. * @param {string | undefined} sessionId - The session ID of the client to check for subscriptions. @@ -112,16 +115,15 @@ const sendSimulatedResourceUpdates = async ( server: McpServer, sessionId: string | undefined ): Promise => { + const notifier = getNotifier(server); + // Search all URIs for ones this client is subscribed to for (const uri of subscriptions.keys()) { const subscribers = subscriptions.get(uri) as Set; // If this client is subscribed, send the notification if (subscribers.has(sessionId)) { - await server.server.notification({ - method: "notifications/resources/updated", - params: { uri }, - }); + notifier.resourceUpdated(uri); } else { subscribers.delete(sessionId); // subscriber has disconnected } diff --git a/src/everything/resources/templates.ts b/src/everything/resources/templates.ts index 6d4903f74c..6ec1c7cc19 100644 --- a/src/everything/resources/templates.ts +++ b/src/everything/resources/templates.ts @@ -3,8 +3,8 @@ import { CompleteResourceTemplateCallback, McpServer, ResourceTemplate, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; +} from "@modelcontextprotocol/server"; +import { completable } from "@modelcontextprotocol/server"; // Resource types export const RESOURCE_TYPE_TEXT = "Text" as const; diff --git a/src/everything/server/index.ts b/src/everything/server/index.ts index f1459cc812..385d02dd3b 100644 --- a/src/everything/server/index.ts +++ b/src/everything/server/index.ts @@ -1,48 +1,53 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - InMemoryTaskStore, - InMemoryTaskMessageQueue, -} from "@modelcontextprotocol/sdk/experimental/tasks"; +import type { McpRequestContext } from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/server"; import { setSubscriptionHandlers, stopSimulatedResourceUpdates, } from "../resources/subscriptions.js"; -import { registerConditionalTools, registerTools } from "../tools/index.js"; +import { registerTools } from "../tools/index.js"; import { registerResources, readInstructions } from "../resources/index.js"; import { registerPrompts } from "../prompts/index.js"; import { stopSimulatedLogging } from "./logging.js"; +import { requestStateCodec } from "./request-state.js"; import { syncRoots } from "./roots.js"; -// Server Factory response -export type ServerFactoryResponse = { - server: McpServer; - cleanup: (sessionId?: string) => void; -}; - /** * Server Factory * - * This function initializes a `McpServer` with specific capabilities and instructions, - * registers tools, resources, and prompts, and configures resource subscription handlers. + * Builds one `McpServer` for a single serving unit -- one HTTP request under + * `createMcpHandler`, or one connection under `serveStdio` -- and registers the + * full tool / resource / prompt surface on it. + * + * The same factory backs **both protocol eras**. Tools, resources and prompts + * are defined once; the serving entry decides which era the instance serves and + * the SDK adapts the wire behavior. `ctx.era` is only consulted for the handful + * of things that genuinely cannot exist on one era or the other. + * + * ## "legacy" and "modern" * - * @returns {ServerFactoryResponse} An object containing the server instance, and a `cleanup` - * function for handling server-side cleanup when a session ends. + * These are the SDK's own era names -- the literal values of `ctx.era` + * (`ProtocolEra = 'legacy' | 'modern'`), used throughout this server: * - * Properties of the returned object: - * - `server` {Object}: The initialized server instance. - * - `cleanup` {Function}: Function to perform cleanup operations for a closing session. + * - **legacy** -- protocol revisions `2024-10-07` through `2025-11-25`. + * `initialize` handshake, sessions, server->client requests, unsolicited + * notifications. + * - **modern** -- `2026-07-28` and later. No handshake and no sessions: every + * request carries its own protocol version, client identity and capabilities + * in `_meta`. Server->client requests are replaced by multi-round-trip + * `inputRequired(...)` returns, and change notifications by + * `subscriptions/listen`. + * + * The split is architectural, not chronological -- the SDK's constant is + * `FIRST_MODERN_PROTOCOL_VERSION`, so later revisions join the modern era + * rather than starting a third one. + * + * @param ctx Construction context from the serving entry: the `era` this + * instance will serve, and (HTTP only) validated `authInfo`. */ -export const createServer: () => ServerFactoryResponse = () => { +export const createServer = (ctx?: McpRequestContext): McpServer => { // Read the server instructions const instructions = readInstructions(); - // Create task store and message queue for task support - const taskStore = new InMemoryTaskStore(); - const taskMessageQueue = new InMemoryTaskMessageQueue(); - - let initializeTimeout: NodeJS.Timeout | null = null; - - // Create the server const server = new McpServer( { name: "mcp-servers/everything", @@ -62,57 +67,76 @@ export const createServer: () => ServerFactoryResponse = () => { listChanged: true, }, logging: {}, - tasks: { - list: {}, - cancel: {}, - requests: { - tools: { - call: {}, - }, - }, - }, }, instructions, - taskStore, - taskMessageQueue, + // Verify the HMAC seal on every echoed `requestState` before a handler + // is entered. The seam runs this on each multi-round-trip round -- on + // both eras, since the legacy shim mirrors the modern driver exactly -- + // and answers a rejection with the frozen `-32602`. + requestState: { verify: requestStateCodec.verify }, + // Advertise a real cache policy for the 2026-07-28 `CacheableResult` + // fields. Without this the SDK emits the most conservative possible + // default (`ttlMs: 0`, `cacheScope: "private"`) on every cacheable + // result. The listing surfaces are static for the process lifetime, so + // they are safe to cache briefly and safe to share. `resources/read` is + // deliberately absent -- several of this server's resources are + // intentionally dynamic. These fields never appear on legacy-era responses. + cacheHints: { + "tools/list": { ttlMs: 60_000, cacheScope: "public" }, + "prompts/list": { ttlMs: 60_000, cacheScope: "public" }, + "resources/list": { ttlMs: 60_000, cacheScope: "public" }, + "resources/templates/list": { ttlMs: 60_000, cacheScope: "public" }, + "server/discover": { ttlMs: 60_000, cacheScope: "public" }, + }, } ); - // Register the tools + // Register the full surface. Note there is no longer a "conditional tools" + // pass gated on client capabilities: the tools that need elicitation / + // sampling / roots return `inputRequired(...)`, and the SDK refuses the + // embedded request with `-32021 MissingRequiredClientCapabilityError` when + // the caller did not declare the capability. That gate runs at dispatch on + // BOTH eras, so registration no longer has to wait for `initialize` -- which + // is what made the old `oninitialized` hook necessary, and which does not + // exist on 2026-07-28 at all. registerTools(server); - - // Register the resources registerResources(server); - - // Register the prompts registerPrompts(server); - // Set resource subscription handlers + // `resources/subscribe` / `resources/unsubscribe` are legacy-era only. They are + // physically absent from the 2026-07-28 method registry, so registering the + // handlers unconditionally is harmless -- an inbound `resources/subscribe` on + // a modern connection gets `-32601` regardless. Modern clients use + // `subscriptions/listen`, which the serving entry handles itself. setSubscriptionHandlers(server); - // Perform post-initialization operations - server.server.oninitialized = async () => { - // Register conditional tools now that client capabilities are known. - // This finishes before the `notifications/initialized` handler finishes. - registerConditionalTools(server); + // Roots on the legacy era are pulled with a server->client `roots/list` request + // after initialization. That channel does not exist on 2026-07-28 -- there the + // `get-roots-list` tool asks for roots via `inputRequired(...)` instead. So + // this hook is legacy-only. + if (ctx?.era !== "modern") { + server.server.oninitialized = () => { + // Delayed: a `roots/list` issued from inside the + // `notifications/initialized` handler is lost before the client is ready + // to answer it. + const sessionId = server.server.transport?.sessionId; + setTimeout(() => void syncRoots(server, sessionId), 350); + }; + } - // Sync roots if the client supports them. - // This is delayed until after the `notifications/initialized` handler finishes, - // otherwise, the request gets lost. - const sessionId = server.server.transport?.sessionId; - initializeTimeout = setTimeout(() => syncRoots(server, sessionId), 350); - }; + return server; +}; - // Return the ServerFactoryResponse - return { - server, - cleanup: (sessionId?: string) => { - // Stop any simulated logging or resource updates that may have been initiated. - stopSimulatedLogging(sessionId); - stopSimulatedResourceUpdates(sessionId); - // Clean up task store timers - taskStore.cleanup(); - if (initializeTimeout) clearTimeout(initializeTimeout); - }, - } satisfies ServerFactoryResponse; +/** + * Release process-level simulation state for a session that has ended. + * + * Only meaningful where a session exists -- a stdio connection, or a legacy-era + * Streamable HTTP session. Modern-era HTTP serving is per request and holds + * nothing between exchanges. + * + * @param sessionId The ending session, or `undefined` for stdio. + */ +export const cleanupSession = (sessionId?: string): void => { + stopSimulatedLogging(sessionId); + stopSimulatedResourceUpdates(sessionId); }; diff --git a/src/everything/server/logging.ts b/src/everything/server/logging.ts index 82edea162c..8b9a166920 100644 --- a/src/everything/server/logging.ts +++ b/src/everything/server/logging.ts @@ -1,5 +1,5 @@ -import { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { LoggingLevel } from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/server"; // Map session ID to the interval for sending logging messages to the client const logsUpdateIntervals: Map = diff --git a/src/everything/server/notifier.ts b/src/everything/server/notifier.ts new file mode 100644 index 0000000000..509061a098 --- /dev/null +++ b/src/everything/server/notifier.ts @@ -0,0 +1,60 @@ +import type { McpServer, ServerNotifier } from "@modelcontextprotocol/server"; + +/** + * Change-notification routing for a dual-era server. + * + * The two serving entries publish change notifications differently: + * + * - `serveStdio` pins one instance per connection and routes that instance's + * `send*ListChanged()` / `sendResourceUpdated()` calls onto whatever + * `subscriptions/listen` streams are open (2026-07-28), or emits them + * unsolicited (legacy-era). Instance methods are the right call there. + * - `createMcpHandler` builds a fresh instance per request, so an instance has + * no long-lived stream to write to. Publishing goes through the handler's + * `notify` facade, which fans out over the handler's event bus to every open + * subscription. + * + * Tools should not care which entry is running. They call `getNotifier(server)` + * and get something that does the right thing for the active transport. + */ + +let busNotifier: ServerNotifier | undefined; + +/** + * Point change notifications at an HTTP handler's `subscriptions/listen` bus. + * Called once by the Streamable HTTP entry with `handler.notify`; passing + * `undefined` reverts to instance-backed publishing. + */ +export const setBusNotifier = (notifier: ServerNotifier | undefined): void => { + busNotifier = notifier; +}; + +/** + * Get the notifier a tool should publish through. + * + * Returns the handler bus when one has been registered (Streamable HTTP), + * otherwise a facade over the supplied instance (stdio). + */ +export const getNotifier = (server: McpServer): ServerNotifier => { + if (busNotifier) return busNotifier; + + // stdio: the pinned instance is the publish path. These are fire-and-forget + // from the caller's perspective; a send that fails on a closing connection + // must not take the tool call down with it. + const swallow = (error: unknown) => { + console.error("Failed to publish change notification:", error); + }; + + // Go through the low-level `Server`: its senders return promises, so a send + // that rejects on a closing connection can be caught. The `McpServer` + // wrappers of the same names return `void` and would leave it unhandled. + return { + toolsChanged: () => void server.server.sendToolListChanged().catch(swallow), + promptsChanged: () => + void server.server.sendPromptListChanged().catch(swallow), + resourcesChanged: () => + void server.server.sendResourceListChanged().catch(swallow), + resourceUpdated: (uri: string) => + void server.server.sendResourceUpdated({ uri }).catch(swallow), + }; +}; diff --git a/src/everything/server/request-state.ts b/src/everything/server/request-state.ts new file mode 100644 index 0000000000..c6b0731bc6 Binary files /dev/null and b/src/everything/server/request-state.ts differ diff --git a/src/everything/server/roots.ts b/src/everything/server/roots.ts index 34b12b21ce..087f4d702f 100644 --- a/src/everything/server/roots.ts +++ b/src/everything/server/roots.ts @@ -1,8 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - Root, - RootsListChangedNotificationSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, Root } from "@modelcontextprotocol/server"; // Track roots by session id export const roots: Map = new Map< @@ -74,9 +70,12 @@ export const syncRoots = async (server: McpServer, sessionId?: string) => { // If the roots have not been synced for this client, // set notification handler and request initial roots if (!roots.has(sessionId)) { - // Set the list changed notification handler + // Set the list changed notification handler. This notification was + // removed in 2026-07-28, so it is physically absent from that era's + // registry -- registering it only ever takes effect on a legacy-era + // connection, which is the only era `syncRoots` runs on anyway. server.server.setNotificationHandler( - RootsListChangedNotificationSchema, + "notifications/roots/list_changed", requestRoots ); diff --git a/src/everything/tools/echo.ts b/src/everything/tools/echo.ts index 0658e83195..c4481cac64 100644 --- a/src/everything/tools/echo.ts +++ b/src/everything/tools/echo.ts @@ -1,5 +1,5 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult } from "@modelcontextprotocol/server"; import { z } from "zod"; // Tool input schema diff --git a/src/everything/tools/get-annotated-message.ts b/src/everything/tools/get-annotated-message.ts index 5de72b029a..2e5486da70 100644 --- a/src/everything/tools/get-annotated-message.ts +++ b/src/everything/tools/get-annotated-message.ts @@ -1,5 +1,5 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult } from "@modelcontextprotocol/server"; import { z } from "zod"; import { MCP_TINY_IMAGE } from "./get-tiny-image.js"; @@ -22,10 +22,10 @@ const config = { "Demonstrates how annotations can be used to provide metadata about content.", inputSchema: GetAnnotatedMessageSchema, annotations: { - readOnlyHint: true, // This tool only returns data, no side effects - destructiveHint: false, // Does not delete or modify anything - idempotentHint: true, // Same input always produces same output - openWorldHint: false, // Does not interact with external systems + readOnlyHint: true, // This tool only returns data, no side effects + destructiveHint: false, // Does not delete or modify anything + idempotentHint: true, // Same input always produces same output + openWorldHint: false, // Does not interact with external systems }, }; diff --git a/src/everything/tools/get-env.ts b/src/everything/tools/get-env.ts index 55eabfaa97..7fcfcd52e7 100644 --- a/src/everything/tools/get-env.ts +++ b/src/everything/tools/get-env.ts @@ -1,5 +1,5 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult } from "@modelcontextprotocol/server"; // Tool configuration const name = "get-env"; diff --git a/src/everything/tools/get-resource-links.ts b/src/everything/tools/get-resource-links.ts index 7684cb64a5..fa9120e3f7 100644 --- a/src/everything/tools/get-resource-links.ts +++ b/src/everything/tools/get-resource-links.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult } from "@modelcontextprotocol/server"; import { textResource, textResourceUri, diff --git a/src/everything/tools/get-resource-reference.ts b/src/everything/tools/get-resource-reference.ts index 5806365868..2ce21d5ad0 100644 --- a/src/everything/tools/get-resource-reference.ts +++ b/src/everything/tools/get-resource-reference.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult } from "@modelcontextprotocol/server"; import { textResource, textResourceUri, diff --git a/src/everything/tools/get-roots-list.ts b/src/everything/tools/get-roots-list.ts index a8778e4c01..ed4d319ea6 100644 --- a/src/everything/tools/get-roots-list.ts +++ b/src/everything/tools/get-roots-list.ts @@ -1,6 +1,12 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { syncRoots } from "../server/roots.js"; +import { + McpServer, + CallToolResult, + InputRequiredResult, + Root, + inputRequired, + inputResponse, +} from "@modelcontextprotocol/server"; +import { roots as cachedRoots } from "../server/roots.js"; // Tool configuration const name = "get-roots-list"; @@ -17,82 +23,97 @@ const config = { }, }; +// Key for this tool's embedded roots request. +const ROOTS = "roots"; + /** * Registers the 'get-roots-list' tool. * - * If the client does not support the roots capability, the tool is not registered. + * Reports the roots the client has made available -- workspace directories or + * file system roots. This server demonstrates the capability without actually + * reading any files. + * + * Roots are obtained differently on each era, and this tool handles both + * without branching: * - * The registered tool interacts with the MCP roots capability, which enables the server to access - * information about the client's workspace directories or file system roots. + * - On a **legacy-era** connection the server pulls `roots/list` after the + * handshake and caches the answer per session (see `server/roots.ts`), so the + * list is usually already known by the time this tool runs and it answers + * immediately from cache. + * - On **2026-07-28** there is no server->client request channel and no + * session to cache against, so nothing is prefetched. The tool asks for the + * roots by returning `inputRequired({ inputRequests: { roots: + * inputRequired.listRoots() } })` and the client retries the call with the + * listing attached. * - * When supported, the server automatically retrieves and formats the current list of roots from the - * client upon connection and whenever the client sends a `roots/list_changed` notification. + * The cache lookup simply misses on the modern era, which falls through to the + * request -- the same code path serves both. * - * Therefore, this tool displays the roots that the server currently knows about for the connected - * client. If for some reason the server never got the initial roots list, the tool will request the - * list from the client again. + * Registration is unconditional. A client that never declared the `roots` + * capability is refused by the SDK at dispatch with `-32021`, on both eras. * * @param {McpServer} server - The McpServer instance where the tool will be registered. */ export const registerGetRootsListTool = (server: McpServer) => { - // Does client support roots? - const clientCapabilities = server.server.getClientCapabilities() || {}; - const clientSupportsRoots: boolean = clientCapabilities.roots !== undefined; + server.registerTool( + name, + config, + async (args, ctx): Promise => { + const answer = inputResponse(ctx.mcpReq.inputResponses, ROOTS); - // If so, register tool - if (clientSupportsRoots) { - server.registerTool( - name, - config, - async (args, extra): Promise => { - // Get the current rootsFetch the current roots list from the client if need be - const currentRoots = await syncRoots(server, extra.sessionId); + // Prefer a listing the client just sent; otherwise fall back to whatever + // the legacy-era post-handshake sync cached for this session. + let currentRoots: Root[] | undefined; + if (answer.kind === "roots") { + currentRoots = answer.roots; + } else if (answer.kind === "missing") { + currentRoots = cachedRoots.get(ctx.sessionId); - // Respond if client supports roots but doesn't have any configured - if ( - clientSupportsRoots && - (!currentRoots || currentRoots.length === 0) - ) { - return { - content: [ - { - type: "text", - text: - "The client supports roots but no roots are currently configured.\n\n" + - "This could mean:\n" + - "1. The client hasn't provided any roots yet\n" + - "2. The client provided an empty roots list\n" + - "3. The roots configuration is still being loaded", - }, - ], - }; + // Nothing cached (always the case on 2026-07-28): ask for it. + if (!currentRoots) { + return inputRequired({ + inputRequests: { [ROOTS]: inputRequired.listRoots() }, + }); } + } - // Create formatted response if there is a list of roots - const rootsList = currentRoots - ? currentRoots - .map((root, index) => { - return `${index + 1}. ${root.name || "Unnamed Root"}\n URI: ${ - root.uri - }`; - }) - .join("\n\n") - : "No roots found"; - + // Respond if the client supports roots but doesn't have any configured + if (!currentRoots || currentRoots.length === 0) { return { content: [ { type: "text", text: - `Current MCP Roots (${ - currentRoots!.length - } total):\n\n${rootsList}\n\n` + - "Note: This server demonstrates the roots protocol capability but doesn't actually access files. " + - "The roots are provided by the MCP client and can be used by servers that need file system access.", + "The client supports roots but no roots are currently configured.\n\n" + + "This could mean:\n" + + "1. The client hasn't provided any roots yet\n" + + "2. The client provided an empty roots list\n" + + "3. The roots configuration is still being loaded", }, ], }; } - ); - } + + // Create formatted response if there is a list of roots + const rootsList = currentRoots + .map((root, index) => { + return `${index + 1}. ${root.name || "Unnamed Root"}\n URI: ${ + root.uri + }`; + }) + .join("\n\n"); + + return { + content: [ + { + type: "text", + text: + `Current MCP Roots (${currentRoots.length} total):\n\n${rootsList}\n\n` + + "Note: This server demonstrates the roots protocol capability but doesn't actually access files. " + + "The roots are provided by the MCP client and can be used by servers that need file system access.", + }, + ], + }; + } + ); }; diff --git a/src/everything/tools/get-structured-content.ts b/src/everything/tools/get-structured-content.ts index f9bde5c9e4..811fa2c02e 100644 --- a/src/everything/tools/get-structured-content.ts +++ b/src/everything/tools/get-structured-content.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - CallToolResult, - ContentBlock, -} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult, ContentBlock } from "@modelcontextprotocol/server"; // Tool input schema const GetStructuredContentInputSchema = { diff --git a/src/everything/tools/get-sum.ts b/src/everything/tools/get-sum.ts index 470293bf6f..0337cde2e6 100644 --- a/src/everything/tools/get-sum.ts +++ b/src/everything/tools/get-sum.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult } from "@modelcontextprotocol/server"; // Tool input schema const GetSumSchema = z.object({ diff --git a/src/everything/tools/get-tiny-image.ts b/src/everything/tools/get-tiny-image.ts index c38c3c698b..222c25b9be 100644 --- a/src/everything/tools/get-tiny-image.ts +++ b/src/everything/tools/get-tiny-image.ts @@ -1,5 +1,5 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult } from "@modelcontextprotocol/server"; // A tiny encoded MCP logo image export const MCP_TINY_IMAGE = diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts index 3dd6fdae4a..938713e98e 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult, Resource } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult, Resource } from "@modelcontextprotocol/server"; import { gzipSync } from "node:zlib"; import { getSessionResourceURI, diff --git a/src/everything/tools/index.ts b/src/everything/tools/index.ts index 0272126eea..62c2134cf9 100644 --- a/src/everything/tools/index.ts +++ b/src/everything/tools/index.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerGetAnnotatedMessageTool } from "./get-annotated-message.js"; import { registerEchoTool } from "./echo.js"; import { registerGetEnvTool } from "./get-env.js"; @@ -14,13 +14,24 @@ import { registerToggleSubscriberUpdatesTool } from "./toggle-subscriber-updates import { registerTriggerElicitationRequestTool } from "./trigger-elicitation-request.js"; import { registerTriggerLongRunningOperationTool } from "./trigger-long-running-operation.js"; import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.js"; -import { registerTriggerSamplingRequestAsyncTool } from "./trigger-sampling-request-async.js"; -import { registerTriggerElicitationRequestAsyncTool } from "./trigger-elicitation-request-async.js"; import { registerSimulateResearchQueryTool } from "./simulate-research-query.js"; import { registerTriggerUrlElicitationTool } from "./trigger-url-elicitation.js"; /** * Register the tools with the MCP server. + * + * There is deliberately no second "conditional tools" pass here any more. The + * old split existed because the tools that need elicitation / sampling / roots + * had to wait for the `initialize` handshake to learn the client's + * capabilities. 2026-07-28 has no handshake, and it does not need one: those + * tools now *return* `inputRequired(...)`, and the SDK refuses an embedded + * request with `-32021 MissingRequiredClientCapabilityError` at dispatch when + * the caller never declared the capability. That gate runs on both eras, so + * every tool can be registered up front, unconditionally. + * + * Tools listed here are era-agnostic: each is written once and served to 2025- + * and modern-era clients alike. + * * @param server */ export const registerTools = (server: McpServer) => { @@ -29,27 +40,16 @@ export const registerTools = (server: McpServer) => { registerGetEnvTool(server); registerGetResourceLinksTool(server); registerGetResourceReferenceTool(server); + registerGetRootsListTool(server); registerGetStructuredContentTool(server); registerGetSumTool(server); registerGetTinyImageTool(server); registerGZipFileAsResourceTool(server); + registerSimulateResearchQueryTool(server); registerToggleSimulatedLoggingTool(server); registerToggleSubscriberUpdatesTool(server); - registerTriggerLongRunningOperationTool(server); -}; - -/** - * Register the tools that are conditional upon client capabilities. - * These must be registered conditionally, after initialization. - */ -export const registerConditionalTools = (server: McpServer) => { - registerGetRootsListTool(server); registerTriggerElicitationRequestTool(server); - registerTriggerUrlElicitationTool(server); + registerTriggerLongRunningOperationTool(server); registerTriggerSamplingRequestTool(server); - // Task-based research tool (uses experimental tasks API) - registerSimulateResearchQueryTool(server); - // Bidirectional task tools - server sends requests that client executes as tasks - registerTriggerSamplingRequestAsyncTool(server); - registerTriggerElicitationRequestAsyncTool(server); + registerTriggerUrlElicitationTool(server); }; diff --git a/src/everything/tools/simulate-research-query.ts b/src/everything/tools/simulate-research-query.ts index 098df8a0a0..16e7ac1343 100644 --- a/src/everything/tools/simulate-research-query.ts +++ b/src/everything/tools/simulate-research-query.ts @@ -1,13 +1,13 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { + McpServer, CallToolResult, - GetTaskResult, - Task, - ElicitResult, - ElicitResultSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { CreateTaskResult } from "@modelcontextprotocol/sdk/experimental/tasks"; + InputRequiredResult, + ServerContext, + inputRequired, + inputResponse, +} from "@modelcontextprotocol/server"; +import { requestStateCodec } from "../server/request-state.js"; // Tool input schema const SimulateResearchQuerySchema = z.object({ @@ -16,7 +16,7 @@ const SimulateResearchQuerySchema = z.object({ .boolean() .default(false) .describe( - "Simulate an ambiguous query that requires clarification (triggers input_required status)" + "Simulate an ambiguous query that requires clarification (pauses to elicit)" ), }); @@ -31,147 +31,206 @@ const STAGES = [ // Duration per stage in milliseconds const STAGE_DURATION = 1000; -// Internal state for tracking research tasks -interface ResearchState { - topic: string; - ambiguous: boolean; - currentStage: number; - clarification?: string; - completed: boolean; - result?: CallToolResult; -} +// The stage at which an ambiguous query pauses to ask for clarification. +const CLARIFY_AT_STAGE = 2; -// Map to store research state per task -const researchStates = new Map(); +// Key for this tool's embedded clarification request. +const CLARIFICATION = "clarification"; /** - * Runs the background research process. - * Updates task status as it progresses through stages. - * If clarification is needed, sends elicitation via sendRequest with relatedTask, - * which queues the request in the task message queue. The SDK delivers it through - * the tasks/result stream when the client calls tasks/result (per spec input_required flow). - * This works on all transports (STDIO, SSE, Streamable HTTP). + * State threaded across rounds of an ambiguous query. + * + * Multi-step multi-round-trip flows must carry everything they have learned in + * `requestState`, because `inputResponses` are **per round** -- each retry + * carries only that round's answers, never earlier ones. Modelled as a + * discriminated union on `step` so each re-entry knows exactly what is in + * scope, per the SDK's phase-switch guidance. */ -async function runResearchProcess( - taskId: string, - args: z.infer, - taskStore: { - updateTaskStatus: ( - taskId: string, - status: Task["status"], - message?: string - ) => Promise; - storeTaskResult: ( - taskId: string, - status: "completed" | "failed", - result: CallToolResult - ) => Promise; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendRequest: any -): Promise { - const state = researchStates.get(taskId); - if (!state) return; - - // Process each stage - for (let i = state.currentStage; i < STAGES.length; i++) { - state.currentStage = i; +type ResearchState = { step: "awaiting-clarification"; topic: string }; - // Check if task was cancelled externally - if (state.completed) return; - - // Update status message for current stage - await taskStore.updateTaskStatus(taskId, "working", `${STAGES[i]}...`); +/** + * Emit a progress notification for one stage. + * + * `ctx.mcpReq.notify` binds the notification to the originating request, which + * is what both eras need: on 2026-07-28 request-scoped notifications flow on + * that request's own response stream rather than a standalone one. + * + * The `progressToken` spans the whole multi-round flow, and progress on a + * single token MUST increase -- so values are derived from the absolute stage + * index, not from a per-round counter, and keep increasing across re-entries. + */ +const reportStage = async (ctx: ServerContext, stage: number) => { + const progressToken = ctx.mcpReq._meta?.progressToken; + if (progressToken === undefined) return; + await ctx.mcpReq.notify({ + method: "notifications/progress", + params: { + progress: stage + 1, + total: STAGES.length, + progressToken, + message: `${STAGES[stage]}...`, + }, + }); +}; - // At synthesis stage (index 2), check if clarification is needed - if (i === 2 && state.ambiguous && !state.clarification) { - // Update status to show we're requesting input (spec SHOULD) - await taskStore.updateTaskStatus( - taskId, - "input_required", - `Found multiple interpretations for "${state.topic}". Requesting clarification...` - ); +/** Run stages `from`..`to` (exclusive), reporting progress for each. */ +const runStages = async (ctx: ServerContext, from: number, to: number) => { + for (let i = from; i < to; i++) { + await reportStage(ctx, i); + await new Promise((resolve) => setTimeout(resolve, STAGE_DURATION)); + } +}; - try { - // relatedTask queues elicitation via task message queue → delivered through tasks/result on all transports - const elicitResult: ElicitResult = await sendRequest( - { - method: "elicitation/create", - params: { - message: `The research query "${state.topic}" could have multiple interpretations. Please clarify what you're looking for:`, - requestedSchema: { - type: "object", - properties: { - interpretation: { - type: "string", - title: "Clarification", - description: - "Which interpretation of the topic do you mean?", - oneOf: getInterpretationsForTopic(state.topic), +/** + * Registers the 'simulate-research-query' tool. + * + * Simulates a long-running research operation that moves through four stages, + * reporting progress as it goes. When `ambiguous` is set, it pauses partway to + * ask the user which interpretation of the topic they meant, then resumes with + * that answer and produces the report. + * + * ## Why this is not a task-based tool any more + * + * The v1 version modelled this with the experimental tasks API + * (`server.experimental.tasks.registerToolTask`): `tools/call` returned a + * `CreateTaskResult`, the client polled `tasks/get`, and clarification was + * queued through the task message queue via `relatedTask`. All of that is gone: + * + * - SDK v2 removed the experimental tasks interception layer entirely. + * - 2026-07-28 moved tasks out of the core protocol into an extension + * (`io.modelcontextprotocol/tasks`, SEP-2663) with a different wire shape. + * - The SDK cannot serve that extension. Methods deleted by a revision are + * physically absent from that era's registry, so an inbound `tasks/get` on a + * modern-era connection is answered `-32601` *even if a handler is registered* + * -- and `tasks/*` are spec names, so they cannot be registered as + * vendor-prefixed custom methods either. Serving the extension today requires + * intercepting `tasks/*` outside the SDK, which has no clean stdio equivalent. + * + * What the tool demonstrated is preserved without any of that machinery. The + * long-running staged operation and its progress reporting are unchanged, and + * the mid-flight clarification is now a multi-round-trip `inputRequired(...)` + * return -- written once, served to modern-era clients natively and to legacy-era + * clients by the SDK's legacy shim. Only the task *wire shape* is lost, and + * only because the SDK cannot put it on the wire. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerSimulateResearchQueryTool = (server: McpServer) => { + server.registerTool( + "simulate-research-query", + { + title: "Simulate Research Query", + description: + "Simulates a deep research operation that gathers, analyzes, and synthesizes information. " + + "Reports progress through multiple stages. If 'ambiguous' is true, pauses partway to ask " + + "the user which interpretation of the topic they meant, then resumes.", + inputSchema: SimulateResearchQuerySchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (args, ctx): Promise => { + const { topic, ambiguous } = SimulateResearchQuerySchema.parse(args); + const state = ctx.mcpReq.requestState(); + + switch (state?.step) { + case undefined: { + // First round. Work up to the clarification point. + if (!ambiguous) { + await runStages(ctx, 0, STAGES.length); + return generateResearchReport(topic); + } + + await runStages(ctx, 0, CLARIFY_AT_STAGE); + return inputRequired({ + inputRequests: { + [CLARIFICATION]: inputRequired.elicit({ + message: + `The research query "${topic}" could have multiple ` + + `interpretations. Please clarify what you're looking for:`, + requestedSchema: { + type: "object", + properties: { + interpretation: { + type: "string", + title: "Clarification", + description: + "Which interpretation of the topic do you mean?", + oneOf: getInterpretationsForTopic(topic), + }, }, + required: ["interpretation"], }, - required: ["interpretation"], - }, + }), }, - }, - ElicitResultSchema, - { relatedTask: { taskId } } - ); + // Carry the topic forward: the retry's `inputResponses` hold only + // the clarification, and nothing else survives the round. + requestState: await requestStateCodec.mint( + { step: "awaiting-clarification", topic }, + ctx + ), + }); + } - // Process elicitation response - if (elicitResult.action === "accept" && elicitResult.content) { - state.clarification = - (elicitResult.content as { interpretation?: string }) - .interpretation || "User accepted without selection"; - } else if (elicitResult.action === "decline") { - state.clarification = "User declined - using default interpretation"; - } else { - state.clarification = "User cancelled - using default interpretation"; + case "awaiting-clarification": { + // Re-entry. The seam has already verified the seal on `requestState`. + const answer = inputResponse( + ctx.mcpReq.inputResponses, + CLARIFICATION + ); + const clarification = readClarification(answer); + + // Finish the remaining stages, continuing the same progress token. + await runStages(ctx, CLARIFY_AT_STAGE, STAGES.length); + return generateResearchReport(state.topic, clarification); } - } catch (error) { - // Elicitation failed - use default interpretation and continue - console.warn( - `Elicitation failed for task ${taskId}:`, - error instanceof Error ? error.message : String(error) - ); - state.clarification = "technical (default - elicitation unavailable)"; } - - // Resume with working status (spec SHOULD) - await taskStore.updateTaskStatus( - taskId, - "working", - `Continuing with interpretation: "${state.clarification}"...` - ); - - // Continue processing (no return - just keep going through the loop) } + ); +}; - // Simulate work for this stage - await new Promise((resolve) => setTimeout(resolve, STAGE_DURATION)); +/** + * Interpret the client's answer to the clarification request. + * + * Content comes from the client and is NOT re-validated against + * `requestedSchema` on either era, so it is read defensively. A decline or + * cancel is not a failure here -- the research just proceeds with a default. + */ +const readClarification = ( + answer: ReturnType +): string => { + if (answer.kind !== "elicit") { + return "no response - using default interpretation"; } - - // All stages complete - generate result - state.completed = true; - const result = generateResearchReport(state); - state.result = result; - - await taskStore.storeTaskResult(taskId, "completed", result); -} + if (answer.action === "decline") { + return "User declined - using default interpretation"; + } + if (answer.action === "cancel") { + return "User cancelled - using default interpretation"; + } + const interpretation = answer.content?.interpretation; + return typeof interpretation === "string" && interpretation.length > 0 + ? interpretation + : "User accepted without selection"; +}; /** - * Generates the final research report with educational content about tasks. + * Generates the final research report. */ -function generateResearchReport(state: ResearchState): CallToolResult { - const topic = state.clarification - ? `${state.topic} (${state.clarification})` - : state.topic; +function generateResearchReport( + topic: string, + clarification?: string +): CallToolResult { + const heading = clarification ? `${topic} (${clarification})` : topic; - const report = `# Research Report: ${topic} + const report = `# Research Report: ${heading} ## Research Parameters -- **Topic**: ${state.topic} -${state.clarification ? `- **Clarification**: ${state.clarification}` : ""} +- **Topic**: ${topic} +${clarification ? `- **Clarification**: ${clarification}` : ""} ## Synthesis This research query was processed through ${STAGES.length} stages: @@ -179,37 +238,35 @@ ${STAGES.map((s, i) => `- Stage ${i + 1}: ${s} ✓`).join("\n")} --- -## About This Demo (SEP-1686: Tasks) - -This tool demonstrates MCP's task-based execution pattern for long-running operations: +## About This Demo (Multi Round-Trip Requests, SEP-2322) -**Task Lifecycle Demonstrated:** -1. \`tools/call\` with \`task\` parameter → Server returns \`CreateTaskResult\` (not the final result) -2. Client polls \`tasks/get\` → Server returns current status and \`statusMessage\` -3. Status progressed: \`working\` → ${ - state.clarification ? `\`input_required\` → \`working\` → ` : "" - }\`completed\` -4. Client calls \`tasks/result\` → Server returns this final result +This tool demonstrates a long-running operation that needs input partway through: +1. \`tools/call\` starts the work and reports progress per stage via + \`notifications/progress\`. ${ - state.clarification - ? `**Elicitation Flow:** -When the query was ambiguous, the server sent an \`elicitation/create\` request -to the client. The task status changed to \`input_required\` while awaiting user input. -${ - state.clarification.includes("unavailable") - ? `**Note:** Elicitation failed and a default interpretation was used.` - : `After receiving clarification ("${state.clarification}"), the task resumed processing and completed.` -} + clarification + ? `2. Hitting an ambiguity, the handler **returns** an \`input_required\` result + carrying an embedded \`elicitation/create\` request -- it does not push a + server-to-client request, which the 2026-07-28 revision removed entirely. +3. The client answers and **retries the original call**, echoing the server's + opaque \`requestState\` (HMAC-sealed here, and verified before this handler + was re-entered) so the topic survives the round trip. +4. The handler resumes from where it paused and returns this report. +` + : `2. With an unambiguous topic the work runs straight through. Pass + \`ambiguous: true\` to see the multi-round-trip clarification flow. ` - : "" } -**Key Concepts:** -- Tasks enable "call now, fetch later" patterns -- \`statusMessage\` provides human-readable progress updates -- Tasks have TTL (time-to-live) for automatic cleanup -- \`pollInterval\` suggests how often to check status -- Elicitation requests use \`relatedTask\` to queue via tasks/result (works on all transports) +**Key concepts:** +- The handler is written **once** and serves both protocol eras. On 2026-07-28 + the client fulfils the embedded request and retries; on a legacy-era connection + the SDK's legacy shim turns the same return into a real server-to-client + \`elicitation/create\` over the live session. The handler cannot tell which. +- \`inputResponses\` are **per round** -- anything that must survive a round + goes in \`requestState\`. +- \`requestState\` round-trips through the client, so it is untrusted on + re-entry and is integrity-protected. *This is a simulated research report from the Everything MCP Server.* `; @@ -224,101 +281,6 @@ ${ }; } -/** - * Registers the 'simulate-research-query' tool as a task-based tool. - * - * This tool demonstrates the MCP Tasks feature (SEP-1686) with a real-world scenario: - * a research tool that gathers and synthesizes information from multiple sources. - * If the query is ambiguous, it pauses to ask for clarification before completing. - * - * @param {McpServer} server - The McpServer instance where the tool will be registered. - */ -export const registerSimulateResearchQueryTool = (server: McpServer) => { - // Check if client supports elicitation (needed for input_required flow) - const clientCapabilities = server.server.getClientCapabilities() || {}; - const clientSupportsElicitation: boolean = - clientCapabilities.elicitation !== undefined; - - server.experimental.tasks.registerToolTask( - "simulate-research-query", - { - title: "Simulate Research Query", - description: - "Simulates a deep research operation that gathers, analyzes, and synthesizes information. " + - "Demonstrates MCP task-based operations with progress through multiple stages. " + - "If 'ambiguous' is true and client supports elicitation, sends an elicitation request for clarification.", - inputSchema: SimulateResearchQuerySchema, - execution: { taskSupport: "required" }, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, - }, - }, - { - /** - * Creates a new research task and starts background processing. - */ - createTask: async (args, extra): Promise => { - const validatedArgs = SimulateResearchQuerySchema.parse(args); - - // Create the task in the store - const task = await extra.taskStore.createTask({ - ttl: 300000, // 5 minutes - pollInterval: 1000, - }); - - // Initialize research state - const state: ResearchState = { - topic: validatedArgs.topic, - ambiguous: validatedArgs.ambiguous && clientSupportsElicitation, - currentStage: 0, - completed: false, - }; - researchStates.set(task.taskId, state); - - // Start background research (don't await - runs asynchronously) - // Pass sendRequest for elicitation (queued via task message queue, works on all transports) - runResearchProcess( - task.taskId, - validatedArgs, - extra.taskStore, - extra.sendRequest - ).catch((error) => { - console.error(`Research task ${task.taskId} failed:`, error); - extra.taskStore - .updateTaskStatus(task.taskId, "failed", String(error)) - .catch(console.error); - }); - - return { task }; - }, - - /** - * Returns the current status of the research task. - */ - getTask: async (args, extra): Promise => { - return await extra.taskStore.getTask(extra.taskId); - }, - - /** - * Returns the task result. - * Elicitation is now handled directly in the background process. - */ - getTaskResult: async (args, extra): Promise => { - // Return the stored result - const result = await extra.taskStore.getTaskResult(extra.taskId); - - // Clean up state - researchStates.delete(extra.taskId); - - return result as CallToolResult; - }, - } - ); -}; - /** * Returns contextual interpretation options based on the topic. */ diff --git a/src/everything/tools/toggle-simulated-logging.ts b/src/everything/tools/toggle-simulated-logging.ts index eb9a4b1e25..dd048382d2 100644 --- a/src/everything/tools/toggle-simulated-logging.ts +++ b/src/everything/tools/toggle-simulated-logging.ts @@ -1,5 +1,5 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult } from "@modelcontextprotocol/server"; import { beginSimulatedLogging, stopSimulatedLogging, @@ -9,7 +9,12 @@ import { const name = "toggle-simulated-logging"; const config = { title: "Toggle Simulated Logging", - description: "Toggles simulated, random-leveled logging on or off.", + description: + "Toggles simulated, random-leveled logging on or off. " + + "Legacy-era connections only (protocol revisions 2024-10-07 through " + + "2025-11-25): this streams unsolicited notifications/message over the " + + "connection, which the 2026-07-28 revision does not have. On a 2026-07-28 " + + "connection the toggle is accepted but no log messages arrive.", inputSchema: {}, annotations: { readOnlyHint: false, @@ -32,6 +37,28 @@ const clients: Set = new Set(); * current state. If logging for the specified session is active, it will be stopped; * if it is inactive, logging will be started. * + * ## legacy-era only + * + * This is the one tool here that cannot be made era-agnostic. It models a + * *connection-scoped* log stream: a background interval pushing unsolicited + * `notifications/message` at whatever level the client selected with + * `logging/setLevel`. + * + * 2026-07-28 removed both halves of that. `logging/setLevel` is gone -- the log + * level is now a per-request `io.modelcontextprotocol/logLevel` key in `_meta` + * -- and a server MUST NOT emit `notifications/message` for a request that did + * not carry it. There is no connection-level channel to stream logs on, so + * there is nothing for a background interval to write to. + * + * The failure is quiet rather than loud: `sendLoggingMessage` filters the + * message out instead of rejecting, so the interval keeps running harmlessly + * and the server stays healthy (verified on both eras). A modern client simply + * never receives anything, which is why the tool's description says so up front. + * + * The era-agnostic equivalent would be a request-scoped burst via + * `ctx.mcpReq.log()` during the call, which works on both eras but demonstrates + * something different from a background stream. + * * @param {McpServer} server - The McpServer instance where the tool will be registered. */ export const registerToggleSimulatedLoggingTool = (server: McpServer) => { diff --git a/src/everything/tools/toggle-subscriber-updates.ts b/src/everything/tools/toggle-subscriber-updates.ts index 759ec20e51..f127fb605b 100644 --- a/src/everything/tools/toggle-subscriber-updates.ts +++ b/src/everything/tools/toggle-subscriber-updates.ts @@ -1,5 +1,5 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult } from "@modelcontextprotocol/server"; import { beginSimulatedResourceUpdates, stopSimulatedResourceUpdates, diff --git a/src/everything/tools/trigger-elicitation-request-async.ts b/src/everything/tools/trigger-elicitation-request-async.ts deleted file mode 100644 index d6e61b7bf7..0000000000 --- a/src/everything/tools/trigger-elicitation-request-async.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; - -// Tool configuration -const name = "trigger-elicitation-request-async"; -const config = { - title: "Trigger Async Elicitation Request Tool", - description: - "Trigger an async elicitation request that the CLIENT executes as a background task. " + - "Demonstrates bidirectional MCP tasks where the server sends an elicitation request and " + - "the client handles user input asynchronously, allowing the server to poll for completion.", - inputSchema: {}, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, - }, -}; - -// Poll interval in milliseconds -const POLL_INTERVAL = 1000; - -// Maximum poll attempts before timeout (10 minutes for user input) -const MAX_POLL_ATTEMPTS = 600; - -/** - * Registers the 'trigger-elicitation-request-async' tool. - * - * This tool demonstrates bidirectional MCP tasks for elicitation: - * - Server sends elicitation request to client with task metadata - * - Client creates a task and returns CreateTaskResult - * - Client prompts user for input (task status: input_required) - * - Server polls client's tasks/get endpoint for status - * - Server fetches final result from client's tasks/result endpoint - * - * @param {McpServer} server - The McpServer instance where the tool will be registered. - */ -export const registerTriggerElicitationRequestAsyncTool = ( - server: McpServer -) => { - // Check client capabilities - const clientCapabilities = server.server.getClientCapabilities() || {}; - - // Client must support elicitation AND tasks.requests.elicitation - const clientSupportsElicitation = - clientCapabilities.elicitation !== undefined; - const clientTasksCapability = clientCapabilities.tasks as - | { - requests?: { elicitation?: { create?: object } }; - } - | undefined; - const clientSupportsAsyncElicitation = - clientTasksCapability?.requests?.elicitation?.create !== undefined; - - if (clientSupportsElicitation && clientSupportsAsyncElicitation) { - server.registerTool( - name, - config, - async (args, extra): Promise => { - // Create the elicitation request WITH task metadata - // Using z.any() schema to avoid complex type matching with _meta - const request = { - method: "elicitation/create" as const, - params: { - task: { - ttl: 600000, // 10 minutes (user input may take a while) - }, - message: - "Please provide inputs for the following fields (async task demo):", - requestedSchema: { - type: "object" as const, - properties: { - name: { - title: "Your Name", - type: "string" as const, - description: "Your full name", - }, - favoriteColor: { - title: "Favorite Color", - type: "string" as const, - description: "What is your favorite color?", - enum: ["Red", "Blue", "Green", "Yellow", "Purple"], - }, - agreeToTerms: { - title: "Terms Agreement", - type: "boolean" as const, - description: "Do you agree to the terms and conditions?", - }, - }, - required: ["name"], - }, - }, - }; - - // Send the elicitation request - // Client may return either: - // - ElicitResult (synchronous execution) - // - CreateTaskResult (task-based execution with { task } object) - const elicitResponse = await extra.sendRequest( - request as Parameters[0], - z.union([ - // CreateTaskResult - client created a task - z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - pollInterval: z.number().optional(), - statusMessage: z.string().optional(), - }), - }), - // ElicitResult - synchronous execution - z.object({ - action: z.string(), - content: z.any().optional(), - }), - ]) - ); - - // Check if client returned CreateTaskResult (has task object) - const isTaskResult = "task" in elicitResponse && elicitResponse.task; - if (!isTaskResult) { - // Client executed synchronously - return the direct response - return { - content: [ - { - type: "text", - text: `[SYNC] Client executed synchronously:\n${JSON.stringify( - elicitResponse, - null, - 2 - )}`, - }, - ], - }; - } - - const taskId = elicitResponse.task.taskId; - const statusMessages: string[] = []; - statusMessages.push(`Task created: ${taskId}`); - - // Poll for task completion - let attempts = 0; - let taskStatus = elicitResponse.task.status; - let taskStatusMessage: string | undefined; - - while ( - taskStatus !== "completed" && - taskStatus !== "failed" && - taskStatus !== "cancelled" && - attempts < MAX_POLL_ATTEMPTS - ) { - // Wait before polling - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); - attempts++; - - // Get task status from client - const pollResult = await extra.sendRequest( - { - method: "tasks/get", - params: { taskId }, - }, - z.looseObject({ - status: z.string(), - statusMessage: z.string().optional(), - }) - ); - - taskStatus = pollResult.status; - taskStatusMessage = pollResult.statusMessage; - - // Only log status changes or every 10 polls to avoid spam - if ( - attempts === 1 || - attempts % 10 === 0 || - taskStatus !== "input_required" - ) { - statusMessages.push( - `Poll ${attempts}: ${taskStatus}${ - taskStatusMessage ? ` - ${taskStatusMessage}` : "" - }` - ); - } - } - - // Check for timeout - if (attempts >= MAX_POLL_ATTEMPTS) { - return { - content: [ - { - type: "text", - text: `[TIMEOUT] Task timed out after ${MAX_POLL_ATTEMPTS} poll attempts\n\nProgress:\n${statusMessages.join( - "\n" - )}`, - }, - ], - }; - } - - // Check for failure/cancellation - if (taskStatus === "failed" || taskStatus === "cancelled") { - return { - content: [ - { - type: "text", - text: `[${taskStatus.toUpperCase()}] ${ - taskStatusMessage || "No message" - }\n\nProgress:\n${statusMessages.join("\n")}`, - }, - ], - }; - } - - // Fetch the final result - const result = await extra.sendRequest( - { - method: "tasks/result", - params: { taskId }, - }, - z.any() - ); - - // Format the elicitation result - const content: CallToolResult["content"] = []; - - if (result.action === "accept" && result.content) { - content.push({ - type: "text", - text: `[COMPLETED] User provided the requested information!`, - }); - - const userData = result.content as Record; - const lines = []; - if (userData.name) lines.push(`- Name: ${userData.name}`); - if (userData.favoriteColor) - lines.push(`- Favorite Color: ${userData.favoriteColor}`); - if (userData.agreeToTerms !== undefined) - lines.push(`- Agreed to terms: ${userData.agreeToTerms}`); - - content.push({ - type: "text", - text: `User inputs:\n${lines.join("\n")}`, - }); - } else if (result.action === "decline") { - content.push({ - type: "text", - text: `[DECLINED] User declined to provide the requested information.`, - }); - } else if (result.action === "cancel") { - content.push({ - type: "text", - text: `[CANCELLED] User cancelled the elicitation dialog.`, - }); - } - - // Include progress and raw result for debugging - content.push({ - type: "text", - text: `\nProgress:\n${statusMessages.join( - "\n" - )}\n\nRaw result: ${JSON.stringify(result, null, 2)}`, - }); - - return { content }; - } - ); - } -}; diff --git a/src/everything/tools/trigger-elicitation-request.ts b/src/everything/tools/trigger-elicitation-request.ts index ca4742141e..a16ae6d82a 100644 --- a/src/everything/tools/trigger-elicitation-request.ts +++ b/src/everything/tools/trigger-elicitation-request.ts @@ -1,8 +1,10 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { - ElicitResultSchema, + McpServer, CallToolResult, -} from "@modelcontextprotocol/sdk/types.js"; + InputRequiredResult, + inputRequired, + inputResponse, +} from "@modelcontextprotocol/server"; // Tool configuration const name = "trigger-elicitation-request"; @@ -18,40 +20,47 @@ const config = { }, }; +// Key for this tool's embedded elicitation request. It identifies the request +// on the way out and its response on the way back in. +const PROFILE = "profile"; + /** * Registers the 'trigger-elicitation-request' tool. * - * If the client does not support the elicitation capability, the tool is not registered. + * The tool asks the user to fill in a form covering the full range of field + * types the elicitation schema supports -- text, booleans, numbers, email, + * dates, and enums of several shapes -- then formats the answer, handling + * acceptance, decline and cancellation. * - * The registered tool sends an elicitation request for the user to provide information - * based on a pre-defined schema of fields including text inputs, booleans, numbers, - * email, dates, enums of various types, etc. It uses validation and handles multiple - * possible outcomes from the user's response, such as acceptance with content, decline, - * or cancellation of the dialog. The process also ensures parsing and validating - * the elicitation input arguments at runtime. + * The request is made in the **multi-round-trip** style: the handler *returns* + * `inputRequired(...)` rather than pushing a server->client + * `elicitation/create` request. This is written once and serves both protocol + * eras. On 2026-07-28 the client fulfils the embedded request and retries the + * call with `inputResponses`; on a legacy-era connection the SDK's legacy shim + * turns the same return into a real server->client request over the live + * session and re-enters this handler with the collected response. The handler + * cannot tell which era served it. * - * The elicitation dialog response is returned, formatted into a structured result, - * which contains both user-submitted input data (if provided) and debugging information, - * including raw results. + * There is also no longer a client-capability check around registration. The + * SDK gates the embedded request at dispatch on both eras, refusing a caller + * that never declared `elicitation` with `-32021`, so the tool can be + * registered unconditionally -- which matters because on 2026-07-28 there is no + * `initialize` handshake to learn capabilities from in the first place. * - * @param {McpServer} server - TThe McpServer instance where the tool will be registered. + * @param {McpServer} server - The McpServer instance where the tool will be registered. */ export const registerTriggerElicitationRequestTool = (server: McpServer) => { - // Does the client support elicitation? - const clientCapabilities = server.server.getClientCapabilities() || {}; - const clientSupportsElicitation: boolean = - clientCapabilities.elicitation !== undefined; + server.registerTool( + name, + config, + async (args, ctx): Promise => { + const answer = inputResponse(ctx.mcpReq.inputResponses, PROFILE); - // If so, register tool - if (clientSupportsElicitation) { - server.registerTool( - name, - config, - async (args, extra): Promise => { - const elicitationResult = await extra.sendRequest( - { - method: "elicitation/create", - params: { + // First round: nothing has been asked yet, so ask. + if (answer.kind === "missing") { + return inputRequired({ + inputRequests: { + [PROFILE]: inputRequired.elicit({ message: "Please provide inputs for the following fields:", requestedSchema: { type: "object", @@ -171,65 +180,63 @@ export const registerTriggerElicitationRequestTool = (server: McpServer) => { }, required: ["name"], }, - }, + }), }, - ElicitResultSchema, - { timeout: 10 * 60 * 1000 /* 10 minutes */ } - ); - - // Handle different response actions - const content: CallToolResult["content"] = []; + }); + } - if ( - elicitationResult.action === "accept" && - elicitationResult.content - ) { - content.push({ - type: "text", - text: `✅ User provided the requested information!`, - }); + // Re-entry: the client answered. `answer` is the discriminated view of + // this round's response, which covers decline/cancel as well as accept. + const content: CallToolResult["content"] = []; - // Only access elicitationResult.content when action is accept - const userData = elicitationResult.content; - const lines = []; - if (userData.name) lines.push(`- Name: ${userData.name}`); - if (userData.check !== undefined) - lines.push(`- Agreed to terms: ${userData.check}`); - if (userData.color) lines.push(`- Favorite Color: ${userData.color}`); - if (userData.email) lines.push(`- Email: ${userData.email}`); - if (userData.homepage) lines.push(`- Homepage: ${userData.homepage}`); - if (userData.birthdate) - lines.push(`- Birthdate: ${userData.birthdate}`); - if (userData.integer !== undefined) - lines.push(`- Favorite Integer: ${userData.integer}`); - if (userData.number !== undefined) - lines.push(`- Favorite Number: ${userData.number}`); - if (userData.petType) lines.push(`- Pet Type: ${userData.petType}`); + if (answer.kind === "elicit" && answer.action === "accept") { + content.push({ + type: "text", + text: `✅ User provided the requested information!`, + }); - content.push({ - type: "text", - text: `User inputs:\n${lines.join("\n")}`, - }); - } else if (elicitationResult.action === "decline") { - content.push({ - type: "text", - text: `❌ User declined to provide the requested information.`, - }); - } else if (elicitationResult.action === "cancel") { - content.push({ - type: "text", - text: `⚠️ User cancelled the elicitation dialog.`, - }); - } + // Content only exists on an accepted elicitation. It comes from the + // client and is NOT re-validated against `requestedSchema` on either + // era -- treat it as untrusted input. + const userData = answer.content ?? {}; + const lines = []; + if (userData.name) lines.push(`- Name: ${userData.name}`); + if (userData.check !== undefined) + lines.push(`- Agreed to terms: ${userData.check}`); + if (userData.color) lines.push(`- Favorite Color: ${userData.color}`); + if (userData.email) lines.push(`- Email: ${userData.email}`); + if (userData.homepage) lines.push(`- Homepage: ${userData.homepage}`); + if (userData.birthdate) + lines.push(`- Birthdate: ${userData.birthdate}`); + if (userData.integer !== undefined) + lines.push(`- Favorite Integer: ${userData.integer}`); + if (userData.number !== undefined) + lines.push(`- Favorite Number: ${userData.number}`); + if (userData.petType) lines.push(`- Pet Type: ${userData.petType}`); - // Include raw result for debugging content.push({ type: "text", - text: `\nRaw result: ${JSON.stringify(elicitationResult, null, 2)}`, + text: `User inputs:\n${lines.join("\n")}`, + }); + } else if (answer.kind === "elicit" && answer.action === "decline") { + content.push({ + type: "text", + text: `❌ User declined to provide the requested information.`, + }); + } else if (answer.kind === "elicit" && answer.action === "cancel") { + content.push({ + type: "text", + text: `⚠️ User cancelled the elicitation dialog.`, }); - - return { content }; } - ); - } + + // Include raw result for debugging + content.push({ + type: "text", + text: `\nRaw result: ${JSON.stringify(answer, null, 2)}`, + }); + + return { content }; + } + ); }; diff --git a/src/everything/tools/trigger-long-running-operation.ts b/src/everything/tools/trigger-long-running-operation.ts index 95415e88e2..2b53068734 100644 --- a/src/everything/tools/trigger-long-running-operation.ts +++ b/src/everything/tools/trigger-long-running-operation.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CallToolResult } from "@modelcontextprotocol/server"; // Tool input schema const TriggerLongRunningOperationSchema = z.object({ @@ -43,11 +43,11 @@ export const registerTriggerLongRunningOperationTool = (server: McpServer) => { server.registerTool( name, config, - async (args, extra): Promise => { + async (args, ctx): Promise => { const validatedArgs = TriggerLongRunningOperationSchema.parse(args); const { duration, steps } = validatedArgs; const stepDuration = duration / steps; - const progressToken = extra._meta?.progressToken; + const progressToken = ctx.mcpReq._meta?.progressToken; for (let i = 1; i < steps + 1; i++) { await new Promise((resolve) => @@ -55,17 +55,18 @@ export const registerTriggerLongRunningOperationTool = (server: McpServer) => { ); if (progressToken !== undefined) { - await server.server.notification( - { - method: "notifications/progress", - params: { - progress: i, - total: steps, - progressToken, - }, + // `ctx.mcpReq.notify` binds the notification to the originating + // request, which is what both eras need: on 2026-07-28, + // request-scoped notifications flow on that request's own response + // stream rather than a standalone one. + await ctx.mcpReq.notify({ + method: "notifications/progress", + params: { + progress: i, + total: steps, + progressToken, }, - { relatedRequestId: extra.requestId } - ); + }); } } diff --git a/src/everything/tools/trigger-sampling-request-async.ts b/src/everything/tools/trigger-sampling-request-async.ts deleted file mode 100644 index d61daddbd2..0000000000 --- a/src/everything/tools/trigger-sampling-request-async.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - CallToolResult, - CreateMessageRequest, -} from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; - -// Tool input schema -const TriggerSamplingRequestAsyncSchema = z.object({ - prompt: z.string().describe("The prompt to send to the LLM"), - maxTokens: z - .number() - .default(100) - .describe("Maximum number of tokens to generate"), -}); - -// Tool configuration -const name = "trigger-sampling-request-async"; -const config = { - title: "Trigger Async Sampling Request Tool", - description: - "Trigger an async sampling request that the CLIENT executes as a background task. " + - "Demonstrates bidirectional MCP tasks where the server sends a request and the client " + - "executes it asynchronously, allowing the server to poll for progress and results.", - inputSchema: TriggerSamplingRequestAsyncSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true, - }, -}; - -// Poll interval in milliseconds -const POLL_INTERVAL = 1000; - -// Maximum poll attempts before timeout -const MAX_POLL_ATTEMPTS = 60; - -/** - * Registers the 'trigger-sampling-request-async' tool. - * - * This tool demonstrates bidirectional MCP tasks: - * - Server sends sampling request to client with task metadata - * - Client creates a task and returns CreateTaskResult - * - Server polls client's tasks/get endpoint for status - * - Server fetches final result from client's tasks/result endpoint - * - * @param {McpServer} server - The McpServer instance where the tool will be registered. - */ -export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => { - // Check client capabilities - const clientCapabilities = server.server.getClientCapabilities() || {}; - - // Client must support sampling AND tasks.requests.sampling - const clientSupportsSampling = clientCapabilities.sampling !== undefined; - const clientTasksCapability = clientCapabilities.tasks as - | { - requests?: { sampling?: { createMessage?: object } }; - } - | undefined; - const clientSupportsAsyncSampling = - clientTasksCapability?.requests?.sampling?.createMessage !== undefined; - - if (clientSupportsSampling && clientSupportsAsyncSampling) { - server.registerTool( - name, - config, - async (args, extra): Promise => { - const validatedArgs = TriggerSamplingRequestAsyncSchema.parse(args); - const { prompt, maxTokens } = validatedArgs; - - // Create the sampling request WITH task metadata - // The params.task field signals to the client that this should be executed as a task - const request: CreateMessageRequest & { - params: { task?: { ttl: number } }; - } = { - method: "sampling/createMessage", - params: { - task: { - ttl: 300000, // 5 minutes - }, - messages: [ - { - role: "user", - content: { - type: "text", - text: `Resource ${name} context: ${prompt}`, - }, - }, - ], - systemPrompt: "You are a helpful test server.", - maxTokens, - temperature: 0.7, - }, - }; - - // Send the sampling request - // Client may return either: - // - CreateMessageResult (synchronous execution) - // - CreateTaskResult (task-based execution with { task } object) - const samplingResponse = await extra.sendRequest( - request, - z.union([ - // CreateTaskResult - client created a task - z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - pollInterval: z.number().optional(), - statusMessage: z.string().optional(), - }), - }), - // CreateMessageResult - synchronous execution - z.object({ - role: z.string(), - content: z.any(), - model: z.string(), - stopReason: z.string().optional(), - }), - ]) - ); - - // Check if client returned CreateTaskResult (has task object) - const isTaskResult = - "task" in samplingResponse && samplingResponse.task; - if (!isTaskResult) { - // Client executed synchronously - return the direct response - return { - content: [ - { - type: "text", - text: `[SYNC] Client executed synchronously:\n${JSON.stringify( - samplingResponse, - null, - 2 - )}`, - }, - ], - }; - } - - const taskId = samplingResponse.task.taskId; - const statusMessages: string[] = []; - statusMessages.push(`Task created: ${taskId}`); - - // Poll for task completion - let attempts = 0; - let taskStatus = samplingResponse.task.status; - let taskStatusMessage: string | undefined; - - while ( - taskStatus !== "completed" && - taskStatus !== "failed" && - taskStatus !== "cancelled" && - attempts < MAX_POLL_ATTEMPTS - ) { - // Wait before polling - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); - attempts++; - - // Get task status from client - const pollResult = await extra.sendRequest( - { - method: "tasks/get", - params: { taskId }, - }, - z.looseObject({ - status: z.string(), - statusMessage: z.string().optional(), - }) - ); - - taskStatus = pollResult.status; - taskStatusMessage = pollResult.statusMessage; - statusMessages.push( - `Poll ${attempts}: ${taskStatus}${ - taskStatusMessage ? ` - ${taskStatusMessage}` : "" - }` - ); - } - - // Check for timeout - if (attempts >= MAX_POLL_ATTEMPTS) { - return { - content: [ - { - type: "text", - text: `[TIMEOUT] Task timed out after ${MAX_POLL_ATTEMPTS} poll attempts\n\nProgress:\n${statusMessages.join( - "\n" - )}`, - }, - ], - }; - } - - // Check for failure/cancellation - if (taskStatus === "failed" || taskStatus === "cancelled") { - return { - content: [ - { - type: "text", - text: `[${taskStatus.toUpperCase()}] ${ - taskStatusMessage || "No message" - }\n\nProgress:\n${statusMessages.join("\n")}`, - }, - ], - }; - } - - // Fetch the final result - const result = await extra.sendRequest( - { - method: "tasks/result", - params: { taskId }, - }, - z.any() - ); - - // Return the result with status history - return { - content: [ - { - type: "text", - text: `[COMPLETED] Async sampling completed!\n\n**Progress:**\n${statusMessages.join( - "\n" - )}\n\n**Result:**\n${JSON.stringify(result, null, 2)}`, - }, - ], - }; - } - ); - } -}; diff --git a/src/everything/tools/trigger-sampling-request.ts b/src/everything/tools/trigger-sampling-request.ts index fd9b5375c4..1b55b8460e 100644 --- a/src/everything/tools/trigger-sampling-request.ts +++ b/src/everything/tools/trigger-sampling-request.ts @@ -1,9 +1,10 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { + McpServer, CallToolResult, - CreateMessageRequest, - CreateMessageResultSchema, -} from "@modelcontextprotocol/sdk/types.js"; + InputRequiredResult, + inputRequired, + inputResponse, +} from "@modelcontextprotocol/server"; import { z } from "zod"; // Tool input schema @@ -29,69 +30,87 @@ const config = { }, }; +// Key for this tool's embedded sampling request. +const COMPLETION = "completion"; + /** * Registers the 'trigger-sampling-request' tool. * - * If the client does not support the sampling capability, the tool is not registered. - * * The registered tool performs the following operations: * - Validates incoming arguments using `TriggerSamplingRequestSchema`. - * - Constructs a `sampling/createMessage` request object using provided prompt and maximum tokens. - * - Sends the request to the server for sampling. + * - Asks the client for an LLM completion using the provided prompt and token budget. * - Formats and returns the sampling result content to the client. * + * The request is made in the **multi-round-trip** style -- the handler returns + * `inputRequired(...)` instead of pushing a server->client + * `sampling/createMessage` request. Written once, it serves both eras: the + * 2026-07-28 client fulfils and retries, and the SDK's legacy shim converts the + * same return into a real server->client request for legacy-era connections. + * + * Registration is unconditional: the SDK refuses the embedded request with + * `-32021` at dispatch when the caller never declared the `sampling` + * capability, on both eras. + * * @param {McpServer} server - The McpServer instance where the tool will be registered. */ export const registerTriggerSamplingRequestTool = (server: McpServer) => { - // Does the client support sampling? - const clientCapabilities = server.server.getClientCapabilities() || {}; - const clientSupportsSampling: boolean = - clientCapabilities.sampling !== undefined; + server.registerTool( + name, + config, + async (args, ctx): Promise => { + const validatedArgs = TriggerSamplingRequestSchema.parse(args); + const { prompt, maxTokens } = validatedArgs; - // If so, register tool - if (clientSupportsSampling) { - server.registerTool( - name, - config, - async (args, extra): Promise => { - const validatedArgs = TriggerSamplingRequestSchema.parse(args); - const { prompt, maxTokens } = validatedArgs; + const answer = inputResponse(ctx.mcpReq.inputResponses, COMPLETION); - // Create the sampling request - const request: CreateMessageRequest = { - method: "sampling/createMessage", - params: { - messages: [ - { - role: "user", - content: { - type: "text", - text: `Resource ${name} context: ${prompt}`, + // First round: ask the client to run the completion. + if (answer.kind === "missing") { + return inputRequired({ + inputRequests: { + [COMPLETION]: inputRequired.createMessage({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Resource ${name} context: ${prompt}`, + }, }, - }, - ], - systemPrompt: "You are a helpful test server.", - maxTokens, - temperature: 0.7, + ], + systemPrompt: "You are a helpful test server.", + maxTokens, + temperature: 0.7, + }), }, - }; - - // Send the sampling request to the client - const result = await extra.sendRequest( - request, - CreateMessageResultSchema - ); + }); + } - // Return the result to the client + // Re-entry: the client ran the completion and retried the call. + if (answer.kind !== "sampling") { return { + isError: true, content: [ { type: "text", - text: `LLM sampling result: \n${JSON.stringify(result, null, 2)}`, + text: `Expected a sampling response for "${COMPLETION}", got "${answer.kind}".`, }, ], }; } - ); - } + + // Return the result to the client + return { + content: [ + { + type: "text", + text: `LLM sampling result: \n${JSON.stringify( + answer.result, + null, + 2 + )}`, + }, + ], + }; + } + ); }; diff --git a/src/everything/tools/trigger-url-elicitation.ts b/src/everything/tools/trigger-url-elicitation.ts index a7de878de7..ce92fad99a 100644 --- a/src/everything/tools/trigger-url-elicitation.ts +++ b/src/everything/tools/trigger-url-elicitation.ts @@ -1,11 +1,11 @@ import { randomUUID } from "node:crypto"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { + McpServer, CallToolResult, - ElicitRequestURLParams, - ElicitResultSchema, - UrlElicitationRequiredError, -} from "@modelcontextprotocol/sdk/types.js"; + InputRequiredResult, + inputRequired, + inputResponse, +} from "@modelcontextprotocol/server"; import { z } from "zod"; // Tool input schema @@ -18,17 +18,8 @@ const TriggerUrlElicitationSchema = z.object({ elicitationId: z .string() .optional() - .describe("Optional explicit elicitation ID. Defaults to a random UUID."), - errorPath: z - .boolean() - .default(false) .describe( - "Controls which elicitation mechanism is used. " + - "When false (default), sends an elicitation/create request (request path). " + - "When true, throws a UrlElicitationRequiredError (MCP error code -32042) so the client handles " + - "the URL elicitation via the error path rather than waiting for a response. " + - "To clear the error, satisfy the prerequisite and retry this call with the same arguments; the " + - "retry ignores errorPath and proceeds, so the client does not loop on the same error." + "Optional correlation ID echoed back in the result. Defaults to a random UUID." ), }); @@ -38,9 +29,8 @@ const config = { title: "Trigger URL Elicitation Tool", description: "Trigger a URL elicitation so the client can direct the user to a browser flow. " + - "Supports two mechanisms: the request path (elicitation/create, default) which awaits the user's " + - "response, and the error path (UrlElicitationRequiredError, -32042) which signals the client " + - "to handle URL elicitation via the error response. Set errorPath=true to use the error path.", + "The tool asks for the elicitation by returning an input-required result; the client " + + "opens the URL, collects the outcome, and retries the call with the response.", inputSchema: TriggerUrlElicitationSchema, annotations: { readOnlyHint: false, @@ -50,166 +40,88 @@ const config = { }, }; -/** - * Tracks requests for which an error-path prerequisite has already been issued, - * keyed by the stable inputs a client resends when it retries the original tool - * call (session + URL + caller-supplied elicitationId). - * - * When the client satisfies the prerequisite and retries the same call, the - * matching entry lets us recognize the retry, ignore `errorPath`, and proceed - * via the request path instead of re-throwing `UrlElicitationRequiredError` — - * which would otherwise loop forever (throw -> client satisfies prerequisite -> - * retry -> throw -> ...). - * - * Demo simplification: entries are only removed on a recognized retry, so a - * client that triggers the error path and never retries leaves its key behind. - * That is acceptable for this reference server; a production implementation - * serving many long-lived sessions should evict entries (e.g. a - * `Map` with TTL-based cleanup). - */ -const issuedErrorPathElicitations = new Set(); - -/** - * Test-only helper to reset the module-level error-path state between cases. - * Not part of the tool's public behavior. - */ -export const __resetIssuedErrorPathElicitations = () => - issuedErrorPathElicitations.clear(); +// Key for this tool's embedded URL elicitation request. +const BROWSER_FLOW = "browserFlow"; /** * Registers the 'trigger-url-elicitation' tool. * - * This tool only registers when the client advertises URL-mode elicitation - * capability (clientCapabilities.elicitation.url). + * Sends a URL-mode elicitation asking the user to open a link, then reports + * whether they completed, declined, or cancelled the flow. + * + * The request is made in the **multi-round-trip** style: the handler returns + * `inputRequired({ inputRequests: { …: inputRequired.elicitUrl(…) } })`. On + * 2026-07-28 URL elicitation rides the MRTR flow natively. On a legacy-era + * connection the SDK's legacy shim issues a real `elicitation/create` with a + * synthesized `elicitationId` (the 2025-11-25 wire requires one; the 2026 + * in-band shape has none) and re-enters this handler with the response. * - * Depending on the `errorPath` argument it either: - * - Sends an `elicitation/create` request and awaits the result (request path), or - * - Throws a `UrlElicitationRequiredError` (MCP error -32042) carrying a - * prerequisite elicitation for the client to handle (error path). When the - * client satisfies the prerequisite and retries the same call, the retry - * ignores `errorPath` and proceeds via the request path, so the client does - * not loop on the same error. + * ## What was removed, and why + * + * The v1 version of this tool also demonstrated an **error path**: throwing + * `UrlElicitationRequiredError` to produce a `-32042` protocol error carrying a + * prerequisite elicitation, plus a module-level `Set` keyed on session id that + * suppressed a re-throw when the client retried. Both are gone: + * + * - `-32042` is legacy-era only. On a 2026-07-28 request the SDK refuses the + * throw outright and steers to `inputRequired.elicitUrl(...)` rather than + * converting it silently, so the error path cannot be written era-agnostically. + * - The retry-suppression `Set` was keyed on `sessionId`, and 2026-07-28 has no + * sessions. The modern equivalent is `requestState` -- opaque server-minted + * state echoed back on the retry -- which the MRTR flow below gets for free + * from the SDK's round tracking, with no hand-rolled dedupe needed. * * @param {McpServer} server - The McpServer instance where the tool will be registered. */ export const registerTriggerUrlElicitationTool = (server: McpServer) => { - const clientCapabilities = server.server.getClientCapabilities() || {}; - const clientElicitationCapabilities = clientCapabilities.elicitation as - | { - url?: object; - } - | undefined; - - const clientSupportsUrlElicitation = - clientElicitationCapabilities?.url !== undefined; - - if (clientSupportsUrlElicitation) { - server.registerTool( - name, - config, - async (args, extra): Promise => { - const { - url, - message, - elicitationId: requestedElicitationId, - errorPath, - } = args; - - const elicitationId = requestedElicitationId ?? randomUUID(); - const sessionId = extra.sessionId ?? "default"; - - // Key the one-shot error-path marker on inputs the client resends - // verbatim when it retries the original tool call. A real client retries - // with the *same* arguments and does NOT echo the prerequisite's - // (server-generated) elicitationId, so we must key on stable inputs: - // the session, the requested URL, and the caller-supplied elicitationId - // (if any). Keying on the resolved/random elicitationId would change on - // every call and never match, re-throwing the prerequisite forever. - const errorPathKey = `${sessionId}\u0000${url}\u0000${requestedElicitationId ?? ""}`; - - const elicitationParams: ElicitRequestURLParams = { - mode: "url", - url, - message, - elicitationId, - }; - - // Error path: signal the client via UrlElicitationRequiredError (-32042) - // so it handles a prerequisite URL elicitation before this request can - // proceed. Two things keep the client from looping forever: - // - // 1. The prerequisite points at a *different* URL than the one that - // failed. Reusing the original `url` would make the client complete - // the prerequisite, retry, and hit the same -32042 error endlessly. - // 2. We remember that we issued a prerequisite for this request. When - // the client satisfies it and retries the same call, we recognize - // the retry, *ignore* errorPath, and fall through to the request - // path. Without this, the retry would re-enter the error path and - // re-request the prerequisite URL — another loop. - if (errorPath) { - if (issuedErrorPathElicitations.has(errorPathKey)) { - // Retry of a satisfied prerequisite: clear the one-shot marker and - // ignore errorPath, falling through to the request path below. - issuedErrorPathElicitations.delete(errorPathKey); - } else { - // Originating call: record that we issued a prerequisite for this - // request, then signal the client via -32042. - issuedErrorPathElicitations.add(errorPathKey); - const prerequisiteElicitation: ElicitRequestURLParams = { - mode: "url", - url: "https://modelcontextprotocol.io", - message: - "Open this link to satisfy the prerequisite, then retry the request.", - elicitationId: randomUUID(), - }; - throw new UrlElicitationRequiredError( - [prerequisiteElicitation], - "This request requires browser-based authorization." - ); - } - } - - // Request path: send elicitation/create and await the user's response - const elicitationResult = await extra.sendRequest( - { - method: "elicitation/create", - params: elicitationParams, + server.registerTool( + name, + config, + async (args, ctx): Promise => { + const { url, message, elicitationId: requestedElicitationId } = args; + const elicitationId = requestedElicitationId ?? randomUUID(); + + const answer = inputResponse(ctx.mcpReq.inputResponses, BROWSER_FLOW); + + // First round: ask the client to take the user through the browser flow. + if (answer.kind === "missing") { + return inputRequired({ + inputRequests: { + [BROWSER_FLOW]: inputRequired.elicitUrl({ url, message }), }, - ElicitResultSchema, - { timeout: 10 * 60 * 1000 /* 10 minutes */ } - ); - - // Handle different response actions - const content: CallToolResult["content"] = []; + }); + } - if (elicitationResult.action === "accept") { - content.push({ - type: "text", - text: - `✅ User completed the URL elicitation flow.\n` + - `Elicitation ID: ${elicitationId}\n` + - `URL: ${url}`, - }); - } else if (elicitationResult.action === "decline") { - content.push({ - type: "text", - text: `❌ User declined to open the URL (Elicitation ID: ${elicitationId}).`, - }); - } else if (elicitationResult.action === "cancel") { - content.push({ - type: "text", - text: `⚠️ User cancelled the URL elicitation (Elicitation ID: ${elicitationId}).`, - }); - } + // Re-entry: the client reports how the flow ended. + const content: CallToolResult["content"] = []; - // Include raw result for debugging + if (answer.kind === "elicit" && answer.action === "accept") { content.push({ type: "text", - text: `\nRaw result: ${JSON.stringify(elicitationResult, null, 2)}`, + text: + `✅ User completed the URL elicitation flow.\n` + + `Elicitation ID: ${elicitationId}\n` + + `URL: ${url}`, + }); + } else if (answer.kind === "elicit" && answer.action === "decline") { + content.push({ + type: "text", + text: `❌ User declined to open the URL (Elicitation ID: ${elicitationId}).`, + }); + } else if (answer.kind === "elicit" && answer.action === "cancel") { + content.push({ + type: "text", + text: `⚠️ User cancelled the URL elicitation (Elicitation ID: ${elicitationId}).`, }); - - return { content }; } - ); - } + + // Include raw result for debugging + content.push({ + type: "text", + text: `\nRaw result: ${JSON.stringify(answer, null, 2)}`, + }); + + return { content }; + } + ); }; diff --git a/src/everything/transports/sse.ts b/src/everything/transports/sse.ts index 2406db7cfa..ceef33b706 100644 --- a/src/everything/transports/sse.ts +++ b/src/everything/transports/sse.ts @@ -1,9 +1,18 @@ -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { SSEServerTransport } from "@modelcontextprotocol/server-legacy/sse"; import express from "express"; -import { createServer } from "../server/index.js"; import cors from "cors"; +import { createServer, cleanupSession } from "../server/index.js"; -console.error("Starting SSE server..."); +/** + * The deprecated HTTP+SSE transport (protocol revision 2024-11-05). + * + * This transport is **legacy-era only** -- it predates Streamable HTTP and has no + * 2026-07-28 equivalent. It is served here from + * `@modelcontextprotocol/server-legacy`, a frozen copy of the v1 transport kept + * for migration only. New clients should use `streamableHttp`, which serves + * both eras from one endpoint. + */ +console.error("Starting SSE server (deprecated, legacy-era only)..."); // Express app with permissive CORS for testing with Inspector direct connect mode const app = express(); @@ -25,7 +34,6 @@ const transports: Map = new Map< // Handle GET requests for new SSE streams app.get("/sse", async (req, res) => { let transport: SSEServerTransport; - const { server, cleanup } = createServer(); // Session Id should not exist for GET /sse requests if (req?.query?.sessionId) { @@ -36,6 +44,10 @@ app.get("/sse", async (req, res) => { transport.sessionId ); } else { + // This transport only ever serves the legacy era, so the factory is built + // with an explicit legacy construction context. + const server = createServer({ era: "legacy" }); + // Create and store transport for the new session transport = new SSEServerTransport("/message", res); transports.set(transport.sessionId, transport); @@ -50,7 +62,7 @@ app.get("/sse", async (req, res) => { const sessionId = transport.sessionId; console.error("Client Disconnected: ", sessionId); transports.delete(sessionId); - cleanup(sessionId); + cleanupSession(sessionId); }; } }); diff --git a/src/everything/transports/stdio.ts b/src/everything/transports/stdio.ts index 3e653bcf4d..a281704159 100644 --- a/src/everything/transports/stdio.ts +++ b/src/everything/transports/stdio.ts @@ -1,33 +1,30 @@ #!/usr/bin/env node -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { createServer } from "../server/index.js"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import { createServer, cleanupSession } from "../server/index.js"; console.error("Starting default (STDIO) server..."); /** - * The main method - * - Initializes the StdioServerTransport, sets up the server, - * - Handles cleanup on process exit. + * Serve MCP over stdio, both protocol eras. * - * @return {Promise} A promise that resolves when the main function has executed and the process exits. + * `serveStdio` owns the era decision for the connection: the opening exchange + * selects it (a `server/discover` probe means 2026-07-28, an `initialize` + * handshake means the legacy era), one instance from the factory is pinned for + * the connection lifetime, and everything after passes straight through. Pass + * `{ legacy: "reject" }` to refuse legacy-era openings. + * + * This replaces the v1 `server.connect(new StdioServerTransport())` wiring, + * which can only ever speak the legacy era. */ -async function main(): Promise { - const transport = new StdioServerTransport(); - const { server, cleanup } = createServer(); - - // Connect transport to server - await server.connect(transport); - - // Cleanup on exit - process.on("SIGINT", async () => { - await server.close(); - cleanup(); - process.exit(0); - }); -} +const handle = serveStdio((ctx) => createServer(ctx), { + onerror: (error) => console.error("Server error:", error), +}); -main().catch((error) => { - console.error("Server error:", error); - process.exit(1); +// Cleanup on exit. stdio has no session id, so the process-level simulation +// state is keyed under `undefined`. +process.on("SIGINT", async () => { + await handle.close(); + cleanupSession(); + process.exit(0); }); diff --git a/src/everything/transports/streamableHttp.ts b/src/everything/transports/streamableHttp.ts index 2e79abc554..829f09c386 100644 --- a/src/everything/transports/streamableHttp.ts +++ b/src/everything/transports/streamableHttp.ts @@ -1,42 +1,39 @@ -import { - StreamableHTTPServerTransport, - EventStore, -} from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import express, { Request, Response } from "express"; -import { createServer } from "../server/index.js"; -import { randomUUID } from "node:crypto"; +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { toNodeHandler } from "@modelcontextprotocol/node"; +import express from "express"; import cors from "cors"; +import { createServer } from "../server/index.js"; +import { setBusNotifier } from "../server/notifier.js"; -// Simple in-memory event store for SSE resumability -class InMemoryEventStore implements EventStore { - private events: Map = - new Map(); - - async storeEvent(streamId: string, message: unknown): Promise { - const eventId = randomUUID(); - this.events.set(eventId, { streamId, message }); - return eventId; - } - - async replayEventsAfter( - lastEventId: string, - { send }: { send: (eventId: string, message: unknown) => Promise } - ): Promise { - const entries = Array.from(this.events.entries()); - const startIndex = entries.findIndex(([id]) => id === lastEventId); - if (startIndex === -1) return lastEventId; +console.log("Starting Streamable HTTP server..."); - let lastId: string = lastEventId; - for (let i = startIndex + 1; i < entries.length; i++) { - const [eventId, { message }] = entries[i]; - await send(eventId, message); - lastId = eventId; - } - return lastId; - } -} +/** + * Serve MCP over Streamable HTTP, both protocol eras, from one endpoint. + * + * `createMcpHandler` serves 2026-07-28 per request and -- on its default + * `legacy: "stateless"` posture -- also serves legacy-era traffic per request + * through the established stateless idiom, from the same factory. + * + * This replaces the v1 wiring, which is gone in its entirety: + * + * - the `Map` and the + * `mcp-session-id` plumbing: 2026-07-28 removed protocol-level sessions, + * and the legacy leg is stateless per request; + * - the `InMemoryEventStore` and `Last-Event-ID` replay: 2026-07-28 removed + * SSE resumability outright (a broken stream loses the in-flight request and + * the client re-issues it with a fresh id); + * - the hand-rolled GET and DELETE routes: GET was the 2025 standalone + * notification stream, replaced by `subscriptions/listen`, which the handler + * answers itself. The handler returns `405` for GET/DELETE on the legacy leg. + */ +const handler = createMcpHandler((ctx) => createServer(ctx), { + onerror: (error) => console.error("MCP handler error:", error), +}); -console.log("Starting Streamable HTTP server..."); +// Change notifications published by tools have to reach the handler's +// `subscriptions/listen` streams. Per-request instances have no long-lived +// stream of their own, so the publish path is the handler's bus. +setBusNotifier(handler.notify); // Express app with permissive CORS for testing with Inspector direct connect mode const app = express(); @@ -46,165 +43,25 @@ app.use( methods: "GET,POST,DELETE", preflightContinue: false, optionsSuccessStatus: 204, + // `mcp-session-id` and `last-event-id` are legacy-era only and are kept so + // this endpoint stays usable by legacy clients. `mcp-protocol-version` is + // read on both eras. exposedHeaders: ["mcp-session-id", "last-event-id", "mcp-protocol-version"], }) ); -// Map sessionId to server transport for each client -const transports: Map = new Map< - string, - StreamableHTTPServerTransport ->(); - -// Handle POST requests for client messages -app.post("/mcp", async (req: Request, res: Response) => { - console.log("Received MCP POST request"); - try { - // Check for existing session ID - const sessionId = req.headers["mcp-session-id"] as string | undefined; - - let transport: StreamableHTTPServerTransport; - - if (sessionId && transports.has(sessionId)) { - // Reuse existing transport - transport = transports.get(sessionId)!; - } else if (!sessionId) { - const { server, cleanup } = createServer(); - - // New initialization request - const eventStore = new InMemoryEventStore(); - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, // Enable resumability - onsessioninitialized: (sessionId: string) => { - // Store the transport by session ID when a session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports.set(sessionId, transport); - }, - }); - - // Set up onclose handler to clean up transport when closed - server.server.onclose = async () => { - const sid = transport.sessionId; - if (sid && transports.has(sid)) { - console.log( - `Transport closed for session ${sid}, removing from transports map` - ); - transports.delete(sid); - cleanup(sid); - } - }; - - // Connect the transport to the MCP server BEFORE handling the request - // so responses can flow back through the same transport - await server.connect(transport); - await transport.handleRequest(req, res); - return; - } else { - // Invalid request - no session ID or not initialization request - res.status(400).json({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Bad Request: No valid session ID provided", - }, - id: req?.body?.id, - }); - return; - } - - // Handle the request with existing transport - no need to reconnect - // The existing transport is already connected to the server - await transport.handleRequest(req, res); - } catch (error) { - console.log("Error handling MCP request:", error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: "2.0", - error: { - code: -32603, - message: "Internal server error", - }, - id: req?.body?.id, - }); - return; - } - } -}); - -// Handle GET requests for SSE streams -app.get("/mcp", async (req: Request, res: Response) => { - console.log("Received MCP GET request"); - const sessionId = req.headers["mcp-session-id"] as string | undefined; - if (!sessionId || !transports.has(sessionId)) { - res.status(400).json({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Bad Request: No valid session ID provided", - }, - id: req?.body?.id, - }); - return; - } - - // Check for Last-Event-ID header for resumability - const lastEventId = req.headers["last-event-id"] as string | undefined; - if (lastEventId) { - console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); - } else { - console.log(`Establishing new SSE stream for session ${sessionId}`); - } - - const transport = transports.get(sessionId); - await transport!.handleRequest(req, res); -}); - -// Handle DELETE requests for session termination -app.delete("/mcp", async (req: Request, res: Response) => { - const sessionId = req.headers["mcp-session-id"] as string | undefined; - if (!sessionId || !transports.has(sessionId)) { - res.status(400).json({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Bad Request: No valid session ID provided", - }, - id: req?.body?.id, - }); - return; - } - - console.log(`Received session termination request for session ${sessionId}`); - - try { - const transport = transports.get(sessionId); - await transport!.handleRequest(req, res); - } catch (error) { - console.log("Error handling session termination:", error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: "2.0", - error: { - code: -32603, - message: "Error handling session termination", - }, - id: req?.body?.id, - }); - return; - } - } -}); +// One route, both eras, every method. The handler classifies each request +// (modern envelope vs. legacy) and routes it internally. +app.all("/mcp", toNodeHandler(handler)); // Start the server const PORT = process.env.PORT || 3001; -const server = app.listen(PORT, () => { +const httpServer = app.listen(PORT, () => { console.error(`MCP Streamable HTTP Server listening on port ${PORT}`); }); // Handle server errors -server.on("error", (err: unknown) => { +httpServer.on("error", (err: unknown) => { const code = typeof err === "object" && err !== null && "code" in err ? (err as { code?: unknown }).code @@ -224,16 +81,11 @@ server.on("error", (err: unknown) => { process.on("SIGINT", async () => { console.log("Shutting down server..."); - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports.get(sessionId)!.close(); - transports.delete(sessionId); - } catch (error) { - console.log(`Error closing transport for session ${sessionId}:`, error); - } - } + // Aborts in-flight modern exchanges, closes their per-request instances, and + // sends the graceful-close result on every open `subscriptions/listen` stream. + await handler.close(); + setBusNotifier(undefined); + httpServer.close(); console.log("Server shutdown complete"); process.exit(0);