From ace9b62d3cb3bb3cd2ee44916132ef202aefff59 Mon Sep 17 00:00:00 2001 From: Matt <77928207+mattzcarey@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:56:48 +0000 Subject: [PATCH 1/8] fix hanging stdio servers (#1200) Co-authored-by: Johnny Santos Co-authored-by: TomasRup --- src/__fixtures__/serverThatHangs.ts | 42 +++++++ src/__fixtures__/testServer.ts | 19 +++ src/{shared => __fixtures__}/zodTestMatrix.ts | 0 src/client/stdio.ts | 47 ++++++-- src/integration-tests/process-cleanup.test.ts | 28 ----- src/integration-tests/processCleanup.test.ts | 113 ++++++++++++++++++ .../stateManagementStreamableHttp.test.ts | 2 +- .../taskResumability.test.ts | 2 +- src/server/completable.test.ts | 2 +- src/server/mcp.test.ts | 2 +- src/server/sse.test.ts | 2 +- src/server/streamableHttp.test.ts | 2 +- src/server/title.test.ts | 2 +- tsconfig.cjs.json | 2 +- tsconfig.prod.json | 2 +- 15 files changed, 220 insertions(+), 47 deletions(-) create mode 100644 src/__fixtures__/serverThatHangs.ts create mode 100644 src/__fixtures__/testServer.ts rename src/{shared => __fixtures__}/zodTestMatrix.ts (100%) delete mode 100644 src/integration-tests/process-cleanup.test.ts create mode 100644 src/integration-tests/processCleanup.test.ts diff --git a/src/__fixtures__/serverThatHangs.ts b/src/__fixtures__/serverThatHangs.ts new file mode 100644 index 0000000000..82c244aa2d --- /dev/null +++ b/src/__fixtures__/serverThatHangs.ts @@ -0,0 +1,42 @@ +import { setInterval } from 'node:timers'; +import process from 'node:process'; +import { McpServer } from '../server/mcp.js'; +import { StdioServerTransport } from '../server/stdio.js'; + +const transport = new StdioServerTransport(); + +const server = new McpServer( + { + name: 'server-that-hangs', + title: 'Test Server that hangs', + version: '1.0.0' + }, + { + capabilities: { + logging: {} + } + } +); + +await server.connect(transport); + +// Keep process alive even after stdin closes +const keepAlive = setInterval(() => {}, 60_000); + +// Prevent transport close from exiting +transport.onclose = () => { + // Intentionally ignore - we want to test the signal handling +}; + +const doNotExitImmediately = async (signal: NodeJS.Signals) => { + await server.sendLoggingMessage({ + level: 'debug', + data: `received signal ${signal}` + }); + // Clear keepalive but delay exit to simulate slow shutdown + clearInterval(keepAlive); + setInterval(() => {}, 30_000); +}; + +process.on('SIGINT', doNotExitImmediately); +process.on('SIGTERM', doNotExitImmediately); diff --git a/src/__fixtures__/testServer.ts b/src/__fixtures__/testServer.ts new file mode 100644 index 0000000000..6401d0f837 --- /dev/null +++ b/src/__fixtures__/testServer.ts @@ -0,0 +1,19 @@ +import { McpServer } from '../server/mcp.js'; +import { StdioServerTransport } from '../server/stdio.js'; + +const transport = new StdioServerTransport(); + +const server = new McpServer({ + name: 'test-server', + version: '1.0.0' +}); + +await server.connect(transport); + +const exit = async () => { + await server.close(); + process.exit(0); +}; + +process.on('SIGINT', exit); +process.on('SIGTERM', exit); diff --git a/src/shared/zodTestMatrix.ts b/src/__fixtures__/zodTestMatrix.ts similarity index 100% rename from src/shared/zodTestMatrix.ts rename to src/__fixtures__/zodTestMatrix.ts diff --git a/src/client/stdio.ts b/src/client/stdio.ts index d62a3aeb65..e488dcd241 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -91,7 +91,6 @@ export function getDefaultEnvironment(): Record { */ export class StdioClientTransport implements Transport { private _process?: ChildProcess; - private _abortController: AbortController = new AbortController(); private _readBuffer: ReadBuffer = new ReadBuffer(); private _serverParams: StdioServerParameters; private _stderrStream: PassThrough | null = null; @@ -126,18 +125,11 @@ export class StdioClientTransport implements Transport { }, stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'], shell: false, - signal: this._abortController.signal, windowsHide: process.platform === 'win32' && isElectron(), cwd: this._serverParams.cwd }); this._process.on('error', error => { - if (error.name === 'AbortError') { - // Expected when close() is called. - this.onclose?.(); - return; - } - reject(error); this.onerror?.(error); }); @@ -210,8 +202,43 @@ export class StdioClientTransport implements Transport { } async close(): Promise { - this._abortController.abort(); - this._process = undefined; + if (this._process) { + const processToClose = this._process; + this._process = undefined; + + const closePromise = new Promise(resolve => { + processToClose.once('close', () => { + resolve(); + }); + }); + + try { + processToClose.stdin?.end(); + } catch { + // ignore + } + + await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2_000).unref())]); + + if (processToClose.exitCode === null) { + try { + processToClose.kill('SIGTERM'); + } catch { + // ignore + } + + await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2_000).unref())]); + } + + if (processToClose.exitCode === null) { + try { + processToClose.kill('SIGKILL'); + } catch { + // ignore + } + } + } + this._readBuffer.clear(); } diff --git a/src/integration-tests/process-cleanup.test.ts b/src/integration-tests/process-cleanup.test.ts deleted file mode 100644 index e90ec7e24b..0000000000 --- a/src/integration-tests/process-cleanup.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Server } from '../server/index.js'; -import { StdioServerTransport } from '../server/stdio.js'; - -describe('Process cleanup', () => { - vi.setConfig({ testTimeout: 5000 }); // 5 second timeout - - it('should exit cleanly after closing transport', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: {} - } - ); - - const transport = new StdioServerTransport(); - await server.connect(transport); - - // Close the transport - await transport.close(); - - // If we reach here without hanging, the test passes - // The test runner will fail if the process hangs - expect(true).toBe(true); - }); -}); diff --git a/src/integration-tests/processCleanup.test.ts b/src/integration-tests/processCleanup.test.ts new file mode 100644 index 0000000000..7579bebdce --- /dev/null +++ b/src/integration-tests/processCleanup.test.ts @@ -0,0 +1,113 @@ +import path from 'node:path'; +import { Readable, Writable } from 'node:stream'; +import { Client } from '../client/index.js'; +import { StdioClientTransport } from '../client/stdio.js'; +import { Server } from '../server/index.js'; +import { StdioServerTransport } from '../server/stdio.js'; +import { LoggingMessageNotificationSchema } from '../types.js'; + +const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__'); + +describe('Process cleanup', () => { + vi.setConfig({ testTimeout: 5000 }); // 5 second timeout + + it('server should exit cleanly after closing transport', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: {} + } + ); + + const mockReadable = new Readable({ + read() { + this.push(null); // signal EOF + } + }), + mockWritable = new Writable({ + write(chunk, encoding, callback) { + callback(); + } + }); + + // Attach mock streams to process for the server transport + const transport = new StdioServerTransport(mockReadable, mockWritable); + await server.connect(transport); + + // Close the transport + await transport.close(); + + // ensure a proper disposal mock streams + mockReadable.destroy(); + mockWritable.destroy(); + + // If we reach here without hanging, the test passes + // The test runner will fail if the process hangs + expect(true).toBe(true); + }); + + it('onclose should be called exactly once', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StdioClientTransport({ + command: 'node', + args: ['--import', 'tsx', 'testServer.ts'], + cwd: FIXTURES_DIR + }); + + await client.connect(transport); + + let onCloseWasCalled = 0; + client.onclose = () => { + onCloseWasCalled++; + }; + + await client.close(); + + // A short delay to allow the close event to propagate + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onCloseWasCalled).toBe(1); + }); + + it('should exit cleanly for a server that hangs', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StdioClientTransport({ + command: 'node', + args: ['--import', 'tsx', 'serverThatHangs.ts'], + cwd: FIXTURES_DIR + }); + + await client.connect(transport); + await client.setLoggingLevel('debug'); + client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + console.debug('server log: ' + notification.params.data); + }); + const serverPid = transport.pid!; + + await client.close(); + + // A short delay to allow the close event to propagate + await new Promise(resolve => setTimeout(resolve, 50)); + + try { + process.kill(serverPid, 9); + throw new Error('Expected server to be dead but it is alive'); + } catch (err: unknown) { + // 'ESRCH' the process doesn't exist + if (err && typeof err === 'object' && 'code' in err && err.code === 'ESRCH') { + // success + } else throw err; + } + }); +}); diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index 3294df4d41..fe79ff9ee4 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -12,7 +12,7 @@ import { ListPromptsResultSchema, LATEST_PROTOCOL_VERSION } from '../types.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index 3c357d1717..bf0d4bc46d 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -7,7 +7,7 @@ import { McpServer } from '../server/mcp.js'; import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../types.js'; import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/src/server/completable.test.ts b/src/server/completable.test.ts index e0d2aba999..69dd67d02f 100644 --- a/src/server/completable.test.ts +++ b/src/server/completable.test.ts @@ -1,5 +1,5 @@ import { completable, getCompleter } from './completable.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('completable with $zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 7a34a08bc7..cfec318afb 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -22,7 +22,7 @@ import { import { completable } from './completable.js'; import { McpServer, ResourceTemplate } from './mcp.js'; import { InMemoryTaskStore } from '../experimental/tasks/stores/in-memory.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; function createLatch() { let latch = false; diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 304f7e8603..b95490c13a 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -6,7 +6,7 @@ import { McpServer } from './mcp.js'; import { createServer, type Server } from 'node:http'; import { AddressInfo } from 'node:net'; import { CallToolResult, JSONRPCMessage } from '../types.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; const createMockResponse = () => { const res = { diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 80ee04d679..39c2e5805f 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -5,7 +5,7 @@ import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from './ import { McpServer } from './mcp.js'; import { CallToolResult, JSONRPCMessage } from '../types.js'; import { AuthInfo } from './auth/types.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; async function getFreePort() { return new Promise(res => { diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 9eb99b992d..2af3de3c04 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -2,7 +2,7 @@ import { Server } from './index.js'; import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; import { McpServer, ResourceTemplate } from './mcp.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 3b46f11c4c..ed5f7fe3e7 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -5,5 +5,5 @@ "moduleResolution": "node", "outDir": "./dist/cjs" }, - "exclude": ["**/*.test.ts", "src/__mocks__/**/*"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*"] } diff --git a/tsconfig.prod.json b/tsconfig.prod.json index fcf2e951c1..a07311af71 100644 --- a/tsconfig.prod.json +++ b/tsconfig.prod.json @@ -3,5 +3,5 @@ "compilerOptions": { "outDir": "./dist/esm" }, - "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/server/zodTestMatrix.ts"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*"] } From 74ab69ffa05efa63f870e6cdd4b3df5437b93bdb Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Mon, 1 Dec 2025 18:04:23 +0200 Subject: [PATCH 2/8] README refactor (#1197) --- README.md | 1873 ++-------------------------------------- docs/capabilities.md | 81 ++ docs/client.md | 60 ++ docs/faq.md | 65 ++ docs/server.md | 198 +++++ src/examples/README.md | 2 +- 6 files changed, 486 insertions(+), 1793 deletions(-) create mode 100644 docs/capabilities.md create mode 100644 docs/client.md create mode 100644 docs/faq.md create mode 100644 docs/server.md diff --git a/README.md b/README.md index 628e6240dc..e0d3f200f4 100644 --- a/README.md +++ b/README.md @@ -7,29 +7,7 @@ - [Installation](#installation) - [Quick Start](#quick-start) - [Core Concepts](#core-concepts) - - [Server](#server) - - [Tools](#tools) - - [Resources](#resources) - - [Prompts](#prompts) - - [Completions](#completions) - - [Display Names and Metadata](#display-names-and-metadata) - - [Sampling](#sampling) -- [Running Your Server](#running-your-server) - - [Streamable HTTP](#streamable-http) - - [stdio](#stdio) - - [Testing and Debugging](#testing-and-debugging) - [Examples](#examples) - - [Echo Server](#echo-server) - - [SQLite Explorer](#sqlite-explorer) -- [Advanced Usage](#advanced-usage) - - [Dynamic Servers](#dynamic-servers) - - [Improving Network Efficiency with Notification Debouncing](#improving-network-efficiency-with-notification-debouncing) - - [Low-Level Server](#low-level-server) - - [Eliciting User Input](#eliciting-user-input) - - [Task-Based Execution](#task-based-execution) - - [Writing MCP Clients](#writing-mcp-clients) - - [Proxy Authorization Requests Upstream](#proxy-authorization-requests-upstream) - - [Backwards Compatibility](#backwards-compatibility) - [Documentation](#documentation) - [Contributing](#contributing) - [License](#license) @@ -55,1821 +33,132 @@ This SDK has a **required peer dependency** on `zod` for schema validation. The ## Quick Start -Let's create a simple MCP server that exposes a calculator tool and some data. Save the following as `server.ts`: +To see the SDK in action end-to-end, start from the runnable examples in `src/examples`: -```typescript -import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import express from 'express'; -import * as z from 'zod/v4'; +1. **Install dependencies** (from the SDK repo root): -// Create an MCP server -const server = new McpServer({ - name: 'demo-server', - version: '1.0.0' -}); + ```bash + npm install + ``` -// Add an addition tool -server.registerTool( - 'add', - { - title: 'Addition Tool', - description: 'Add two numbers', - inputSchema: { a: z.number(), b: z.number() }, - outputSchema: { result: z.number() } - }, - async ({ a, b }) => { - const output = { result: a + b }; - return { - content: [{ type: 'text', text: JSON.stringify(output) }], - structuredContent: output - }; - } -); +2. **Run the example Streamable HTTP server**: -// Add a dynamic greeting resource -server.registerResource( - 'greeting', - new ResourceTemplate('greeting://{name}', { list: undefined }), - { - title: 'Greeting Resource', // Display name for UI - description: 'Dynamic greeting generator' - }, - async (uri, { name }) => ({ - contents: [ - { - uri: uri.href, - text: `Hello, ${name}!` - } - ] - }) -); + ```bash + npx tsx src/examples/server/simpleStreamableHttp.ts + ``` -// Set up Express and HTTP transport -const app = express(); -app.use(express.json()); +3. **Run the interactive client in another terminal**: -app.post('/mcp', async (req, res) => { - // Create a new transport for each request to prevent request ID collisions - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true - }); + ```bash + npx tsx src/examples/client/simpleStreamableHttp.ts + ``` - res.on('close', () => { - transport.close(); - }); - - await server.connect(transport); - await transport.handleRequest(req, res, req.body); -}); - -const port = parseInt(process.env.PORT || '3000'); -app.listen(port, () => { - console.log(`Demo MCP Server running on http://localhost:${port}/mcp`); -}).on('error', error => { - console.error('Server error:', error); - process.exit(1); -}); -``` - -Install the deps with `npm install @modelcontextprotocol/sdk express zod`, and run with `npx -y tsx server.ts`. - -You can connect to it using any MCP client that supports streamable http, such as: - -- [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): `npx @modelcontextprotocol/inspector` and connect to the streamable HTTP URL `http://localhost:3000/mcp` -- [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp): `claude mcp add --transport http my-server http://localhost:3000/mcp` -- [VS Code](https://code.visualstudio.com/docs/copilot/customization/mcp-servers): `code --add-mcp "{\"name\":\"my-server\",\"type\":\"http\",\"url\":\"http://localhost:3000/mcp\"}"` -- [Cursor](https://cursor.com/docs/context/mcp): Click [this deeplink](cursor://anysphere.cursor-deeplink/mcp/install?name=my-server&config=eyJ1cmwiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAvbWNwIn0%3D) - -Then try asking your agent to add two numbers using its new tool! +This pair of examples demonstrates tools, resources, prompts, sampling, elicitation, tasks and logging. For a guided walkthrough and variations (stateless servers, JSON-only responses, SSE compatibility, OAuth, etc.), see [docs/server.md](docs/server.md) and +[docs/client.md](docs/client.md). ## Core Concepts -### Server - -The McpServer is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: - -```typescript -const server = new McpServer({ - name: 'my-app', - version: '1.0.0' -}); -``` - -### Tools - -[Tools](https://modelcontextprotocol.io/specification/draft/server/tools) let LLMs take actions through your server. Tools can perform computation, fetch data and have side effects. Tools should be designed to be model-controlled - i.e. AI models will decide which tools to call, -and the arguments. - -```typescript -// Simple tool with parameters -server.registerTool( - 'calculate-bmi', - { - title: 'BMI Calculator', - description: 'Calculate Body Mass Index', - inputSchema: { - weightKg: z.number(), - heightM: z.number() - }, - outputSchema: { bmi: z.number() } - }, - async ({ weightKg, heightM }) => { - const output = { bmi: weightKg / (heightM * heightM) }; - return { - content: [ - { - type: 'text', - text: JSON.stringify(output) - } - ], - structuredContent: output - }; - } -); - -// Async tool with external API call -server.registerTool( - 'fetch-weather', - { - title: 'Weather Fetcher', - description: 'Get weather data for a city', - inputSchema: { city: z.string() }, - outputSchema: { temperature: z.number(), conditions: z.string() } - }, - async ({ city }) => { - const response = await fetch(`https://api.weather.com/${city}`); - const data = await response.json(); - const output = { temperature: data.temp, conditions: data.conditions }; - return { - content: [{ type: 'text', text: JSON.stringify(output) }], - structuredContent: output - }; - } -); - -// Tool that returns ResourceLinks -server.registerTool( - 'list-files', - { - title: 'List Files', - description: 'List project files', - inputSchema: { pattern: z.string() }, - outputSchema: { - count: z.number(), - files: z.array(z.object({ name: z.string(), uri: z.string() })) - } - }, - async ({ pattern }) => { - const output = { - count: 2, - files: [ - { name: 'README.md', uri: 'file:///project/README.md' }, - { name: 'index.ts', uri: 'file:///project/src/index.ts' } - ] - }; - return { - content: [ - { type: 'text', text: JSON.stringify(output) }, - // ResourceLinks let tools return references without file content - { - type: 'resource_link', - uri: 'file:///project/README.md', - name: 'README.md', - mimeType: 'text/markdown', - description: 'A README file' - }, - { - type: 'resource_link', - uri: 'file:///project/src/index.ts', - name: 'index.ts', - mimeType: 'text/typescript', - description: 'An index file' - } - ], - structuredContent: output - }; - } -); -``` - -#### ResourceLinks - -Tools can return `ResourceLink` objects to reference resources without embedding their full content. This can be helpful for performance when dealing with large files or many resources - clients can then selectively read only the resources they need using the provided URIs. - -### Resources - -[Resources](https://modelcontextprotocol.io/specification/draft/server/resources) can also expose data to LLMs, but unlike tools shouldn't perform significant computation or have side effects. - -Resources are designed to be used in an application-driven way, meaning MCP client applications can decide how to expose them. For example, a client could expose a resource picker to the human, or could expose them to the model directly. - -```typescript -// Static resource -server.registerResource( - 'config', - 'config://app', - { - title: 'Application Config', - description: 'Application configuration data', - mimeType: 'text/plain' - }, - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'App configuration here' - } - ] - }) -); - -// Dynamic resource with parameters -server.registerResource( - 'user-profile', - new ResourceTemplate('users://{userId}/profile', { list: undefined }), - { - title: 'User Profile', - description: 'User profile information' - }, - async (uri, { userId }) => ({ - contents: [ - { - uri: uri.href, - text: `Profile data for user ${userId}` - } - ] - }) -); - -// Resource with context-aware completion -server.registerResource( - 'repository', - new ResourceTemplate('github://repos/{owner}/{repo}', { - list: undefined, - complete: { - // Provide intelligent completions based on previously resolved parameters - repo: (value, context) => { - if (context?.arguments?.['owner'] === 'org1') { - return ['project1', 'project2', 'project3'].filter(r => r.startsWith(value)); - } - return ['default-repo'].filter(r => r.startsWith(value)); - } - } - }), - { - title: 'GitHub Repository', - description: 'Repository information' - }, - async (uri, { owner, repo }) => ({ - contents: [ - { - uri: uri.href, - text: `Repository: ${owner}/${repo}` - } - ] - }) -); -``` - -### Prompts - -[Prompts](https://modelcontextprotocol.io/specification/draft/server/prompts) are reusable templates that help humans prompt models to interact with your server. They're designed to be user-driven, and might appear as slash commands in a chat interface. - -```typescript -import { completable } from '@modelcontextprotocol/sdk/server/completable.js'; - -server.registerPrompt( - 'review-code', - { - title: 'Code Review', - description: 'Review code for best practices and potential issues', - argsSchema: { code: z.string() } - }, - ({ code }) => ({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please review this code:\n\n${code}` - } - } - ] - }) -); - -// Prompt with context-aware completion -server.registerPrompt( - 'team-greeting', - { - title: 'Team Greeting', - description: 'Generate a greeting for team members', - argsSchema: { - department: completable(z.string(), value => { - // Department suggestions - return ['engineering', 'sales', 'marketing', 'support'].filter(d => d.startsWith(value)); - }), - name: completable(z.string(), (value, context) => { - // Name suggestions based on selected department - const department = context?.arguments?.['department']; - if (department === 'engineering') { - return ['Alice', 'Bob', 'Charlie'].filter(n => n.startsWith(value)); - } else if (department === 'sales') { - return ['David', 'Eve', 'Frank'].filter(n => n.startsWith(value)); - } else if (department === 'marketing') { - return ['Grace', 'Henry', 'Iris'].filter(n => n.startsWith(value)); - } - return ['Guest'].filter(n => n.startsWith(value)); - }) - } - }, - ({ department, name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Hello ${name}, welcome to the ${department} team!` - } - } - ] - }) -); -``` - -### Completions - -MCP supports argument completions to help users fill in prompt arguments and resource template parameters. See the examples above for [resource completions](#resources) and [prompt completions](#prompts). - -#### Client Usage - -```typescript -// Request completions for any argument -const result = await client.complete({ - ref: { - type: 'ref/prompt', // or "ref/resource" - name: 'example' // or uri: "template://..." - }, - argument: { - name: 'argumentName', - value: 'partial' // What the user has typed so far - }, - context: { - // Optional: Include previously resolved arguments - arguments: { - previousArg: 'value' - } - } -}); -``` - -### Display Names and Metadata - -All resources, tools, and prompts support an optional `title` field for better UI presentation. The `title` is used as a display name (e.g. 'Create a new issue'), while `name` remains the unique identifier (e.g. `create_issue`). - -**Note:** The `register*` methods (`registerTool`, `registerPrompt`, `registerResource`) are the recommended approach for new code. The older methods (`tool`, `prompt`, `resource`) remain available for backwards compatibility. - -#### Title Precedence for Tools - -For tools specifically, there are two ways to specify a title: - -- `title` field in the tool configuration -- `annotations.title` field (when using the older `tool()` method with annotations) - -The precedence order is: `title` → `annotations.title` → `name` - -```typescript -// Using registerTool (recommended) -server.registerTool( - 'my_tool', - { - title: 'My Tool', // This title takes precedence - annotations: { - title: 'Annotation Title' // This is ignored if title is set - } - }, - handler -); - -// Using tool with annotations (older API) -server.tool( - 'my_tool', - 'description', - { - title: 'Annotation Title' // This is used as title - }, - handler -); -``` - -When building clients, use the provided utility to get the appropriate display name: - -```typescript -import { getDisplayName } from '@modelcontextprotocol/sdk/shared/metadataUtils.js'; - -// Automatically handles the precedence: title → annotations.title → name -const displayName = getDisplayName(tool); -``` - -### Sampling - -MCP servers can request LLM completions from connected clients that support sampling. - -```typescript -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import express from 'express'; -import * as z from 'zod/v4'; - -const mcpServer = new McpServer({ - name: 'tools-with-sample-server', - version: '1.0.0' -}); - -// Tool that uses LLM sampling to summarize any text -mcpServer.registerTool( - 'summarize', - { - title: 'Text Summarizer', - description: 'Summarize any text using an LLM', - inputSchema: { - text: z.string().describe('Text to summarize') - }, - outputSchema: { summary: z.string() } - }, - async ({ text }) => { - // Call the LLM through MCP sampling - const response = await mcpServer.server.createMessage({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please summarize the following text concisely:\n\n${text}` - } - } - ], - maxTokens: 500 - }); - - const summary = response.content.type === 'text' ? response.content.text : 'Unable to generate summary'; - const output = { summary }; - return { - content: [{ type: 'text', text: JSON.stringify(output) }], - structuredContent: output - }; - } -); - -const app = express(); -app.use(express.json()); - -app.post('/mcp', async (req, res) => { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true - }); - - res.on('close', () => { - transport.close(); - }); - - await mcpServer.connect(transport); - await transport.handleRequest(req, res, req.body); -}); - -const port = parseInt(process.env.PORT || '3000'); -app.listen(port, () => { - console.log(`MCP Server running on http://localhost:${port}/mcp`); -}).on('error', error => { - console.error('Server error:', error); - process.exit(1); -}); -``` - -## Running Your Server - -MCP servers in TypeScript need to be connected to a transport to communicate with clients. How you start the server depends on the choice of transport: - -### Streamable HTTP - -For remote servers, use the Streamable HTTP transport. - -#### Without Session Management (Recommended) - -For most use cases where session management isn't needed: +### Servers and transports -```typescript -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import express from 'express'; -import * as z from 'zod/v4'; +An MCP server is typically created with `McpServer` and connected to a transport such as Streamable HTTP or stdio. The SDK supports: -const app = express(); -app.use(express.json()); +- **Streamable HTTP** for remote servers (recommended). +- **HTTP + SSE** for backwards compatibility only. +- **stdio** for local, process-spawned integrations. -// Create the MCP server once (can be reused across requests) -const server = new McpServer({ - name: 'example-server', - version: '1.0.0' -}); +Runnable server examples live under `src/examples/server` and are documented in [docs/server.md](docs/server.md). -// Set up your tools, resources, and prompts -server.registerTool( - 'echo', - { - title: 'Echo Tool', - description: 'Echoes back the provided message', - inputSchema: { message: z.string() }, - outputSchema: { echo: z.string() } - }, - async ({ message }) => { - const output = { echo: `Tool echo: ${message}` }; - return { - content: [{ type: 'text', text: JSON.stringify(output) }], - structuredContent: output - }; - } -); +### Tools, resources, prompts -app.post('/mcp', async (req, res) => { - // In stateless mode, create a new transport for each request to prevent - // request ID collisions. Different clients may use the same JSON-RPC request IDs, - // which would cause responses to be routed to the wrong HTTP connections if - // the transport state is shared. +- **Tools** let LLMs ask your server to take actions (computation, side effects, network calls). +- **Resources** expose read-only data that clients can surface to users or models. +- **Prompts** are reusable templates that help users talk to models in a consistent way. - try { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true - }); +The detailed APIs, including `ResourceTemplate`, completions, and display-name metadata, are covered in [docs/server.md](docs/server.md#tools-resources-and-prompts), with runnable implementations in [`simpleStreamableHttp.ts`](src/examples/server/simpleStreamableHttp.ts). - res.on('close', () => { - transport.close(); - }); +### Capabilities: sampling, elicitation, and tasks - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Internal server error' - }, - id: null - }); - } - } -}); +The SDK includes higher-level capabilities for richer workflows: -// Handle GET requests when session management is not supported - the server must return an HTTP 405 status code in this case -app.get('/mcp', (req, res) => { - res.status(405).end(); -}); +- **Sampling**: server-side tools can ask connected clients to run LLM completions. +- **Form elicitation**: tools can request non-sensitive input via structured forms. +- **URL elicitation**: servers can ask users to complete secure flows in a browser (e.g., API key entry, payments, OAuth). +- **Tasks (experimental)**: long-running tool calls can be turned into tasks that you poll or resume later. -const port = parseInt(process.env.PORT || '3000'); -app.listen(port, () => { - console.log(`MCP Server running on http://localhost:${port}/mcp`); -}).on('error', error => { - console.error('Server error:', error); - process.exit(1); -}); -``` - -#### With Session Management - -In some cases, servers need stateful sessions. This can be achieved by [session management](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#session-management) in the MCP protocol. - -```typescript -import express from 'express'; -import { randomUUID } from 'node:crypto'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; - -const app = express(); -app.use(express.json()); - -// Map to store transports by session ID -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; - -// Handle POST requests for client-to-server communication -app.post('/mcp', async (req, res) => { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; - let transport: StreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: sessionId => { - // Store the transport by session ID - transports[sessionId] = transport; - } - // DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server - // locally, make sure to set: - // enableDnsRebindingProtection: true, - // allowedHosts: ['127.0.0.1'], - }); - - // Clean up transport when closed - transport.onclose = () => { - if (transport.sessionId) { - delete transports[transport.sessionId]; - } - }; - const server = new McpServer({ - name: 'example-server', - version: '1.0.0' - }); - - // ... set up server resources, tools, and prompts ... - - // Connect to the MCP server - await server.connect(transport); - } else { - // Invalid request - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided' - }, - id: null - }); - return; - } - - // Handle the request - await transport.handleRequest(req, res, req.body); -}); - -// Reusable handler for GET and DELETE requests -const handleSessionRequest = async (req: express.Request, res: express.Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - return; - } - - const transport = transports[sessionId]; - await transport.handleRequest(req, res); -}; - -// Handle GET requests for server-to-client notifications via SSE -app.get('/mcp', handleSessionRequest); - -// Handle DELETE requests for session termination -app.delete('/mcp', handleSessionRequest); - -app.listen(3000); -``` - -#### CORS Configuration for Browser-Based Clients - -If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: +Conceptual overviews and links to runnable examples are in: -```typescript -import cors from 'cors'; - -// Add CORS middleware before your MCP routes -app.use( - cors({ - origin: '*', // Configure appropriately for production, for example: - // origin: ['https://your-remote-domain.com', 'https://your-other-remote-domain.com'], - exposedHeaders: ['Mcp-Session-Id'], - allowedHeaders: ['Content-Type', 'mcp-session-id'] - }) -); -``` +- [docs/capabilities.md](docs/capabilities.md) -This configuration is necessary because: +Key example servers include: -- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management -- Browsers restrict access to response headers unless explicitly exposed via CORS -- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses +- [`toolWithSampleServer.ts`](src/examples/server/toolWithSampleServer.ts) +- [`elicitationFormExample.ts`](src/examples/server/elicitationFormExample.ts) +- [`elicitationUrlExample.ts`](src/examples/server/elicitationUrlExample.ts) -#### DNS Rebinding Protection +### Clients -The Streamable HTTP transport includes DNS rebinding protection to prevent security vulnerabilities. By default, this protection is **disabled** for backwards compatibility. - -**Important**: If you are running this server locally, enable DNS rebinding protection: - -```typescript -const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - enableDnsRebindingProtection: true, - - allowedHosts: ['127.0.0.1', ...], - allowedOrigins: ['https://yourdomain.com', 'https://www.yourdomain.com'] -}); -``` +The high-level `Client` class connects to MCP servers over different transports and exposes helpers like `listTools`, `callTool`, `listResources`, `readResource`, `listPrompts`, and `getPrompt`. -### stdio +Runnable clients live under `src/examples/client` and are described in [docs/client.md](docs/client.md), including: -For local integrations spawned by another process, you can use the stdio transport: - -```typescript -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; - -const server = new McpServer({ - name: 'example-server', - version: '1.0.0' -}); - -// ... set up server resources, tools, and prompts ... - -const transport = new StdioServerTransport(); -await server.connect(transport); -``` - -### Testing and Debugging - -To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information. +- Interactive Streamable HTTP client ([`simpleStreamableHttp.ts`](src/examples/client/simpleStreamableHttp.ts)) +- Streamable HTTP client with SSE fallback ([`streamableHttpWithSseFallbackClient.ts`](src/examples/client/streamableHttpWithSseFallbackClient.ts)) +- OAuth-enabled clients and polling/parallel examples ### Node.js Web Crypto (globalThis.crypto) compatibility Some parts of the SDK (for example, JWT-based client authentication in `auth-extensions.ts` via `jose`) rely on the Web Crypto API exposed as `globalThis.crypto`. -- **Node.js v19.0.0 and later**: `globalThis.crypto` is available by default. -- **Node.js v18.x**: `globalThis.crypto` may not be defined by default; in this repository we polyfill it for tests (see `vitest.setup.ts`), and you should do the same in your app if it is missing - or alternatively, run Node with `--experimental-global-webcrypto` as per your - Node version documentation. (See https://nodejs.org/dist/latest-v18.x/docs/api/globals.html#crypto ) - -If you run tests or applications on Node.js versions where `globalThis.crypto` is missing, you can polyfill it using the built-in `node:crypto` module, similar to the SDK's own `vitest.setup.ts`: - -```typescript -import { webcrypto } from 'node:crypto'; - -if (typeof globalThis.crypto === 'undefined') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).crypto = webcrypto as unknown as Crypto; -} -``` - -For production use, you can either: - -- Run on a Node.js version where `globalThis.crypto` is available by default (recommended), or -- Apply a similar polyfill early in your application's startup code when targeting older Node.js runtimes. +See [docs/faq.md](docs/faq.md) for details on supported Node.js versions and how to polyfill `globalThis.crypto` when running on older Node.js runtimes. ## Examples -### Echo Server - -A simple server demonstrating resources, tools, and prompts: - -```typescript -import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; -import * as z from 'zod/v4'; - -const server = new McpServer({ - name: 'echo-server', - version: '1.0.0' -}); - -server.registerTool( - 'echo', - { - title: 'Echo Tool', - description: 'Echoes back the provided message', - inputSchema: { message: z.string() }, - outputSchema: { echo: z.string() } - }, - async ({ message }) => { - const output = { echo: `Tool echo: ${message}` }; - return { - content: [{ type: 'text', text: JSON.stringify(output) }], - structuredContent: output - }; - } -); - -server.registerResource( - 'echo', - new ResourceTemplate('echo://{message}', { list: undefined }), - { - title: 'Echo Resource', - description: 'Echoes back messages as resources' - }, - async (uri, { message }) => ({ - contents: [ - { - uri: uri.href, - text: `Resource echo: ${message}` - } - ] - }) -); - -server.registerPrompt( - 'echo', - { - title: 'Echo Prompt', - description: 'Creates a prompt to process a message', - argsSchema: { message: z.string() } - }, - ({ message }) => ({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please process this message: ${message}` - } - } - ] - }) -); -``` - -### SQLite Explorer - -A more complex example showing database integration: - -```typescript -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import sqlite3 from 'sqlite3'; -import { promisify } from 'util'; -import * as z from 'zod/v4'; - -const server = new McpServer({ - name: 'sqlite-explorer', - version: '1.0.0' -}); - -// Helper to create DB connection -const getDb = () => { - const db = new sqlite3.Database('database.db'); - return { - all: promisify(db.all.bind(db)), - close: promisify(db.close.bind(db)) - }; -}; - -server.registerResource( - 'schema', - 'schema://main', - { - title: 'Database Schema', - description: 'SQLite database schema', - mimeType: 'text/plain' - }, - async uri => { - const db = getDb(); - try { - const tables = await db.all("SELECT sql FROM sqlite_master WHERE type='table'"); - return { - contents: [ - { - uri: uri.href, - text: tables.map((t: { sql: string }) => t.sql).join('\n') - } - ] - }; - } finally { - await db.close(); - } - } -); - -server.registerTool( - 'query', - { - title: 'SQL Query', - description: 'Execute SQL queries on the database', - inputSchema: { sql: z.string() }, - outputSchema: { - rows: z.array(z.record(z.any())), - rowCount: z.number() - } - }, - async ({ sql }) => { - const db = getDb(); - try { - const results = await db.all(sql); - const output = { rows: results, rowCount: results.length }; - return { - content: [ - { - type: 'text', - text: JSON.stringify(output, null, 2) - } - ], - structuredContent: output - }; - } catch (err: unknown) { - const error = err as Error; - return { - content: [ - { - type: 'text', - text: `Error: ${error.message}` - } - ], - isError: true - }; - } finally { - await db.close(); - } - } -); -``` - -## Advanced Usage - -### Dynamic Servers - -If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notifications: - -```typescript -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import express from 'express'; -import * as z from 'zod/v4'; - -const server = new McpServer({ - name: 'Dynamic Example', - version: '1.0.0' -}); - -const listMessageTool = server.registerTool( - 'listMessages', - { - title: 'List Messages', - description: 'List messages in a channel', - inputSchema: { channel: z.string() }, - outputSchema: { messages: z.array(z.string()) } - }, - async ({ channel }) => { - const messages = await listMessages(channel); - const output = { messages }; - return { - content: [{ type: 'text', text: JSON.stringify(output) }], - structuredContent: output - }; - } -); - -const putMessageTool = server.registerTool( - 'putMessage', - { - title: 'Put Message', - description: 'Send a message to a channel', - inputSchema: { channel: z.string(), message: z.string() }, - outputSchema: { success: z.boolean() } - }, - async ({ channel, message }) => { - await putMessage(channel, message); - const output = { success: true }; - return { - content: [{ type: 'text', text: JSON.stringify(output) }], - structuredContent: output - }; - } -); -// Until we upgrade auth, `putMessage` is disabled (won't show up in listTools) -putMessageTool.disable(); - -const upgradeAuthTool = server.registerTool( - 'upgradeAuth', - { - title: 'Upgrade Authorization', - description: 'Upgrade user authorization level', - inputSchema: { permission: z.enum(['write', 'admin']) }, - outputSchema: { - success: z.boolean(), - newPermission: z.string() - } - }, - // Any mutations here will automatically emit `listChanged` notifications - async ({ permission }) => { - const { ok, err, previous } = await upgradeAuthAndStoreToken(permission); - if (!ok) { - return { - content: [{ type: 'text', text: `Error: ${err}` }], - isError: true - }; - } - - // If we previously had read-only access, 'putMessage' is now available - if (previous === 'read') { - putMessageTool.enable(); - } - - if (permission === 'write') { - // If we've just upgraded to 'write' permissions, we can still call 'upgradeAuth' - // but can only upgrade to 'admin'. - upgradeAuthTool.update({ - paramsSchema: { permission: z.enum(['admin']) } // change validation rules - }); - } else { - // If we're now an admin, we no longer have anywhere to upgrade to, so fully remove that tool - upgradeAuthTool.remove(); - } - - const output = { success: true, newPermission: permission }; - return { - content: [{ type: 'text', text: JSON.stringify(output) }], - structuredContent: output - }; - } -); - -// Connect with HTTP transport -const app = express(); -app.use(express.json()); - -app.post('/mcp', async (req, res) => { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true - }); - - res.on('close', () => { - transport.close(); - }); +The SDK ships runnable examples under `src/examples`. Use these tables to find the scenario you care about and jump straight to the corresponding code and docs. - await server.connect(transport); - await transport.handleRequest(req, res, req.body); -}); +### Server examples -const port = parseInt(process.env.PORT || '3000'); -app.listen(port, () => { - console.log(`MCP Server running on http://localhost:${port}/mcp`); -}); -``` - -### Improving Network Efficiency with Notification Debouncing - -When performing bulk updates that trigger notifications (e.g., enabling or disabling multiple tools in a loop), the SDK can send a large number of messages in a short period. To improve performance and reduce network traffic, you can enable notification debouncing. - -This feature coalesces multiple, rapid calls for the same notification type into a single message. For example, if you disable five tools in a row, only one `notifications/tools/list_changed` message will be sent instead of five. - -> [!IMPORTANT] This feature is designed for "simple" notifications that do not carry unique data in their parameters. To prevent silent data loss, debouncing is **automatically bypassed** for any notification that contains a `params` object or a `relatedRequestId`. Such -> notifications will always be sent immediately. - -This is an opt-in feature configured during server initialization. - -```typescript -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; - -const server = new McpServer( - { - name: "efficient-server", - version: "1.0.0" - }, - { - // Enable notification debouncing for specific methods - debouncedNotificationMethods: [ - 'notifications/tools/list_changed', - 'notifications/resources/list_changed', - 'notifications/prompts/list_changed' - ] - } -); - -// Now, any rapid changes to tools, resources, or prompts will result -// in a single, consolidated notification for each type. -server.registerTool("tool1", ...).disable(); -server.registerTool("tool2", ...).disable(); -server.registerTool("tool3", ...).disable(); -// Only one 'notifications/tools/list_changed' is sent. -``` - -### Low-Level Server - -For more control, you can use the low-level Server class directly: - -```typescript -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { ListPromptsRequestSchema, GetPromptRequestSchema } from '@modelcontextprotocol/sdk/types.js'; - -const server = new Server( - { - name: 'example-server', - version: '1.0.0' - }, - { - capabilities: { - prompts: {} - } - } -); - -server.setRequestHandler(ListPromptsRequestSchema, async () => { - return { - prompts: [ - { - name: 'example-prompt', - description: 'An example prompt template', - arguments: [ - { - name: 'arg1', - description: 'Example argument', - required: true - } - ] - } - ] - }; -}); - -server.setRequestHandler(GetPromptRequestSchema, async request => { - if (request.params.name !== 'example-prompt') { - throw new Error('Unknown prompt'); - } - return { - description: 'Example prompt', - messages: [ - { - role: 'user', - content: { - type: 'text', - text: 'Example prompt text' - } - } - ] - }; -}); - -const transport = new StdioServerTransport(); -await server.connect(transport); -``` - -### Eliciting User Input - -MCP servers can request non-sensitive information from users through the form elicitation capability. This is useful for interactive workflows where the server needs user input or confirmation: - -```typescript -// Server-side: Restaurant booking tool that asks for alternatives -server.registerTool( - 'book-restaurant', - { - title: 'Book Restaurant', - description: 'Book a table at a restaurant', - inputSchema: { - restaurant: z.string(), - date: z.string(), - partySize: z.number() - }, - outputSchema: { - success: z.boolean(), - booking: z - .object({ - restaurant: z.string(), - date: z.string(), - partySize: z.number() - }) - .optional(), - alternatives: z.array(z.string()).optional() - } - }, - async ({ restaurant, date, partySize }) => { - // Check availability - const available = await checkAvailability(restaurant, date, partySize); - - if (!available) { - // Ask user if they want to try alternative dates - const result = await server.server.elicitInput({ - mode: 'form', - message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, - requestedSchema: { - type: 'object', - properties: { - checkAlternatives: { - type: 'boolean', - title: 'Check alternative dates', - description: 'Would you like me to check other dates?' - }, - flexibleDates: { - type: 'string', - title: 'Date flexibility', - description: 'How flexible are your dates?', - enum: ['next_day', 'same_week', 'next_week'], - enumNames: ['Next day', 'Same week', 'Next week'] - } - }, - required: ['checkAlternatives'] - } - }); - - if (result.action === 'accept' && result.content?.checkAlternatives) { - const alternatives = await findAlternatives(restaurant, date, partySize, result.content.flexibleDates as string); - const output = { success: false, alternatives }; - return { - content: [ - { - type: 'text', - text: JSON.stringify(output) - } - ], - structuredContent: output - }; - } - - const output = { success: false }; - return { - content: [ - { - type: 'text', - text: JSON.stringify(output) - } - ], - structuredContent: output - }; - } - - // Book the table - await makeBooking(restaurant, date, partySize); - const output = { - success: true, - booking: { restaurant, date, partySize } - }; - return { - content: [ - { - type: 'text', - text: JSON.stringify(output) - } - ], - structuredContent: output - }; - } -); -``` - -On the client side, handle form elicitation requests: - -```typescript -// This is a placeholder - implement based on your UI framework -async function getInputFromUser( - message: string, - schema: any -): Promise<{ - action: 'accept' | 'decline' | 'cancel'; - data?: Record; -}> { - // This should be implemented depending on the app - throw new Error('getInputFromUser must be implemented for your platform'); -} - -client.setRequestHandler(ElicitRequestSchema, async request => { - const userResponse = await getInputFromUser(request.params.message, request.params.requestedSchema); - - return { - action: userResponse.action, - content: userResponse.action === 'accept' ? userResponse.data : undefined - }; -}); -``` - -When calling `server.elicitInput`, prefer to explicitly set `mode: 'form'` for new code. Omitting the mode continues to work for backwards compatibility and defaults to form elicitation. - -Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization: - -```typescript -const client = new Client( - { - name: 'example-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: { - form: {} - } - } - } -); -``` - -**Note**: Form elicitation **must** only be used to gather non-sensitive information. For sensitive information such as API keys or secrets, use URL elicitation instead. - -### Eliciting URL Actions - -MCP servers can prompt the user to perform a URL-based action through URL elicitation. This is useful for securely gathering sensitive information such as API keys or secrets, or for redirecting users to secure web-based flows. - -```typescript -// Server-side: Prompt the user to navigate to a URL -const result = await server.server.elicitInput({ - mode: 'url', - message: 'Please enter your API key', - elicitationId: '550e8400-e29b-41d4-a716-446655440000', - url: 'http://localhost:3000/api-key' -}); - -// Alternative, return an error from within a tool: -throw new UrlElicitationRequiredError([ - { - mode: 'url', - message: 'This tool requires a payment confirmation. Open the link to confirm payment!', - url: `http://localhost:${MCP_PORT}/confirm-payment?session=${sessionId}&elicitation=${elicitationId}&cartId=${encodeURIComponent(cartId)}`, - elicitationId: '550e8400-e29b-41d4-a716-446655440000' - } -]); -``` - -On the client side, handle URL elicitation requests: - -```typescript -client.setRequestHandler(ElicitRequestSchema, async request => { - if (request.params.mode !== 'url') { - throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); - } - - // At a minimum, implement a UI that: - // - Display the full URL and server reason to prevent phishing - // - Explicitly ask the user for consent, with clear decline/cancel options - // - Open the URL in the system (not embedded) browser - // Optionally, listen for a `nofifications/elicitation/complete` message from the server -}); -``` - -Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization: - -```typescript -const client = new Client( - { - name: 'example-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: { - url: {} - } - } - } -); -``` - -### Task-Based Execution - -> **⚠️ Experimental API**: Task-based execution is an experimental feature and may change without notice. Access these APIs via the `.experimental.tasks` namespace. - -Task-based execution enables "call-now, fetch-later" patterns for long-running operations. This is useful for tools that take significant time to complete, where clients may want to disconnect and check on progress or retrieve results later. - -Common use cases include: - -- Long-running data processing or analysis -- Code migration or refactoring operations -- Complex computational tasks -- Operations that require periodic status updates - -#### Server-Side: Implementing Task Support - -To enable task-based execution, configure your server with a `TaskStore` implementation. The SDK doesn't provide a built-in TaskStore—you'll need to implement one backed by your database of choice: - -```typescript -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { TaskStore } from '@modelcontextprotocol/sdk/experimental'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; - -// Implement TaskStore backed by your database (e.g., PostgreSQL, Redis, etc.) -class MyTaskStore implements TaskStore { - async createTask(taskParams, requestId, request, sessionId?): Promise { - // Generate unique taskId and lastUpdatedAt/createdAt timestamps - // Store task in your database, using the session ID as a proxy to restrict unauthorized access - // Return final Task object - } - - async getTask(taskId): Promise { - // Retrieve task from your database - } - - async updateTaskStatus(taskId, status, statusMessage?): Promise { - // Update task status in your database - } - - async storeTaskResult(taskId, result): Promise { - // Store task result in your database - } - - async getTaskResult(taskId): Promise { - // Retrieve task result from your database - } - - async listTasks(cursor?, sessionId?): Promise<{ tasks: Task[]; nextCursor?: string }> { - // List tasks with pagination support - } -} - -const taskStore = new MyTaskStore(); - -const server = new Server( - { - name: 'task-enabled-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {}, - // Declare capabilities - tasks: { - list: {}, - cancel: {}, - requests: { - tools: { - // Declares support for tasks on tools/call - call: {} - } - } - } - }, - taskStore // Enable task support - } -); - -// Register a tool that supports tasks using the experimental API -server.experimental.tasks.registerToolTask( - 'my-echo-tool', - { - title: 'My Echo Tool', - description: 'A simple task-based echo tool.', - inputSchema: { - message: z.string().describe('Message to send') - } - }, - { - async createTask({ message }, { taskStore, taskRequestedTtl, requestId }) { - // Create the task - const task = await taskStore.createTask({ - ttl: taskRequestedTtl - }); - - // Simulate out-of-band work - (async () => { - await new Promise(resolve => setTimeout(resolve, 5000)); - await taskStore.storeTaskResult(task.taskId, 'completed', { - content: [ - { - type: 'text', - text: message - } - ] - }); - })(); - - // Return CreateTaskResult with the created task - return { task }; - }, - async getTask(_args, { taskId, taskStore }) { - // Retrieve the task - return await taskStore.getTask(taskId); - }, - async getTaskResult(_args, { taskId, taskStore }) { - // Retrieve the result of the task - const result = await taskStore.getTaskResult(taskId); - return result as CallToolResult; - } - } -); -``` +| Scenario | Description | Example file(s) | Related docs | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Streamable HTTP server (stateful) | Feature-rich server with tools, resources, prompts, logging, tasks, sampling, and optional OAuth. | [`simpleStreamableHttp.ts`](src/examples/server/simpleStreamableHttp.ts) | [`server.md`](docs/server.md), [`capabilities.md`](docs/capabilities.md) | +| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`simpleStatelessStreamableHttp.ts`](src/examples/server/simpleStatelessStreamableHttp.ts) | [`server.md`](docs/server.md) | +| JSON response mode (no SSE) | Streamable HTTP with JSON responses only and limited notifications. | [`jsonResponseStreamableHttp.ts`](src/examples/server/jsonResponseStreamableHttp.ts) | [`server.md`](docs/server.md) | +| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications using SSE with Streamable HTTP. | [`standaloneSseWithGetStreamableHttp.ts`](src/examples/server/standaloneSseWithGetStreamableHttp.ts) | [`server.md`](docs/server.md) | +| Deprecated HTTP+SSE server | Legacy HTTP+SSE transport for backwards-compatibility testing. | [`simpleSseServer.ts`](src/examples/server/simpleSseServer.ts) | [`server.md`](docs/server.md) | +| Backwards-compatible server (Streamable HTTP + SSE) | Single server that supports both Streamable HTTP and legacy SSE clients. | [`sseAndStreamableHttpCompatibleServer.ts`](src/examples/server/sseAndStreamableHttpCompatibleServer.ts) | [`server.md`](docs/server.md) | +| Form elicitation server | Uses form elicitation to collect non-sensitive user input. | [`elicitationFormExample.ts`](src/examples/server/elicitationFormExample.ts) | [`capabilities.md`](docs/capabilities.md#elicitation) | +| URL elicitation server | Demonstrates URL-mode elicitation in an OAuth-protected server. | [`elicitationUrlExample.ts`](src/examples/server/elicitationUrlExample.ts) | [`capabilities.md`](docs/capabilities.md#elicitation) | +| Sampling and tasks server | Combines tools, logging, sampling, and experimental task-based execution. | [`toolWithSampleServer.ts`](src/examples/server/toolWithSampleServer.ts) | [`capabilities.md`](docs/capabilities.md) | +| OAuth demo authorization server | In-memory OAuth provider used with the example servers. | [`demoInMemoryOAuthProvider.ts`](src/examples/server/demoInMemoryOAuthProvider.ts) | [`server.md`](docs/server.md) | -**Note**: See `src/examples/shared/inMemoryTaskStore.ts` in the SDK source for a reference task store implementation suitable for development and testing. +### Client examples -#### Client-Side: Using Task-Based Execution +| Scenario | Description | Example file(s) | Related docs | +| --------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Interactive Streamable HTTP client | CLI client that exercises tools, resources, prompts, elicitation, and tasks. | [`simpleStreamableHttp.ts`](src/examples/client/simpleStreamableHttp.ts) | [`client.md`](docs/client.md) | +| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, then falls back to SSE on 4xx responses. | [`streamableHttpWithSseFallbackClient.ts`](src/examples/client/streamableHttpWithSseFallbackClient.ts) | [`client.md`](docs/client.md), [`server.md`](docs/server.md) | +| SSE polling client | Polls a legacy SSE server and demonstrates notification handling. | [`ssePollingClient.ts`](src/examples/client/ssePollingClient.ts) | [`client.md`](docs/client.md) | +| Parallel tool calls client | Shows how to run multiple tool calls in parallel. | [`parallelToolCallsClient.ts`](src/examples/client/parallelToolCallsClient.ts) | [`client.md`](docs/client.md) | +| Multiple clients in parallel | Demonstrates connecting multiple clients concurrently to the same server. | [`multipleClientsParallel.ts`](src/examples/client/multipleClientsParallel.ts) | [`client.md`](docs/client.md) | +| OAuth clients | Examples of client_credentials (basic and private_key_jwt) and reusable providers. | [`simpleOAuthClient.ts`](src/examples/client/simpleOAuthClient.ts), [`simpleOAuthClientProvider.ts`](src/examples/client/simpleOAuthClientProvider.ts), [`simpleClientCredentials.ts`](src/examples/client/simpleClientCredentials.ts) | [`client.md`](docs/client.md) | +| URL elicitation client | Works with the URL elicitation server to drive secure browser flows. | [`elicitationUrlExample.ts`](src/examples/client/elicitationUrlExample.ts) | [`capabilities.md`](docs/capabilities.md#elicitation) | -Clients use `experimental.tasks.callToolStream()` to initiate task-augmented tool calls. The returned `AsyncGenerator` abstracts automatic polling and status updates: +Shared utilities: -```typescript -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; - -const client = new Client({ - name: 'task-client', - version: '1.0.0' -}); - -// ... connect to server ... - -// Call the tool with task metadata using the experimental streaming API -const stream = client.experimental.tasks.callToolStream( - { - name: 'my-echo-tool', - arguments: { message: 'Hello, world!' } - }, - CallToolResultSchema -); - -// Iterate the stream and handle stream events -let taskId = ''; -for await (const message of stream) { - switch (message.type) { - case 'taskCreated': - console.log('Task created successfully with ID:', message.task.taskId); - taskId = message.task.taskId; - break; - case 'taskStatus': - console.log(` ${message.task.status}${message.task.statusMessage ?? ''}`); - break; - case 'result': - console.log('Task completed! Tool result:'); - message.result.content.forEach(item => { - if (item.type === 'text') { - console.log(` ${item.text}`); - } - }); - break; - case 'error': - throw message.error; - } -} - -// Optional: Fire and forget - disconnect and reconnect later -// (useful when you don't want to wait for long-running tasks) -// Later, after disconnecting and reconnecting to the server: -const taskStatus = await client.getTask({ taskId }); -console.log('Task status:', taskStatus.status); - -if (taskStatus.status === 'completed') { - const taskResult = await client.getTaskResult({ taskId }, CallToolResultSchema); - console.log('Retrieved result after reconnect:', taskResult); -} -``` - -The `experimental.tasks.callToolStream()` method also works with non-task tools, making it a drop-in replacement for `callTool()` in applications that support it. When used to invoke a tool that doesn't support tasks, the `taskCreated` and `taskStatus` events will not be emitted. - -#### Task Status Lifecycle - -Tasks transition through the following states: - -- **working**: Task is actively being processed -- **input_required**: Task is waiting for additional input (e.g., from elicitation) -- **completed**: Task finished successfully -- **failed**: Task encountered an error -- **cancelled**: Task was cancelled by the client - -The `ttl` parameter suggests how long the server will manage the task for. If the task duration exceeds this, the server may delete the task prematurely. The client's suggested value may be overridden by the server, and the final TTL will be provided in `Task.ttl` in -`taskCreated` and `taskStatus` events. - -### Writing MCP Clients - -The SDK provides a high-level client interface: - -```typescript -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; - -const transport = new StdioClientTransport({ - command: 'node', - args: ['server.js'] -}); - -const client = new Client({ - name: 'example-client', - version: '1.0.0' -}); - -await client.connect(transport); - -// List prompts -const prompts = await client.listPrompts(); - -// Get a prompt -const prompt = await client.getPrompt({ - name: 'example-prompt', - arguments: { - arg1: 'value' - } -}); - -// List resources -const resources = await client.listResources(); - -// Read a resource -const resource = await client.readResource({ - uri: 'file:///example.txt' -}); - -// Call a tool -const result = await client.callTool({ - name: 'example-tool', - arguments: { - arg1: 'value' - } -}); -``` - -### OAuth client authentication helpers - -For OAuth-secured MCP servers, the client `auth` module exposes a generic `OAuthClientProvider` interface, and `src/client/auth-extensions.ts` provides ready-to-use implementations for common machine-to-machine authentication flows: - -- **ClientCredentialsProvider**: Uses the `client_credentials` grant with `client_secret_basic` authentication. -- **PrivateKeyJwtProvider**: Uses the `client_credentials` grant with `private_key_jwt` client authentication, signing a JWT assertion on each token request. -- **StaticPrivateKeyJwtProvider**: Similar to `PrivateKeyJwtProvider`, but accepts a pre-built JWT assertion string via `jwtBearerAssertion` and reuses it for token requests. - -You can use these providers with the `StreamableHTTPClientTransport` and the high-level `auth()` helper: - -```typescript -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { ClientCredentialsProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from '@modelcontextprotocol/sdk/client/auth-extensions.js'; -import { auth } from '@modelcontextprotocol/sdk/client/auth.js'; - -const serverUrl = new URL('https://mcp.example.com/'); - -// Example: client_credentials with client_secret_basic -const basicProvider = new ClientCredentialsProvider({ - clientId: process.env.CLIENT_ID!, - clientSecret: process.env.CLIENT_SECRET!, - clientName: 'example-basic-client' -}); - -// Example: client_credentials with private_key_jwt (JWT signed locally) -const privateKeyJwtProvider = new PrivateKeyJwtProvider({ - clientId: process.env.CLIENT_ID!, - privateKey: process.env.CLIENT_PRIVATE_KEY_PEM!, - algorithm: 'RS256', - clientName: 'example-private-key-jwt-client', - jwtLifetimeSeconds: 300 -}); - -// Example: client_credentials with a pre-built JWT assertion -const staticJwtProvider = new StaticPrivateKeyJwtProvider({ - clientId: process.env.CLIENT_ID!, - jwtBearerAssertion: process.env.CLIENT_ASSERTION!, - clientName: 'example-static-private-key-jwt-client' -}); - -const transport = new StreamableHTTPClientTransport(serverUrl, { - authProvider: privateKeyJwtProvider -}); - -const client = new Client({ - name: 'example-client', - version: '1.0.0' -}); - -// Perform the OAuth flow (including dynamic client registration if needed) -await auth(privateKeyJwtProvider, { serverUrl, fetchFn: transport.fetch }); - -await client.connect(transport); -``` - -If you need lower-level control, you can also use `createPrivateKeyJwtAuth()` directly to implement `addClientAuthentication` on a custom `OAuthClientProvider`. - -### Proxy Authorization Requests Upstream - -You can proxy OAuth requests to an external authorization provider: - -```typescript -import express from 'express'; -import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js'; -import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; - -const app = express(); - -const proxyProvider = new ProxyOAuthServerProvider({ - endpoints: { - authorizationUrl: 'https://auth.external.com/oauth2/v1/authorize', - tokenUrl: 'https://auth.external.com/oauth2/v1/token', - revocationUrl: 'https://auth.external.com/oauth2/v1/revoke' - }, - verifyAccessToken: async token => { - return { - token, - clientId: '123', - scopes: ['openid', 'email', 'profile'] - }; - }, - getClient: async client_id => { - return { - client_id, - redirect_uris: ['http://localhost:3000/callback'] - }; - } -}); - -app.use( - mcpAuthRouter({ - provider: proxyProvider, - issuerUrl: new URL('http://auth.external.com'), - baseUrl: new URL('http://mcp.example.com'), - serviceDocumentationUrl: new URL('https://docs.example.com/') - }) -); -``` - -This setup allows you to: - -- Forward OAuth requests to an external provider -- Add custom token validation logic -- Manage client registrations -- Provide custom documentation URLs -- Maintain control over the OAuth flow while delegating to an external provider - -### Backwards Compatibility - -Clients and servers with StreamableHttp transport can maintain [backwards compatibility](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility) with the deprecated HTTP+SSE transport (from protocol version 2024-11-05) as follows - -#### Client-Side Compatibility - -For clients that need to work with both Streamable HTTP and older SSE servers: - -```typescript -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -let client: Client | undefined = undefined; -const baseUrl = new URL(url); -try { - client = new Client({ - name: 'streamable-http-client', - version: '1.0.0' - }); - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - console.log('Connected using Streamable HTTP transport'); -} catch (error) { - // If that fails with a 4xx error, try the older SSE transport - console.log('Streamable HTTP connection failed, falling back to SSE transport'); - client = new Client({ - name: 'sse-client', - version: '1.0.0' - }); - const sseTransport = new SSEClientTransport(baseUrl); - await client.connect(sseTransport); - console.log('Connected using SSE transport'); -} -``` - -#### Server-Side Compatibility - -For servers that need to support both Streamable HTTP and older clients: - -```typescript -import express from 'express'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; - -const server = new McpServer({ - name: 'backwards-compatible-server', - version: '1.0.0' -}); - -// ... set up server resources, tools, and prompts ... - -const app = express(); -app.use(express.json()); - -// Store transports for each session type -const transports = { - streamable: {} as Record, - sse: {} as Record -}; - -// Modern Streamable HTTP endpoint -app.all('/mcp', async (req, res) => { - // Handle Streamable HTTP transport for modern clients - // Implementation as shown in the "With Session Management" example - // ... -}); - -// Legacy SSE endpoint for older clients -app.get('/sse', async (req, res) => { - // Create SSE transport for legacy clients - const transport = new SSEServerTransport('/messages', res); - transports.sse[transport.sessionId] = transport; - - res.on('close', () => { - delete transports.sse[transport.sessionId]; - }); - - await server.connect(transport); -}); - -// Legacy message endpoint for older clients -app.post('/messages', async (req, res) => { - const sessionId = req.query.sessionId as string; - const transport = transports.sse[sessionId]; - if (transport) { - await transport.handlePostMessage(req, res, req.body); - } else { - res.status(400).send('No transport found for sessionId'); - } -}); - -app.listen(3000); -``` +- In-memory event store for resumability: [`inMemoryEventStore.ts`](src/examples/shared/inMemoryEventStore.ts) (see [`server.md`](docs/server.md)). -**Note**: The SSE transport is now deprecated in favor of Streamable HTTP. New implementations should use Streamable HTTP, and existing SSE implementations should plan to migrate. +For more details on how to run these examples (including recommended commands and deployment diagrams), see `src/examples/README.md`. ## Documentation -- [Model Context Protocol documentation](https://modelcontextprotocol.io) -- [MCP Specification](https://spec.modelcontextprotocol.io) -- [Example Servers](https://github.com/modelcontextprotocol/servers) +- Local SDK docs: + - [docs/server.md](docs/server.md) – building and running MCP servers, transports, tools/resources/prompts, CORS, DNS rebinding, and multi-node deployment. + - [docs/client.md](docs/client.md) – using the high-level client, transports, backwards compatibility, and OAuth helpers. + - [docs/capabilities.md](docs/capabilities.md) – sampling, elicitation (form and URL), and experimental task-based execution. + - [docs/faq.md](docs/faq.md) – environment and troubleshooting FAQs (including Node.js Web Crypto support). +- External references: + - [Model Context Protocol documentation](https://modelcontextprotocol.io) + - [MCP Specification](https://spec.modelcontextprotocol.io) + - [Example Servers](https://github.com/modelcontextprotocol/servers) ## Contributing diff --git a/docs/capabilities.md b/docs/capabilities.md new file mode 100644 index 0000000000..301e850fe8 --- /dev/null +++ b/docs/capabilities.md @@ -0,0 +1,81 @@ +## Sampling + +MCP servers can request LLM completions from connected clients that support the sampling capability. This lets your tools offload summarisation or generation to the client’s model. + +For a runnable server that combines tools, logging and tasks, see: + +- [`toolWithSampleServer.ts`](../src/examples/server/toolWithSampleServer.ts) + +In practice you will: + +- Declare the sampling capability on the client. +- Call `server.server.createMessage(...)` from within a tool handler. +- Return the model’s response as structured content and/or text. + +Refer to the MCP spec’s sampling section for full request/response details. + +## Elicitation + +### Form elicitation + +Form elicitation lets a tool ask the user for additional, **non‑sensitive** information via a schema‑driven form. The server sends a schema and message, and the client is responsible for collecting and returning the data. + +Runnable example: + +- Server: [`elicitationFormExample.ts`](../src/examples/server/elicitationFormExample.ts) +- Client‑side handling: [`simpleStreamableHttp.ts`](../src/examples/client/simpleStreamableHttp.ts) + +The `simpleStreamableHttp` server also includes a `collect-user-info` tool that demonstrates how to drive elicitation from a tool and handle the response. + +### URL elicitation + +URL elicitation is designed for sensitive data and secure web‑based flows (e.g., collecting an API key, confirming a payment, or doing third‑party OAuth). Instead of returning form data, the server asks the client to open a URL and the rest of the flow happens in the browser. + +Runnable example: + +- Server: [`elicitationUrlExample.ts`](../src/examples/server/elicitationUrlExample.ts) +- Client: [`elicitationUrlExample.ts`](../src/examples/client/elicitationUrlExample.ts) + +Key points: + +- Use `mode: 'url'` when calling `server.server.elicitInput(...)`. +- Implement a client‑side handler for `ElicitRequestSchema` that: + - Shows the full URL and reason to the user. + - Asks for explicit consent. + - Opens the URL in the system browser. + +Sensitive information **must not** be collected via form elicitation; always use URL elicitation or out‑of‑band flows for secrets. + +## Task-based execution (experimental) + +Task-based execution enables “call-now, fetch-later” patterns for long-running operations. Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. + +The APIs live under the experimental `.experimental.tasks` namespace and may change without notice. + +### Server-side concepts + +On the server you will: + +- Provide a `TaskStore` implementation that persists task metadata and results. +- Enable the `tasks` capability when constructing the server. +- Register tools with `server.experimental.tasks.registerToolTask(...)`. + +For a runnable example that uses the in-memory store shipped with the SDK, see: + +- [`toolWithSampleServer.ts`](../src/examples/server/toolWithSampleServer.ts) +- `src/experimental/tasks/stores/in-memory.ts` + +### Client-side usage + +On the client, you use: + +- `client.experimental.tasks.callToolStream(...)` to start a tool call that may create a task and emit status updates over time. +- `client.getTask(...)` and `client.getTaskResult(...)` to check status and fetch results after reconnecting. + +The interactive client in: + +- [`simpleStreamableHttp.ts`](../src/examples/client/simpleStreamableHttp.ts) + +includes commands to demonstrate calling tools that support tasks and handling their lifecycle. + +See the MCP spec’s tasks section and the example server/client above for a full walkthrough of the task status lifecycle and TTL handling. diff --git a/docs/client.md b/docs/client.md new file mode 100644 index 0000000000..8a958081e7 --- /dev/null +++ b/docs/client.md @@ -0,0 +1,60 @@ +## Client overview + +The SDK provides a high-level `Client` class that connects to MCP servers over different transports: + +- `StdioClientTransport` – for local processes you spawn. +- `StreamableHTTPClientTransport` – for remote HTTP servers. +- `SSEClientTransport` – for legacy HTTP+SSE servers (deprecated). + +Runnable client examples live under: + +- [`simpleStreamableHttp.ts`](../src/examples/client/simpleStreamableHttp.ts) +- [`streamableHttpWithSseFallbackClient.ts`](../src/examples/client/streamableHttpWithSseFallbackClient.ts) +- [`ssePollingClient.ts`](../src/examples/client/ssePollingClient.ts) +- [`multipleClientsParallel.ts`](../src/examples/client/multipleClientsParallel.ts) +- [`parallelToolCallsClient.ts`](../src/examples/client/parallelToolCallsClient.ts) + +## Connecting and basic operations + +A typical flow: + +1. Construct a `Client` with name, version and capabilities. +2. Create a transport and call `client.connect(transport)`. +3. Use high-level helpers: + - `listTools`, `callTool` + - `listPrompts`, `getPrompt` + - `listResources`, `readResource` + +See [`simpleStreamableHttp.ts`](../src/examples/client/simpleStreamableHttp.ts) for an interactive CLI client that exercises these methods and shows how to handle notifications, elicitation and tasks. + +## Transports and backwards compatibility + +To support both modern Streamable HTTP and legacy SSE servers, use a client that: + +1. Tries `StreamableHTTPClientTransport`. +2. Falls back to `SSEClientTransport` on a 4xx response. + +Runnable example: + +- [`streamableHttpWithSseFallbackClient.ts`](../src/examples/client/streamableHttpWithSseFallbackClient.ts) + +## OAuth client authentication helpers + +For OAuth-secured MCP servers, the client `auth` module exposes: + +- `ClientCredentialsProvider` +- `PrivateKeyJwtProvider` +- `StaticPrivateKeyJwtProvider` + +Examples: + +- [`simpleOAuthClient.ts`](../src/examples/client/simpleOAuthClient.ts) +- [`simpleOAuthClientProvider.ts`](../src/examples/client/simpleOAuthClientProvider.ts) +- [`simpleClientCredentials.ts`](../src/examples/client/simpleClientCredentials.ts) +- Server-side auth demo: [`demoInMemoryOAuthProvider.ts`](../src/examples/server/demoInMemoryOAuthProvider.ts) + +These examples show how to: + +- Perform dynamic client registration if needed. +- Acquire access tokens. +- Attach OAuth credentials to Streamable HTTP requests. diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000000..6de0ecaae8 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,65 @@ +## FAQ + +
+Table of Contents + +- [General](#general) +- [Clients](#clients) +- [Servers](#servers) + +
+ +## General + +### Why do I see `TS2589: Type instantiation is excessively deep and possibly infinite` after upgrading the SDK? + +This TypeScript error can appear when upgrading to newer SDK versions that support Zod v4 (for example, from `@modelcontextprotocol/sdk` `1.22.0` to `1.23.0`) **and** your project ends up with multiple `zod` versions in the dependency tree. + +When there are multiple copies or versions of `zod`, TypeScript may try to instantiate very complex, cross-version types and hit its recursion limits, resulting in `TS2589`. This scenario is discussed in GitHub issue +[#1180](https://github.com/modelcontextprotocol/typescript-sdk/issues/1180#event-21236550401). + +To diagnose and fix this: + +- **Inspect your installed `zod` versions**: + - Run `npm ls zod` or `npm explain zod`, `pnpm list zod` or `pnpm why zod`, or `yarn why zod` and check whether more than one version is installed. +- **Align on a single `zod` version**: + - Make sure all packages that depend on `zod` use a compatible version range so that your package manager can hoist a single copy. + - In monorepos, consider declaring `zod` at the workspace root and using compatible ranges in individual packages. +- **Use overrides/resolutions if necessary**: + - With npm, Yarn, or pnpm, you can use `overrides` / `resolutions` to force a single `zod` version if some transitive dependencies pull in a different one. + +Once your project is using a single, compatible `zod` version, the `TS2589` error should no longer occur. + +## Clients + +### How do I enable Web Crypto (`globalThis.crypto`) for client authentication in older Node.js versions? + +The SDK’s OAuth client authentication helpers (for example, those in `src/client/auth-extensions.ts` that use `jose`) rely on the Web Crypto API exposed as `globalThis.crypto`. This is especially important for **client credentials** and **JWT-based** authentication flows used by +MCP clients. + +- **Node.js v19.0.0 and later**: `globalThis.crypto` is available by default. +- **Node.js v18.x**: `globalThis.crypto` may not be defined by default. In this repository we polyfill it for tests (see `vitest.setup.ts`), and you should do the same in your app if it is missing – or alternatively, run Node with `--experimental-global-webcrypto` as per your + Node version documentation. (See https://nodejs.org/dist/latest-v18.x/docs/api/globals.html#crypto ) + +If you run clients on Node.js versions where `globalThis.crypto` is missing, you can polyfill it using the built-in `node:crypto` module, similar to the SDK's own `vitest.setup.ts`: + +```typescript +import { webcrypto } from 'node:crypto'; + +if (typeof globalThis.crypto === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).crypto = webcrypto as unknown as Crypto; +} +``` + +For production use, you can either: + +- Run clients on a Node.js version where `globalThis.crypto` is available by default (recommended), or +- Apply a similar polyfill early in your client's startup code when targeting older Node.js runtimes, so that OAuth client authentication works reliably. + +## Servers + +### Where can I find runnable server examples? + +The SDK ships several runnable server examples under `src/examples/server`. The root `README.md` contains a curated **Server examples** table that links to each scenario (stateful/stateless Streamable HTTP, JSON-only mode, SSE/backwards compatibility, elicitation, sampling, +tasks, and OAuth demos), and `src/examples/README.md` includes commands and deployment diagrams for running them. diff --git a/docs/server.md b/docs/server.md new file mode 100644 index 0000000000..8c42f31aa6 --- /dev/null +++ b/docs/server.md @@ -0,0 +1,198 @@ +## Server overview + +This SDK lets you build MCP servers in TypeScript and connect them to different transports. For most use cases you will use `McpServer` from `@modelcontextprotocol/sdk/server/mcp.js` and choose one of: + +- **Streamable HTTP** (recommended for remote servers) +- **HTTP + SSE** (deprecated, for backwards compatibility only) +- **stdio** (for local, process‑spawned integrations) + +For a complete, runnable example server, see: + +- [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) – feature‑rich Streamable HTTP server +- [`jsonResponseStreamableHttp.ts`](../src/examples/server/jsonResponseStreamableHttp.ts) – Streamable HTTP with JSON response mode +- [`simpleStatelessStreamableHttp.ts`](../src/examples/server/simpleStatelessStreamableHttp.ts) – stateless Streamable HTTP server +- [`simpleSseServer.ts`](../src/examples/server/simpleSseServer.ts) – deprecated HTTP+SSE transport +- [`sseAndStreamableHttpCompatibleServer.ts`](../src/examples/server/sseAndStreamableHttpCompatibleServer.ts) – backwards‑compatible server for old and new clients + +## Transports + +### Streamable HTTP + +Streamable HTTP is the modern, fully featured transport. It supports: + +- Request/response over HTTP POST +- Server‑to‑client notifications over SSE (when enabled) +- Optional JSON‑only response mode with no SSE +- Session management and resumability + +Key examples: + +- [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) – sessions, logging, tasks, elicitation, auth hooks +- [`jsonResponseStreamableHttp.ts`](../src/examples/server/jsonResponseStreamableHttp.ts) – `enableJsonResponse: true`, no SSE +- [`standaloneSseWithGetStreamableHttp.ts`](../src/examples/server/standaloneSseWithGetStreamableHttp.ts) – notifications with Streamable HTTP GET + SSE + +See the MCP spec for full transport details: +`https://modelcontextprotocol.io/specification/2025-03-26/basic/transports` + +### Stateless vs stateful sessions + +Streamable HTTP can run: + +- **Stateless** – no session tracking, ideal for simple API‑style servers. +- **Stateful** – sessions have IDs, and you can enable resumability and advanced features. + +Examples: + +- Stateless Streamable HTTP: [`simpleStatelessStreamableHttp.ts`](../src/examples/server/simpleStatelessStreamableHttp.ts) +- Stateful with resumability: [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) + +### Deprecated HTTP + SSE + +The older HTTP+SSE transport (protocol version 2024‑11‑05) is supported only for backwards compatibility. New implementations should prefer Streamable HTTP. + +Examples: + +- Legacy SSE server: [`simpleSseServer.ts`](../src/examples/server/simpleSseServer.ts) +- Backwards‑compatible server (Streamable HTTP + SSE): + [`sseAndStreamableHttpCompatibleServer.ts`](../src/examples/server/sseAndStreamableHttpCompatibleServer.ts) + +## Running your server + +For a minimal “getting started” experience: + +1. Start from [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts). +2. Remove features you do not need (tasks, advanced logging, OAuth, etc.). +3. Register your own tools, resources and prompts. + +For more detailed patterns (stateless vs stateful, JSON response mode, CORS, DNS rebind protection), see the examples above and the MCP spec sections on transports. + +## Tools, resources, and prompts + +### Tools + +Tools let MCP clients ask your server to take actions. They are usually the main way that LLMs call into your application. + +A typical registration with `registerTool` looks like this: + +```typescript +server.registerTool( + 'calculate-bmi', + { + title: 'BMI Calculator', + description: 'Calculate Body Mass Index', + inputSchema: { + weightKg: z.number(), + heightM: z.number() + }, + outputSchema: { bmi: z.number() } + }, + async ({ weightKg, heightM }) => { + const output = { bmi: weightKg / (heightM * heightM) }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } +); +``` + +This snippet is illustrative only; for runnable servers that expose tools, see: + +- [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) +- [`toolWithSampleServer.ts`](../src/examples/server/toolWithSampleServer.ts) + +#### ResourceLink outputs + +Tools can return `resource_link` content items to reference large resources without embedding them directly, allowing clients to fetch only what they need. + +The README’s `list-files` example shows the pattern conceptually; for concrete usage, see the Streamable HTTP examples in `src/examples/server`. + +### Resources + +Resources expose data to clients, but should not perform heavy computation or side‑effects. They are ideal for configuration, documents, or other reference data. + +Conceptually, you might register resources like: + +```typescript +server.registerResource( + 'config', + 'config://app', + { + title: 'Application Config', + description: 'Application configuration data', + mimeType: 'text/plain' + }, + async uri => ({ + contents: [{ uri: uri.href, text: 'App configuration here' }] + }) +); +``` + +Dynamic resources use `ResourceTemplate` and can support completions on path parameters. For full runnable examples of resources: + +- [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) + +### Prompts + +Prompts are reusable templates that help humans (or client UIs) talk to models in a consistent way. They are declared on the server and listed through MCP. + +A minimal prompt: + +```typescript +server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices and potential issues', + argsSchema: { code: z.string() } + }, + ({ code }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please review this code:\n\n${code}` + } + } + ] + }) +); +``` + +For prompts integrated into a full server, see: + +- [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) + +### Completions + +Both prompts and resources can support argument completions. On the client side, you use `client.complete()` with a reference to the prompt or resource and the partially‑typed argument. + +See the MCP spec sections on prompts and resources for complete details, and [`simpleStreamableHttp.ts`](../src/examples/client/simpleStreamableHttp.ts) for client‑side usage patterns. + +### Display names and metadata + +Tools, resources and prompts support a `title` field for human‑readable names. Older APIs can also attach `annotations.title`. To compute the correct display name on the client, use: + +- `getDisplayName` from `@modelcontextprotocol/sdk/shared/metadataUtils.js` + +## Multi‑node deployment patterns + +The SDK supports multi‑node deployments using Streamable HTTP. The high‑level patterns are documented in [`README.md`](../src/examples/README.md): + +- Stateless mode (any node can handle any request) +- Persistent storage mode (shared database for session state) +- Local state with message routing (message queue + pub/sub) + +Those deployment diagrams are kept in [`README.md`](../src/examples/README.md) so the examples and documentation stay aligned. + +## Backwards compatibility + +To handle both modern and legacy clients: + +- Run a backwards‑compatible server: + - [`sseAndStreamableHttpCompatibleServer.ts`](../src/examples/server/sseAndStreamableHttpCompatibleServer.ts) +- Use a client that falls back from Streamable HTTP to SSE: + - [`streamableHttpWithSseFallbackClient.ts`](../src/examples/client/streamableHttpWithSseFallbackClient.ts) + +For the detailed protocol rules, see the “Backwards compatibility” section of the MCP spec. diff --git a/src/examples/README.md b/src/examples/README.md index f46ae83e3d..0d98456a6b 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -1,6 +1,6 @@ # MCP TypeScript SDK Examples -This directory contains example implementations of MCP clients and servers using the TypeScript SDK. +This directory contains example implementations of MCP clients and servers using the TypeScript SDK. For a high-level index of scenarios and where they live, see the **Examples** table in the root `README.md`. ## Table of Contents From 6c863c8e75a196ad6e1cbf4c80c75a3928164ebd Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 2 Dec 2025 01:05:48 +0900 Subject: [PATCH 3/8] [Docs] Fix typo (#1067) Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> --- src/server/stdio.ts | 2 +- src/shared/transport.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/stdio.ts b/src/server/stdio.ts index ece9d0ae4a..e552af0fa1 100644 --- a/src/server/stdio.ts +++ b/src/server/stdio.ts @@ -5,7 +5,7 @@ import { JSONRPCMessage } from '../types.js'; import { Transport } from '../shared/transport.js'; /** - * Server transport for stdio: this communicates with a MCP client by reading from the current process' stdin and writing to stdout. + * Server transport for stdio: this communicates with an MCP client by reading from the current process' stdin and writing to stdout. * * This transport is only available in Node.js environments. */ diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 7e15dca473..f9b21bed32 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -69,7 +69,7 @@ export type TransportSendOptions = { onresumptiontoken?: (token: string) => void; }; /** - * Describes the minimal contract for a MCP transport that a client or server can communicate over. + * Describes the minimal contract for an MCP transport that a client or server can communicate over. */ export interface Transport { /** From fab7e1e9b3494d8992d11573991ddaa7081b8f1a Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:18:29 +0000 Subject: [PATCH 4/8] feat: add closeSSEStream callback to RequestHandlerExtra (#1166) --- src/examples/server/ssePollingExample.ts | 18 ++-- src/server/streamableHttp.test.ts | 114 +++++++++++++++++++++++ src/server/streamableHttp.ts | 10 +- src/shared/protocol.ts | 10 +- src/types.ts | 6 ++ 5 files changed, 147 insertions(+), 11 deletions(-) diff --git a/src/examples/server/ssePollingExample.ts b/src/examples/server/ssePollingExample.ts index 8bb8cfbc92..ea1d752f07 100644 --- a/src/examples/server/ssePollingExample.ts +++ b/src/examples/server/ssePollingExample.ts @@ -7,7 +7,7 @@ * Key features: * - Configures `retryInterval` to tell clients how long to wait before reconnecting * - Uses `eventStore` to persist events for replay after reconnection - * - Calls `closeSSEStream()` to gracefully disconnect clients mid-operation + * - Uses `extra.closeSSEStream()` callback to gracefully disconnect clients mid-operation * * Run with: npx tsx src/examples/server/ssePollingExample.ts * Test with: curl or the MCP Inspector @@ -31,9 +31,6 @@ const server = new McpServer( } ); -// Track active transports by session ID for closeSSEStream access -const transports = new Map(); - // Register a long-running tool that demonstrates server-initiated disconnect server.tool( 'long-task', @@ -65,11 +62,11 @@ server.tool( await sleep(1000); // Server decides to disconnect the client to free resources - // Client will reconnect via GET with Last-Event-ID after retryInterval - const transport = transports.get(extra.sessionId!); - if (transport) { + // Client will reconnect via GET with Last-Event-ID after the transport's retryInterval + // Use extra.closeSSEStream callback - available when eventStore is configured + if (extra.closeSSEStream) { console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`); - transport.closeSSEStream(extra.requestId); + extra.closeSSEStream(); } // Continue processing while client is disconnected @@ -112,6 +109,9 @@ app.use(cors()); // Create event store for resumability const eventStore = new InMemoryEventStore(); +// Track transports by session ID for session reuse +const transports = new Map(); + // Handle all MCP requests - use express.json() only for this route app.all('/mcp', express.json(), async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; @@ -123,7 +123,7 @@ app.all('/mcp', express.json(), async (req: Request, res: Response) => { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, - retryInterval: 2000, // Client should reconnect after 2 seconds + retryInterval: 2000, // Default retry interval for priming events onsessioninitialized: id => { console.log(`[${id}] Session initialized`); transports.set(id, transport!); diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 39c2e5805f..0e1acd6be4 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1802,6 +1802,120 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { // Clean up - resolve the tool promise toolResolve!(); }); + + it('should provide closeSSEStream callback in extra when eventStore is configured', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 1000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Track whether closeSSEStream callback was provided + let receivedCloseSSEStream: (() => void) | undefined; + + // Register a tool that captures the extra.closeSSEStream callback + mcpServer.tool('test-callback-tool', 'Test tool', {}, async (_args, extra) => { + receivedCloseSSEStream = extra.closeSSEStream; + return { content: [{ type: 'text', text: 'Done' }] }; + }); + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Call the tool + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 200, + method: 'tools/call', + params: { name: 'test-callback-tool', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + }, + body: JSON.stringify(toolCallRequest) + }); + + expect(postResponse.status).toBe(200); + + // Read all events to completion + const reader = postResponse.body?.getReader(); + while (true) { + const { done } = await reader!.read(); + if (done) break; + } + + // Verify closeSSEStream callback was provided + expect(receivedCloseSSEStream).toBeDefined(); + expect(typeof receivedCloseSSEStream).toBe('function'); + }); + + it('should NOT provide closeSSEStream callback when eventStore is NOT configured', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID() + // No eventStore + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Track whether closeSSEStream callback was provided + let receivedCloseSSEStream: (() => void) | undefined; + + // Register a tool that captures the extra.closeSSEStream callback + mcpServer.tool('test-no-callback-tool', 'Test tool', {}, async (_args, extra) => { + receivedCloseSSEStream = extra.closeSSEStream; + return { content: [{ type: 'text', text: 'Done' }] }; + }); + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Call the tool + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 201, + method: 'tools/call', + params: { name: 'test-no-callback-tool', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + }, + body: JSON.stringify(toolCallRequest) + }); + + expect(postResponse.status).toBe(200); + + // Read all events to completion + const reader = postResponse.body?.getReader(); + while (true) { + const { done } = await reader!.read(); + if (done) break; + } + + // Verify closeSSEStream callback was NOT provided + expect(receivedCloseSSEStream).toBeUndefined(); + }); }); // Test onsessionclosed callback diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 4514e619c3..bdd8e516cb 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -649,7 +649,15 @@ export class StreamableHTTPServerTransport implements Transport { // handle each message for (const message of messages) { - this.onmessage?.(message, { authInfo, requestInfo }); + // Build closeSSEStream callback for requests when eventStore is configured + let closeSSEStream: (() => void) | undefined; + if (isJSONRPCRequest(message) && this._eventStore) { + closeSSEStream = () => { + this.closeSSEStream(message.id); + }; + } + + this.onmessage?.(message, { authInfo, requestInfo, closeSSEStream }); } // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses // This will be handled by the send() method when responses are ready diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 61312926e0..ce25e45fb0 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -283,6 +283,13 @@ export type RequestHandlerExtra(request: SendRequestT, resultSchema: U, options?: TaskRequestOptions) => Promise>; + + /** + * Closes the SSE stream for this request, triggering client reconnection. + * Only available when using StreamableHTTPServerTransport with eventStore configured. + * Use this to implement polling behavior during long-running operations. + */ + closeSSEStream?: () => void; }; /** @@ -728,7 +735,8 @@ export abstract class Protocol void; } /* JSON-RPC types */ From f67fc2ff585f86205dcf4c2fe39a35e31f6bd33e Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:31:24 +0000 Subject: [PATCH 5/8] fix: improve SSE reconnection behavior (#1191) --- src/client/streamableHttp.test.ts | 106 ++++++++++++++++++++++++++++++ src/client/streamableHttp.ts | 28 ++++++-- 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 51a6a7dfaa..596ad23108 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -866,6 +866,112 @@ describe('StreamableHTTPClientTransport', () => { expect(reconnectHeaders.get('last-event-id')).toBe('event-123'); }); + it('should NOT reconnect a POST stream when response was received', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + // Create a stream that sends: + // 1. Priming event with ID (enables potential reconnection) + // 2. The actual response (should prevent reconnection) + // 3. Then closes + const streamWithResponse = new ReadableStream({ + start(controller) { + // Priming event with ID + controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n')); + // The actual response to the request + controller.enqueue( + new TextEncoder().encode('id: response-456\ndata: {"jsonrpc":"2.0","result":{"tools":[]},"id":"request-1"}\n\n') + ); + // Stream closes normally + controller.close(); + } + }); + + const fetchMock = global.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: streamWithResponse + }); + + const requestMessage: JSONRPCRequest = { + jsonrpc: '2.0', + method: 'tools/list', + id: 'request-1', + params: {} + }; + + // ACT + await transport.start(); + await transport.send(requestMessage); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT + // THE KEY ASSERTION: Fetch was called ONCE only - no reconnection! + // The response was received, so no need to reconnect. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1]?.method).toBe('POST'); + }); + + it('should not attempt reconnection after close() is called', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 100, + maxRetries: 3, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + // Stream with priming event + notification (no response) that closes + // This triggers reconnection scheduling + const streamWithPriming = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('id: event-123\ndata: {"jsonrpc":"2.0","method":"notifications/test","params":{}}\n\n') + ); + controller.close(); + } + }); + + const fetchMock = global.fetch as Mock; + + // POST request returns streaming response + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: streamWithPriming + }); + + // ACT + await transport.start(); + await transport.send({ jsonrpc: '2.0', method: 'test', id: '1', params: {} }); + + // Wait a tick to let stream processing complete and schedule reconnection + await vi.advanceTimersByTimeAsync(10); + + // Now close() - reconnection timeout is pending (scheduled for 100ms) + await transport.close(); + + // Advance past reconnection delay + await vi.advanceTimersByTimeAsync(200); + + // ASSERT + // Only 1 call: the initial POST. No reconnection attempts after close(). + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1]?.method).toBe('POST'); + }); + it('should not throw JSON parse error on priming events with empty data', async () => { transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 2adc32e10c..9cc4887df1 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -136,6 +136,7 @@ export class StreamableHTTPClientTransport implements Transport { private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401 private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping. private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field + private _reconnectionTimeout?: ReturnType; onclose?: () => void; onerror?: (error: Error) => void; @@ -287,7 +288,7 @@ export class StreamableHTTPClientTransport implements Transport { const delay = this._getNextReconnectionDelay(attemptCount); // Schedule the reconnection - setTimeout(() => { + this._reconnectionTimeout = setTimeout(() => { // Use the last event ID to resume where we left off this._startOrAuthSse(options).catch(error => { this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); @@ -307,6 +308,9 @@ export class StreamableHTTPClientTransport implements Transport { // Track whether we've received a priming event (event with ID) // Per spec, server SHOULD send a priming event with ID before closing let hasPrimingEvent = false; + // Track whether we've received a response - if so, no need to reconnect + // Reconnection is for when server disconnects BEFORE sending response + let receivedResponse = false; const processStream = async () => { // this is the closest we can get to trying to catch network errors // if something happens reader will throw @@ -346,8 +350,12 @@ export class StreamableHTTPClientTransport implements Transport { if (!event.event || event.event === 'message') { try { const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); - if (replayMessageId !== undefined && isJSONRPCResponse(message)) { - message.id = replayMessageId; + if (isJSONRPCResponse(message)) { + // Mark that we received a response - no need to reconnect for this request + receivedResponse = true; + if (replayMessageId !== undefined) { + message.id = replayMessageId; + } } this.onmessage?.(message); } catch (error) { @@ -359,8 +367,10 @@ export class StreamableHTTPClientTransport implements Transport { // Handle graceful server-side disconnect // Server may close connection after sending event ID and retry field // Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID) + // BUT don't reconnect if we already received a response - the request is complete const canResume = isReconnectable || hasPrimingEvent; - if (canResume && this._abortController && !this._abortController.signal.aborted) { + const needsReconnect = canResume && !receivedResponse; + if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { this._scheduleReconnection( { resumptionToken: lastEventId, @@ -376,8 +386,10 @@ export class StreamableHTTPClientTransport implements Transport { // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing // Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID) + // BUT don't reconnect if we already received a response - the request is complete const canResume = isReconnectable || hasPrimingEvent; - if (canResume && this._abortController && !this._abortController.signal.aborted) { + const needsReconnect = canResume && !receivedResponse; + if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { // Use the exponential backoff reconnection strategy try { this._scheduleReconnection( @@ -428,9 +440,11 @@ export class StreamableHTTPClientTransport implements Transport { } async close(): Promise { - // Abort any pending requests + if (this._reconnectionTimeout) { + clearTimeout(this._reconnectionTimeout); + this._reconnectionTimeout = undefined; + } this._abortController?.abort(); - this.onclose?.(); } From 5ceabfb3fbcf52f0f76e7d4974b0c54bfb383645 Mon Sep 17 00:00:00 2001 From: marcrasi Date: Mon, 1 Dec 2025 08:38:19 -0800 Subject: [PATCH 6/8] fix: normalize headers in sse transport (#856) Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Matt Carey --- src/client/sse.test.ts | 93 ++++++++++++++++++++++++++----- src/client/sse.ts | 11 +++- src/client/streamableHttp.test.ts | 28 ++++++++++ 3 files changed, 114 insertions(+), 18 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 3bdeb65e22..8d78fb95aa 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -308,14 +308,52 @@ describe('SSEClientTransport', () => { await transport.start(); - // Store original fetch const originalFetch = global.fetch; + try { + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {} + }; + + await transport.send(message); + + const calledHeaders = (global.fetch as Mock).mock.calls[0][1].headers; + expect(calledHeaders.get('Authorization')).toBe('Bearer test-token'); + expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value'); + expect(calledHeaders.get('content-type')).toBe('application/json'); + + customHeaders['X-Custom-Header'] = 'updated-value'; + + await transport.send(message); + + const updatedHeaders = (global.fetch as Mock).mock.calls[1][1].headers; + expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); + } finally { + global.fetch = originalFetch; + } + }); + + it('passes custom headers to fetch requests (Headers class)', async () => { + const customHeaders = new Headers({ + Authorization: 'Bearer test-token', + 'X-Custom-Header': 'custom-value' + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + requestInit: { + headers: customHeaders + } + }); + + await transport.start(); + const originalFetch = global.fetch; try { - // Mock fetch for the message sending test - global.fetch = vi.fn().mockResolvedValue({ - ok: true - }); + global.fetch = vi.fn().mockResolvedValue({ ok: true }); const message: JSONRPCMessage = { jsonrpc: '2.0', @@ -326,20 +364,45 @@ describe('SSEClientTransport', () => { await transport.send(message); - // Verify fetch was called with correct headers - expect(global.fetch).toHaveBeenCalledWith( - expect.any(URL), - expect.objectContaining({ - headers: expect.any(Headers) - }) - ); + const calledHeaders = (global.fetch as Mock).mock.calls[0][1].headers; + expect(calledHeaders.get('Authorization')).toBe('Bearer test-token'); + expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value'); + expect(calledHeaders.get('content-type')).toBe('application/json'); + + customHeaders.set('X-Custom-Header', 'updated-value'); + + await transport.send(message); + + const updatedHeaders = (global.fetch as Mock).mock.calls[1][1].headers; + expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); + } finally { + global.fetch = originalFetch; + } + }); + + it('passes custom headers to fetch requests (array of tuples)', async () => { + transport = new SSEClientTransport(resourceBaseUrl, { + requestInit: { + headers: [ + ['Authorization', 'Bearer test-token'], + ['X-Custom-Header', 'custom-value'] + ] + } + }); + + await transport.start(); + + const originalFetch = global.fetch; + try { + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + + await transport.send({ jsonrpc: '2.0', id: '1', method: 'test', params: {} }); const calledHeaders = (global.fetch as Mock).mock.calls[0][1].headers; - expect(calledHeaders.get('Authorization')).toBe(customHeaders.Authorization); - expect(calledHeaders.get('X-Custom-Header')).toBe(customHeaders['X-Custom-Header']); + expect(calledHeaders.get('Authorization')).toBe('Bearer test-token'); + expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value'); expect(calledHeaders.get('content-type')).toBe('application/json'); } finally { - // Restore original fetch global.fetch = originalFetch; } }); diff --git a/src/client/sse.ts b/src/client/sse.ts index 94eafe1b19..2b06619589 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -1,5 +1,5 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource'; -import { Transport, FetchLike, createFetchWithInit } from '../shared/transport.js'; +import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; @@ -114,7 +114,7 @@ export class SSEClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: HeadersInit = {}; + const headers: HeadersInit & Record = {}; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { @@ -125,7 +125,12 @@ export class SSEClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } - return new Headers({ ...headers, ...this._requestInit?.headers }); + const extraHeaders = normalizeHeaders(this._requestInit?.headers); + + return new Headers({ + ...headers, + ...extraHeaders + }); } private _startOrAuth(): Promise { diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 596ad23108..db836d127a 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -480,6 +480,7 @@ describe('StreamableHTTPClientTransport', () => { it('should always send specified custom headers', async () => { const requestInit = { headers: { + Authorization: 'Bearer test-token', 'X-Custom-Header': 'CustomValue' } }; @@ -497,6 +498,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.start(); await transport['_startOrAuthSse']({}); + expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer test-token'); expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('CustomValue'); requestInit.headers['X-Custom-Header'] = 'SecondCustomValue'; @@ -510,6 +512,7 @@ describe('StreamableHTTPClientTransport', () => { it('should always send specified custom headers (Headers class)', async () => { const requestInit = { headers: new Headers({ + Authorization: 'Bearer test-token', 'X-Custom-Header': 'CustomValue' }) }; @@ -527,6 +530,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.start(); await transport['_startOrAuthSse']({}); + expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer test-token'); expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('CustomValue'); (requestInit.headers as Headers).set('X-Custom-Header', 'SecondCustomValue'); @@ -537,6 +541,30 @@ describe('StreamableHTTPClientTransport', () => { expect(global.fetch).toHaveBeenCalledTimes(2); }); + it('should always send specified custom headers (array of tuples)', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + requestInit: { + headers: [ + ['Authorization', 'Bearer test-token'], + ['X-Custom-Header', 'CustomValue'] + ] + } + }); + + let actualReqInit: RequestInit = {}; + + (global.fetch as Mock).mockImplementation(async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } }); + }); + + await transport.start(); + + await transport['_startOrAuthSse']({}); + expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer test-token'); + expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('CustomValue'); + }); + it('should have exponential backoff with configurable maxRetries', () => { // This test verifies the maxRetries and backoff calculation directly From 4b651b8fe17a567c8563b98c8fd677d9df688c0a Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:01:20 +0000 Subject: [PATCH 7/8] feat: add closeStandaloneSSEStream for GET stream polling (#1203) --- src/server/streamableHttp.test.ts | 280 +++++++++++++++++++++++++++++- src/server/streamableHttp.ts | 30 +++- src/shared/protocol.ts | 10 +- src/types.ts | 6 + 4 files changed, 310 insertions(+), 16 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 0e1acd6be4..d2a29ac9e1 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1738,7 +1738,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { expect(text).not.toContain('retry:'); }); - it('should close POST SSE stream when closeSseStream is called', async () => { + it('should close POST SSE stream when extra.closeSSEStream is called', async () => { const result = await createTestServer({ sessionIdGenerator: () => randomUUID(), eventStore: createEventStore(), @@ -1749,15 +1749,21 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { baseUrl = result.baseUrl; mcpServer = result.mcpServer; - // Track tool execution state + // Track when stream close is called and tool completes + let streamCloseCalled = false; let toolResolve: () => void; - const toolPromise = new Promise(resolve => { + const toolCompletePromise = new Promise(resolve => { toolResolve = resolve; }); - // Register a blocking tool - mcpServer.tool('blocking-tool', 'A blocking tool', {}, async () => { - await toolPromise; + // Register a tool that closes its own SSE stream via extra callback + mcpServer.tool('close-stream-tool', 'Closes its own stream', {}, async (_args, extra) => { + // Close the SSE stream for this request + extra.closeSSEStream?.(); + streamCloseCalled = true; + + // Wait before returning so we can observe the stream closure + await toolCompletePromise; return { content: [{ type: 'text', text: 'Done' }] }; }); @@ -1771,7 +1777,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { jsonrpc: '2.0', id: 100, method: 'tools/call', - params: { name: 'blocking-tool', arguments: {} } + params: { name: 'close-stream-tool', arguments: {} } }; const postResponse = await fetch(baseUrl, { @@ -1792,8 +1798,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { // Read the priming event await reader!.read(); - // Close the SSE stream - transport.closeSSEStream(100); + // Wait a moment for the tool to call closeSSEStream + await new Promise(resolve => setTimeout(resolve, 100)); + expect(streamCloseCalled).toBe(true); // Stream should now be closed const { done } = await reader!.read(); @@ -1916,6 +1923,261 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { // Verify closeSSEStream callback was NOT provided expect(receivedCloseSSEStream).toBeUndefined(); }); + + it('should provide closeStandaloneSSEStream callback in extra when eventStore is configured', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 1000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Track whether closeStandaloneSSEStream callback was provided + let receivedCloseStandaloneSSEStream: (() => void) | undefined; + + // Register a tool that captures the extra.closeStandaloneSSEStream callback + mcpServer.tool('test-standalone-callback-tool', 'Test tool', {}, async (_args, extra) => { + receivedCloseStandaloneSSEStream = extra.closeStandaloneSSEStream; + return { content: [{ type: 'text', text: 'Done' }] }; + }); + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Call the tool + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 203, + method: 'tools/call', + params: { name: 'test-standalone-callback-tool', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + }, + body: JSON.stringify(toolCallRequest) + }); + + expect(postResponse.status).toBe(200); + + // Read all events to completion + const reader = postResponse.body?.getReader(); + while (true) { + const { done } = await reader!.read(); + if (done) break; + } + + // Verify closeStandaloneSSEStream callback was provided + expect(receivedCloseStandaloneSSEStream).toBeDefined(); + expect(typeof receivedCloseStandaloneSSEStream).toBe('function'); + }); + + it('should close standalone GET SSE stream when extra.closeStandaloneSSEStream is called', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 1000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Register a tool that closes the standalone SSE stream via extra callback + mcpServer.tool('close-standalone-stream-tool', 'Closes standalone stream', {}, async (_args, extra) => { + extra.closeStandaloneSSEStream?.(); + return { content: [{ type: 'text', text: 'Stream closed' }] }; + }); + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Open a standalone GET SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + expect(sseResponse.status).toBe(200); + + const getReader = sseResponse.body?.getReader(); + + // Send a notification to confirm GET stream is established + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Stream established' }); + + // Read the notification to confirm stream is working + const { value } = await getReader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain('id: '); + expect(text).toContain('Stream established'); + + // Call the tool that closes the standalone SSE stream + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 300, + method: 'tools/call', + params: { name: 'close-standalone-stream-tool', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + }, + body: JSON.stringify(toolCallRequest) + }); + expect(postResponse.status).toBe(200); + + // Read the POST response to completion + const postReader = postResponse.body?.getReader(); + while (true) { + const { done } = await postReader!.read(); + if (done) break; + } + + // GET stream should now be closed - use a race with timeout to avoid hanging + const readPromise = getReader!.read(); + const timeoutPromise = new Promise<{ done: boolean; value: undefined }>((_, reject) => + setTimeout(() => reject(new Error('Stream did not close in time')), 1000) + ); + + const { done } = await Promise.race([readPromise, timeoutPromise]); + expect(done).toBe(true); + }); + + it('should allow client to reconnect after standalone SSE stream is closed via extra.closeStandaloneSSEStream', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 1000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Register a tool that closes the standalone SSE stream + mcpServer.tool('close-standalone-for-reconnect', 'Closes standalone stream', {}, async (_args, extra) => { + extra.closeStandaloneSSEStream?.(); + return { content: [{ type: 'text', text: 'Stream closed' }] }; + }); + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Open a standalone GET SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + expect(sseResponse.status).toBe(200); + + const getReader = sseResponse.body?.getReader(); + + // Send a notification to get an event ID + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Initial message' }); + + // Read the notification to get the event ID + const { value } = await getReader!.read(); + const text = new TextDecoder().decode(value); + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + const lastEventId = idMatch![1]; + + // Call the tool to close the standalone SSE stream + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 301, + method: 'tools/call', + params: { name: 'close-standalone-for-reconnect', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + }, + body: JSON.stringify(toolCallRequest) + }); + expect(postResponse.status).toBe(200); + + // Read the POST response to completion + const postReader = postResponse.body?.getReader(); + while (true) { + const { done } = await postReader!.read(); + if (done) break; + } + + // Wait for GET stream to close - use a race with timeout + const readPromise = getReader!.read(); + const timeoutPromise = new Promise<{ done: boolean; value: undefined }>((_, reject) => + setTimeout(() => reject(new Error('Stream did not close in time')), 1000) + ); + const { done } = await Promise.race([readPromise, timeoutPromise]); + expect(done).toBe(true); + + // Send a notification while client is disconnected + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed while disconnected' }); + + // Client reconnects with Last-Event-ID + const reconnectResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26', + 'last-event-id': lastEventId + } + }); + expect(reconnectResponse.status).toBe(200); + + // Read the replayed notification + const reconnectReader = reconnectResponse.body?.getReader(); + let allText = ''; + const readWithTimeout = async () => { + const timeout = setTimeout(() => reconnectReader!.cancel(), 2000); + try { + while (!allText.includes('Missed while disconnected')) { + const { value, done } = await reconnectReader!.read(); + if (done) break; + allText += new TextDecoder().decode(value); + } + } finally { + clearTimeout(timeout); + } + }; + await readWithTimeout(); + + // Verify we received the notification that was sent while disconnected + expect(allText).toContain('Missed while disconnected'); + }); }); // Test onsessionclosed callback diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index bdd8e516cb..1e04390cfe 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -651,13 +651,17 @@ export class StreamableHTTPServerTransport implements Transport { for (const message of messages) { // Build closeSSEStream callback for requests when eventStore is configured let closeSSEStream: (() => void) | undefined; + let closeStandaloneSSEStream: (() => void) | undefined; if (isJSONRPCRequest(message) && this._eventStore) { closeSSEStream = () => { this.closeSSEStream(message.id); }; + closeStandaloneSSEStream = () => { + this.closeStandaloneSSEStream(); + }; } - this.onmessage?.(message, { authInfo, requestInfo, closeSSEStream }); + this.onmessage?.(message, { authInfo, requestInfo, closeSSEStream, closeStandaloneSSEStream }); } // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses // This will be handled by the send() method when responses are ready @@ -814,6 +818,18 @@ export class StreamableHTTPServerTransport implements Transport { } } + /** + * Close the standalone GET SSE stream, triggering client reconnection. + * Use this to implement polling behavior for server-initiated notifications. + */ + closeStandaloneSSEStream(): void { + const stream = this._streamMapping.get(this._standaloneSseStreamId); + if (stream) { + stream.end(); + this._streamMapping.delete(this._standaloneSseStreamId); + } + } + async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { let requestId = options?.relatedRequestId; if (isJSONRPCResponse(message) || isJSONRPCError(message)) { @@ -829,19 +845,21 @@ export class StreamableHTTPServerTransport implements Transport { if (isJSONRPCResponse(message) || isJSONRPCError(message)) { throw new Error('Cannot send a response on a standalone SSE stream unless resuming a previous client request'); } - const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId); - if (standaloneSse === undefined) { - // The spec says the server MAY send messages on the stream, so it's ok to discard if no stream - return; - } // Generate and store event ID if event store is provided + // Store even if stream is disconnected so events can be replayed on reconnect let eventId: string | undefined; if (this._eventStore) { // Stores the event and gets the generated event ID eventId = await this._eventStore.storeEvent(this._standaloneSseStreamId, message); } + const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId); + if (standaloneSse === undefined) { + // Stream is disconnected - event is stored for replay, nothing more to do + return; + } + // Send the message to the standalone SSE stream this.writeSSEEvent(standaloneSse, message, eventId); return; diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ce25e45fb0..e195478f27 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -290,6 +290,13 @@ export type RequestHandlerExtra void; + + /** + * Closes the standalone GET SSE stream, triggering client reconnection. + * Only available when using StreamableHTTPServerTransport with eventStore configured. + * Use this to implement polling behavior for server-initiated notifications. + */ + closeStandaloneSSEStream?: () => void; }; /** @@ -736,7 +743,8 @@ export abstract class Protocol void; + + /** + * Callback to close the standalone GET SSE stream, triggering client reconnection. + * Only available when using StreamableHTTPServerTransport with eventStore configured. + */ + closeStandaloneSSEStream?: () => void; } /* JSON-RPC types */ From a6ee2cb3c234960b6c448eb9ffb4e442233e6c10 Mon Sep 17 00:00:00 2001 From: Matt <77928207+mattzcarey@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:02:13 +0000 Subject: [PATCH 8/8] fix: normalize null to undefined in ElicitResultSchema content field (#1204) --- src/types.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 63e33678b4..03acc3e6a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1831,8 +1831,13 @@ export const ElicitResultSchema = ResultSchema.extend({ /** * The submitted form data, only present when action is "accept". * Contains values matching the requested schema. + * Per MCP spec, content is "typically omitted" for decline/cancel actions. + * We normalize null to undefined for leniency while maintaining type compatibility. */ - content: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional() + content: z.preprocess( + val => (val === null ? undefined : val), + z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional() + ) }); /* Autocomplete */