From 074d8344b1bcaf85abb5079d3b19ce4997aa71e7 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 17 Aug 2026 16:37:17 +0000 Subject: [PATCH 1/4] docs: cover tool content types, prompt image content, elicitation defaults, ping, legacy SSE serving No-Verification-Needed: docs and guide-companion examples only --- docs/clients/calling.md | 20 +++++++ docs/protocol-versions.md | 1 + docs/servers/elicitation.md | 40 +++++++++++++ docs/servers/prompts.md | 43 ++++++++++++- docs/servers/tools.md | 60 +++++++++++++++++++ docs/serving/legacy-clients.md | 32 +++++++++- examples/guides/clients/calling.examples.ts | 6 ++ .../guides/servers/elicitation.examples.ts | 49 +++++++++++++++ examples/guides/servers/prompts.examples.ts | 30 ++++++++++ examples/guides/servers/tools.examples.ts | 32 ++++++++++ .../guides/serving/legacy-clients.examples.ts | 29 +++++++++ examples/package.json | 1 + examples/tsconfig.json | 1 + pnpm-lock.yaml | 3 + 14 files changed, 344 insertions(+), 3 deletions(-) diff --git a/docs/clients/calling.md b/docs/clients/calling.md index 581abe4aea..b833b27b17 100644 --- a/docs/clients/calling.md +++ b/docs/clients/calling.md @@ -169,6 +169,25 @@ The updates stream in while the call is still pending; the return type does not [ { type: 'text', text: '2 orders exported as csv' } ] ``` +## Check the connection + +`ping` sends a `ping` request and resolves with the empty result the server returns; the SDK answers a `ping` on both sides automatically, so neither side registers a handler. + +```ts source="../../examples/guides/clients/calling.examples.ts#ping_basic" +const pong = await client.ping({ timeout: 5000 }); +console.log(pong); +``` + +The `orders` server answers at once: + +``` +{} +``` + +A server that stops answering rejects the call with an `SdkError` coded `REQUEST_TIMEOUT` once `timeout` elapses. + +`ping` is a 2025-era method — see [Protocol versions](../protocol-versions.md). + ## Recap - `listTools`, `listResources`, `listResourceTemplates`, and `listPrompts` aggregate every page; `{ cursor }` fetches a single raw page and `listMaxPages` caps the walk. @@ -176,3 +195,4 @@ The updates stream in while the call is still pending; the return type does not - `readResource({ uri })` and `getPrompt({ name, arguments })` follow the same list-then-fetch shape as tools. - `complete()` returns the server's suggestions for a prompt or resource-template argument. - `onprogress` in the request options streams progress updates without changing the call's return type. +- `ping()` checks that the server still answers; both sides answer pings automatically. diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index ba1338b0d2..06e3d174f2 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -171,6 +171,7 @@ This table is the only copy of the era differences in these docs. `getProtocolEr | `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `logLevel` `_meta` envelope key (absent = no logs) | | HTTP `400` with a JSON-RPC error body | `SdkHttpError` | `ProtocolError`, delivered in-band | | Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` | +| Liveness check | `client.ping()` | not defined — outbound call rejects per the era-mismatch row | ## Separate deprecation from era diff --git a/docs/servers/elicitation.md b/docs/servers/elicitation.md index a4063512d7..737c4e55a0 100644 --- a/docs/servers/elicitation.md +++ b/docs/servers/elicitation.md @@ -123,6 +123,45 @@ server.registerTool( [ { type: 'text', text: 'Declined - nothing deleted.' } ] ``` +## Prefill a field with a default + +Set `default` on a field and the client renders the form with that value already filled in. + +```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitDefault" +server.registerTool( + 'export-report', + { + description: 'Export a report after the user picks a format', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }, ctx) => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Export ${name} as which format?`, + requestedSchema: { + type: 'object', + properties: { format: { type: 'string', title: 'Format', enum: ['pdf', 'csv'], default: 'pdf' } }, + required: ['format'] + } + }); + if (result.action !== 'accept') { + return { content: [{ type: 'text', text: `Export ${result.action}.` }] }; + } + return { content: [{ type: 'text', text: `Exported ${name} as ${result.content?.format}.` }] }; + } +); +``` + +`requestedSchema` reaches the client unchanged, `default` included; the end user submits the prefilled `pdf` or picks `csv`. An accept with `format` left out still returns: + +``` +[ { type: 'text', text: 'Exported quarterly-sales as pdf.' } ] +``` + +::: info +A client that declares `elicitation: { form: { applyDefaults: true } }` — an SDK flag, not a protocol capability — fills defaulted fields the end user leaves out before the accept reaches your handler; the output above is that case. +::: + ## Send the end user to a URL **URL mode** replaces the form with a browser flow: pass `url` and a unique `elicitationId` instead of `requestedSchema`. @@ -181,5 +220,6 @@ Elicitation only works against a client that declared the `elicitation` capabili - `ctx.mcpReq.elicitInput` sends an `elicitation/create` request mid-handler and resolves with the end user's answer. - Form mode carries a `message` and a flat JSON-Schema `requestedSchema`; the SDK validates accepted content against it. - `result.action` is `accept`, `decline`, or `cancel`; `result.content` is present only on accept. +- `default` on a `requestedSchema` field prefills the form; a client that declares `applyDefaults` fills the field in when the end user leaves it out. - URL mode hands the end user a browser flow — use it for anything sensitive. - Calls against a client that never declared the `elicitation` capability fail before reaching the wire. diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md index 141b1b2f09..b84fa18304 100644 --- a/docs/servers/prompts.md +++ b/docs/servers/prompts.md @@ -116,6 +116,47 @@ server.registerPrompt( The host hands the messages to the model in order, so the trailing `assistant` message becomes the start of its reply. `content` accepts the same union a tool result does: `text`, `image`, `audio`, `resource_link`, and `resource`. +## Add an image to a message + +An `image` block carries base64 `data` and a `mimeType`; pair it with a `text` block that says what to do with the image. + +```ts source="../../examples/guides/servers/prompts.examples.ts#registerPrompt_image" +server.registerPrompt( + 'describe-image', + { + description: 'Describe an image for alt text', + argsSchema: z.object({ imageBase64: z.string().describe('Base64-encoded PNG') }) + }, + ({ imageBase64 }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'image' as const, data: imageBase64, mimeType: 'image/png' } + }, + { + role: 'user' as const, + content: { type: 'text' as const, text: 'Write one sentence of alt text for this image.' } + } + ] + }) +); +``` + +`prompts/get` returns the image block as the first message, bytes unchanged: + +``` +{ + role: 'user', + content: { + type: 'image', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + mimeType: 'image/png' + } +} +``` + +`audio` takes the same shape: base64 `data` plus `mimeType`. + ## Embed a resource in a message `type: 'resource'` puts a resource's contents inside a message. Register the resource as usual — see [Resources](./resources.md) — and embed the same `uri`, `mimeType`, and `text` in the prompt. @@ -201,5 +242,5 @@ The client sends `completion/complete` with the characters typed so far; the SDK - `argsSchema` is one Zod object: the advertised argument list, argument validation, and the callback's argument types. - Arguments that fail the schema reject `prompts/get` with a `-32602` protocol error; the callback never runs. - The callback returns `{ messages }`; each message names a `role` and one `content` block. -- A message can embed a registered resource's contents with `type: 'resource'`. +- A message can carry an `image` (base64 `data` plus `mimeType`) or embed a registered resource's contents with `type: 'resource'`. - `completable()` adds per-argument autocompletion. diff --git a/docs/servers/tools.md b/docs/servers/tools.md index 554669c144..5325bbcb05 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -129,6 +129,65 @@ Calling `product-details` with `{ name: 'Travel mug' }` returns both renderings: The wire encoding of structured results differs by protocol era — see [Protocol versions](../protocol-versions.md). +## Return other content types + +One result can mix content blocks: `image` and `audio` carry base64 `data` with a `mimeType`; `resource` embeds a resource's contents inline; `resource_link` names a resource by `uri` without its bytes. + +```ts source="../../examples/guides/servers/tools.examples.ts#registerTool_contentTypes" +// Base64 payloads; read yours from disk: readFileSync('card.png').toString('base64') +const cardPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; +const spokenNameWav = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='; + +server.registerTool( + 'product-card', + { + description: 'Render one product as an image, a spoken name, and its catalog record', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }) => { + const product = catalog.find(candidate => candidate.name === name); + if (!product) throw new Error(`No product named ${name}`); + return { + content: [ + { type: 'image', data: cardPng, mimeType: 'image/png' }, + { type: 'audio', data: spokenNameWav, mimeType: 'audio/wav' }, + { + type: 'resource', + resource: { uri: `catalog://products/${product.name}`, mimeType: 'application/json', text: JSON.stringify(product) } + } + ] + }; + } +); +``` + +Calling `product-card` with `{ name: 'Travel mug' }` returns the three blocks as written: + +``` +[ + { + type: 'image', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + mimeType: 'image/png' + }, + { + type: 'audio', + data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=', + mimeType: 'audio/wav' + }, + { + type: 'resource', + resource: { + uri: 'catalog://products/Travel mug', + mimeType: 'application/json', + text: '{"name":"Travel mug","price":24}' + } + } +] +``` + +The blocks reach the client exactly as returned, and the embedded `resource` arrives without a `resources/read` round trip. + ## Annotate the tool `title` is the display name; `annotations` are behavior hints for the client. @@ -156,4 +215,5 @@ A tool that takes no arguments omits `inputSchema`. Annotations never change how - The one schema yields the advertised JSON Schema, argument validation, and the handler's argument types. - Arguments that fail the schema come back as an `isError: true` tool result; the handler never runs. - `outputSchema` plus `structuredContent` add machine-readable results, validated before they leave the server. +- `content` blocks are `text`, `image`, `audio`, `resource_link`, or an embedded `resource`; one result can mix them. - `title` and `annotations` describe the tool to clients and never change execution. diff --git a/docs/serving/legacy-clients.md b/docs/serving/legacy-clients.md index 62980e01cc..eb4820ce89 100644 --- a/docs/serving/legacy-clients.md +++ b/docs/serving/legacy-clients.md @@ -91,7 +91,35 @@ Behind an Express body parser the Node stream is already drained: build the `Req The v2 server never serves the HTTP+SSE transport. An SSE server moving to v2 moves to Streamable HTTP — `createMcpHandler` above — as part of the [v2 upgrade](../migration/upgrade-to-v2.md). -The client side keeps `SSEClientTransport`, so a v2 `Client` still reaches old SSE servers. For a server deployment that cannot move yet, a frozen v1 copy of the transport ships as `@modelcontextprotocol/server-legacy/sse` (deprecated). +The client side keeps `SSEClientTransport`, so a v2 `Client` still reaches old SSE servers. For a server deployment that cannot move yet, a frozen v1 copy of the transport ships as `@modelcontextprotocol/server-legacy/sse` (deprecated, planned for removal in v3). + +Mount the frozen transport on two Express routes: `GET /sse` opens the stream and `POST /messages` delivers each client message to the session its `sessionId` query names. + +```ts source="../../examples/guides/serving/legacy-clients.examples.ts#SSEServerTransport_express" +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'; + +const sessions = new Map(); +const sseApp = createMcpExpressApp(); + +sseApp.get('/sse', async (_req, res) => { + const transport = new SSEServerTransport('/messages', res); + sessions.set(transport.sessionId, transport); + transport.onclose = () => sessions.delete(transport.sessionId); + await buildServer().connect(transport); +}); + +sseApp.post('/messages', async (req, res) => { + const transport = sessions.get(String(req.query.sessionId)); + if (!transport) { + res.status(404).send('Session not found'); + return; + } + await transport.handlePostMessage(req, res, req.body); +}); +``` + +Each `GET /sse` connects a fresh instance from `buildServer` and answers with an `endpoint` event naming `/messages?sessionId=…`; the client POSTs every JSON-RPC message there and reads responses off the stream. ## Recap @@ -99,4 +127,4 @@ The client side keeps `SSEClientTransport`, so a v2 `Client` still reaches old S - The default HTTP posture is per request and stateless: legacy `GET` and `DELETE` session operations answer `405`. - `serveStdio` decides the era once per connection; its default is `'serve'`. - `isLegacyRequest` in front of a strict handler keeps an existing sessionful 2025 deployment serving its clients. -- The v2 server never serves SSE; the frozen v1 transport is `@modelcontextprotocol/server-legacy/sse`, and the client keeps `SSEClientTransport`. +- The v2 server never serves SSE; the frozen v1 `SSEServerTransport` in `@modelcontextprotocol/server-legacy/sse` mounts on `GET /sse` + `POST /messages`, and the client keeps `SSEClientTransport`. diff --git a/examples/guides/clients/calling.examples.ts b/examples/guides/clients/calling.examples.ts index a4526caeb2..14e7ae80f6 100644 --- a/examples/guides/clients/calling.examples.ts +++ b/examples/guides/clients/calling.examples.ts @@ -201,5 +201,11 @@ const exported = await client.callTool( console.log(exported.content); //#endregion callTool_progress +// "Check the connection" — the empty result the page quotes. +//#region ping_basic +const pong = await client.ping({ timeout: 5000 }); +console.log(pong); +//#endregion ping_basic + await client.close(); await server.close(); diff --git a/examples/guides/servers/elicitation.examples.ts b/examples/guides/servers/elicitation.examples.ts index da63ba1ec4..96396ee567 100644 --- a/examples/guides/servers/elicitation.examples.ts +++ b/examples/guides/servers/elicitation.examples.ts @@ -78,6 +78,37 @@ server.registerTool( ); //#endregion registerTool_elicitActions +// "Prefill a field with a default" — the requested schema carries `default`. +// Wrapped so the harness can register the same tool on a second server whose +// client declares `applyDefaults`; the page's fence shows the body unindented. +function registerExportReport(server: McpServer): void { + //#region registerTool_elicitDefault + server.registerTool( + 'export-report', + { + description: 'Export a report after the user picks a format', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }, ctx) => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Export ${name} as which format?`, + requestedSchema: { + type: 'object', + properties: { format: { type: 'string', title: 'Format', enum: ['pdf', 'csv'], default: 'pdf' } }, + required: ['format'] + } + }); + if (result.action !== 'accept') { + return { content: [{ type: 'text', text: `Export ${result.action}.` }] }; + } + return { content: [{ type: 'text', text: `Exported ${name} as ${result.content?.format}.` }] }; + } + ); + //#endregion registerTool_elicitDefault +} +registerExportReport(server); + // "Send the end user to a URL" — url mode hands the browser flow to the client. //#region registerTool_elicitUrl server.registerTool( @@ -144,6 +175,24 @@ client.setRequestHandler('elicitation/create', async () => ({ action: 'decline' const declined = await client.callTool({ name: 'delete-dataset', arguments: { name: 'staging-snapshots' } }); console.log(declined.content); +// "Prefill a field with a default" — a client that declares `applyDefaults` +// accepts with `format` left out; the SDK fills it from the schema before the +// accept reaches the handler. +const defaultsClient = new Client( + { name: 'defaults-host', version: '1.0.0' }, + { capabilities: { elicitation: { form: { applyDefaults: true } } } } +); +defaultsClient.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: {} })); +const [defaultsClientTransport, defaultsServerTransport] = InMemoryTransport.createLinkedPair(); +const defaultsServer = new McpServer({ name: 'feedback', version: '1.0.0' }); +registerExportReport(defaultsServer); +await defaultsServer.connect(defaultsServerTransport); +await defaultsClient.connect(defaultsClientTransport); +const exported = await defaultsClient.callTool({ name: 'export-report', arguments: { name: 'quarterly-sales' } }); +console.log(exported.content); +await defaultsClient.close(); +await defaultsServer.close(); + // "Require the elicitation capability" — the same form tool served to a client // that never declared the elicitation capability. elicitInput throws before // anything reaches the wire and the message becomes the tool result. diff --git a/examples/guides/servers/prompts.examples.ts b/examples/guides/servers/prompts.examples.ts index c7fcd84664..77505dd50d 100644 --- a/examples/guides/servers/prompts.examples.ts +++ b/examples/guides/servers/prompts.examples.ts @@ -60,6 +60,28 @@ server.registerPrompt( ); //#endregion registerPrompt_messages +//#region registerPrompt_image +server.registerPrompt( + 'describe-image', + { + description: 'Describe an image for alt text', + argsSchema: z.object({ imageBase64: z.string().describe('Base64-encoded PNG') }) + }, + ({ imageBase64 }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'image' as const, data: imageBase64, mimeType: 'image/png' } + }, + { + role: 'user' as const, + content: { type: 'text' as const, text: 'Write one sentence of alt text for this image.' } + } + ] + }) +); +//#endregion registerPrompt_image + //#region registerPrompt_embedResource const styleGuide = '- Prefer const over let.\n- No single-letter identifiers.'; @@ -152,6 +174,14 @@ try { } //#endregion getPrompt_invalid +// "Add an image to a message" — the image message the page quotes; the +// argument is a real (1x1) PNG. +const described = await client.getPrompt({ + name: 'describe-image', + arguments: { imageBase64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' } +}); +console.log(described.messages[0]); + // "Embed a resource in a message" — the embedded-resource message the page quotes. const review = await client.getPrompt({ name: 'review-against-style', diff --git a/examples/guides/servers/tools.examples.ts b/examples/guides/servers/tools.examples.ts index 90548250da..8046956f16 100644 --- a/examples/guides/servers/tools.examples.ts +++ b/examples/guides/servers/tools.examples.ts @@ -76,6 +76,34 @@ server.registerTool( ); //#endregion registerTool_annotations +//#region registerTool_contentTypes +// Base64 payloads; read yours from disk: readFileSync('card.png').toString('base64') +const cardPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; +const spokenNameWav = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='; + +server.registerTool( + 'product-card', + { + description: 'Render one product as an image, a spoken name, and its catalog record', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }) => { + const product = catalog.find(candidate => candidate.name === name); + if (!product) throw new Error(`No product named ${name}`); + return { + content: [ + { type: 'image', data: cardPng, mimeType: 'image/png' }, + { type: 'audio', data: spokenNameWav, mimeType: 'audio/wav' }, + { + type: 'resource', + resource: { uri: `catalog://products/${product.name}`, mimeType: 'application/json', text: JSON.stringify(product) } + } + ] + }; + } +); +//#endregion registerTool_contentTypes + // --------------------------------------------------------------------------- // Harness (not shown on the page). An in-memory client drives the calls whose // output servers/tools.md quotes verbatim. Any MCP client behaves the same. @@ -105,6 +133,10 @@ console.log(rejected); const details = await client.callTool({ name: 'product-details', arguments: { name: 'Travel mug' } }); console.log(details); +// "Return other content types" — the three-block result the page quotes. +const card = await client.callTool({ name: 'product-card', arguments: { name: 'Travel mug' } }); +console.log(card.content); + // Proof for the page's ::: tip — `.describe()` lands in the JSON Schema that // `tools/list` advertises for the `query` argument. Throws (non-zero exit) if // the claim is false. diff --git a/examples/guides/serving/legacy-clients.examples.ts b/examples/guides/serving/legacy-clients.examples.ts index 1a66c470ff..10eed5a247 100644 --- a/examples/guides/serving/legacy-clients.examples.ts +++ b/examples/guides/serving/legacy-clients.examples.ts @@ -56,6 +56,35 @@ async function serve(request: Request): Promise { } //#endregion isLegacyRequest_route +// --------------------------------------------------------------------------- +// "Know where SSE went" — the frozen v1 transport on two Express routes. The +// app never listens here: docs companions never bind a port. +// --------------------------------------------------------------------------- + +//#region SSEServerTransport_express +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'; + +const sessions = new Map(); +const sseApp = createMcpExpressApp(); + +sseApp.get('/sse', async (_req, res) => { + const transport = new SSEServerTransport('/messages', res); + sessions.set(transport.sessionId, transport); + transport.onclose = () => sessions.delete(transport.sessionId); + await buildServer().connect(transport); +}); + +sseApp.post('/messages', async (req, res) => { + const transport = sessions.get(String(req.query.sessionId)); + if (!transport) { + res.status(404).send('Session not found'); + return; + } + await transport.handlePostMessage(req, res, req.body); +}); +//#endregion SSEServerTransport_express + // --------------------------------------------------------------------------- // Harness (not shown on the page). A 2025-era client opens with a claim-less // `initialize` POST; build that request twice and send it to the strict diff --git a/examples/package.json b/examples/package.json index 07788657ad..79b480721a 100644 --- a/examples/package.json +++ b/examples/package.json @@ -31,6 +31,7 @@ "@modelcontextprotocol/hono": "workspace:^", "@modelcontextprotocol/node": "workspace:^", "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/server-legacy": "workspace:^", "@valibot/to-json-schema": "catalog:devTools", "ajv": "catalog:runtimeShared", "arktype": "catalog:devTools", diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 6a35348636..9405854c1f 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -11,6 +11,7 @@ "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], "@modelcontextprotocol/server/validators/ajv": ["./node_modules/@modelcontextprotocol/server/src/validators/ajv.ts"], "@modelcontextprotocol/server/validators/cf-worker": ["./node_modules/@modelcontextprotocol/server/src/validators/cfWorker.ts"], + "@modelcontextprotocol/server-legacy/sse": ["./node_modules/@modelcontextprotocol/server-legacy/src/sse/index.ts"], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 839b152070..85218d9ea0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -331,6 +331,9 @@ importers: '@modelcontextprotocol/server': specifier: workspace:^ version: link:../packages/server + '@modelcontextprotocol/server-legacy': + specifier: workspace:^ + version: link:../packages/server-legacy '@valibot/to-json-schema': specifier: catalog:devTools version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) From 465dc347ae37af85cee96046e9930da83251ff53 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 18 Aug 2026 10:15:56 +0000 Subject: [PATCH 2/4] docs: split 400/404 in the SSE example, encode the resource URI, note Express options No-Verification-Needed: docs and guide-companion examples only --- docs/servers/tools.md | 8 ++++++-- docs/serving/legacy-clients.md | 11 ++++++++--- examples/guides/servers/tools.examples.ts | 6 +++++- examples/guides/serving/legacy-clients.examples.ts | 9 +++++++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/servers/tools.md b/docs/servers/tools.md index 5325bbcb05..aff0fc430f 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -153,7 +153,11 @@ server.registerTool( { type: 'audio', data: spokenNameWav, mimeType: 'audio/wav' }, { type: 'resource', - resource: { uri: `catalog://products/${product.name}`, mimeType: 'application/json', text: JSON.stringify(product) } + resource: { + uri: `catalog://products/${encodeURIComponent(product.name)}`, + mimeType: 'application/json', + text: JSON.stringify(product) + } } ] }; @@ -178,7 +182,7 @@ Calling `product-card` with `{ name: 'Travel mug' }` returns the three blocks as { type: 'resource', resource: { - uri: 'catalog://products/Travel mug', + uri: 'catalog://products/Travel%20mug', mimeType: 'application/json', text: '{"name":"Travel mug","price":24}' } diff --git a/docs/serving/legacy-clients.md b/docs/serving/legacy-clients.md index eb4820ce89..b216293753 100644 --- a/docs/serving/legacy-clients.md +++ b/docs/serving/legacy-clients.md @@ -93,14 +93,14 @@ The v2 server never serves the HTTP+SSE transport. An SSE server moving to v2 mo The client side keeps `SSEClientTransport`, so a v2 `Client` still reaches old SSE servers. For a server deployment that cannot move yet, a frozen v1 copy of the transport ships as `@modelcontextprotocol/server-legacy/sse` (deprecated, planned for removal in v3). -Mount the frozen transport on two Express routes: `GET /sse` opens the stream and `POST /messages` delivers each client message to the session its `sessionId` query names. +Mount the frozen transport on two Express routes: `GET /sse` opens the stream and `POST /messages` delivers each client message to the session its `sessionId` query names. `createMcpExpressApp` takes the same options as on the [Express](./express.md) page — set `host` (or `allowedHosts`) for a public deployment and raise `jsonLimit` above Express's 100kb default, since the SSE transport itself accepts messages up to 4mb. ```ts source="../../examples/guides/serving/legacy-clients.examples.ts#SSEServerTransport_express" import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'; const sessions = new Map(); -const sseApp = createMcpExpressApp(); +const sseApp = createMcpExpressApp({ host: 'sse.example.com', jsonLimit: '4mb' }); sseApp.get('/sse', async (_req, res) => { const transport = new SSEServerTransport('/messages', res); @@ -110,7 +110,12 @@ sseApp.get('/sse', async (_req, res) => { }); sseApp.post('/messages', async (req, res) => { - const transport = sessions.get(String(req.query.sessionId)); + const sessionId = req.query.sessionId; + if (typeof sessionId !== 'string') { + res.status(400).send('Missing sessionId parameter'); + return; + } + const transport = sessions.get(sessionId); if (!transport) { res.status(404).send('Session not found'); return; diff --git a/examples/guides/servers/tools.examples.ts b/examples/guides/servers/tools.examples.ts index 8046956f16..bacd56fa30 100644 --- a/examples/guides/servers/tools.examples.ts +++ b/examples/guides/servers/tools.examples.ts @@ -96,7 +96,11 @@ server.registerTool( { type: 'audio', data: spokenNameWav, mimeType: 'audio/wav' }, { type: 'resource', - resource: { uri: `catalog://products/${product.name}`, mimeType: 'application/json', text: JSON.stringify(product) } + resource: { + uri: `catalog://products/${encodeURIComponent(product.name)}`, + mimeType: 'application/json', + text: JSON.stringify(product) + } } ] }; diff --git a/examples/guides/serving/legacy-clients.examples.ts b/examples/guides/serving/legacy-clients.examples.ts index 10eed5a247..f7f27e2907 100644 --- a/examples/guides/serving/legacy-clients.examples.ts +++ b/examples/guides/serving/legacy-clients.examples.ts @@ -66,7 +66,7 @@ import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'; const sessions = new Map(); -const sseApp = createMcpExpressApp(); +const sseApp = createMcpExpressApp({ host: 'sse.example.com', jsonLimit: '4mb' }); sseApp.get('/sse', async (_req, res) => { const transport = new SSEServerTransport('/messages', res); @@ -76,7 +76,12 @@ sseApp.get('/sse', async (_req, res) => { }); sseApp.post('/messages', async (req, res) => { - const transport = sessions.get(String(req.query.sessionId)); + const sessionId = req.query.sessionId; + if (typeof sessionId !== 'string') { + res.status(400).send('Missing sessionId parameter'); + return; + } + const transport = sessions.get(sessionId); if (!transport) { res.status(404).send('Session not found'); return; From 472c0ce822161ab71efded9418fe428b46f63869 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 18 Aug 2026 10:30:30 +0000 Subject: [PATCH 3/4] docs: bind the SSE example to 0.0.0.0 with allowedHosts instead of a bare host No-Verification-Needed: docs and guide-companion examples only --- docs/serving/legacy-clients.md | 4 ++-- examples/guides/serving/legacy-clients.examples.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/serving/legacy-clients.md b/docs/serving/legacy-clients.md index b216293753..9c26d29080 100644 --- a/docs/serving/legacy-clients.md +++ b/docs/serving/legacy-clients.md @@ -93,14 +93,14 @@ The v2 server never serves the HTTP+SSE transport. An SSE server moving to v2 mo The client side keeps `SSEClientTransport`, so a v2 `Client` still reaches old SSE servers. For a server deployment that cannot move yet, a frozen v1 copy of the transport ships as `@modelcontextprotocol/server-legacy/sse` (deprecated, planned for removal in v3). -Mount the frozen transport on two Express routes: `GET /sse` opens the stream and `POST /messages` delivers each client message to the session its `sessionId` query names. `createMcpExpressApp` takes the same options as on the [Express](./express.md) page — set `host` (or `allowedHosts`) for a public deployment and raise `jsonLimit` above Express's 100kb default, since the SSE transport itself accepts messages up to 4mb. +Mount the frozen transport on two Express routes: `GET /sse` opens the stream and `POST /messages` delivers each client message to the session its `sessionId` query names. `createMcpExpressApp` takes the same options as on the [Express](./express.md) page: binding beyond localhost drops the default `Host`/`Origin` validation, so name the hosts you serve in `allowedHosts`, and raise `jsonLimit` above Express's 100kb default, since the SSE transport itself accepts messages up to 4mb. ```ts source="../../examples/guides/serving/legacy-clients.examples.ts#SSEServerTransport_express" import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'; const sessions = new Map(); -const sseApp = createMcpExpressApp({ host: 'sse.example.com', jsonLimit: '4mb' }); +const sseApp = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['sse.example.com'], jsonLimit: '4mb' }); sseApp.get('/sse', async (_req, res) => { const transport = new SSEServerTransport('/messages', res); diff --git a/examples/guides/serving/legacy-clients.examples.ts b/examples/guides/serving/legacy-clients.examples.ts index f7f27e2907..aa65218612 100644 --- a/examples/guides/serving/legacy-clients.examples.ts +++ b/examples/guides/serving/legacy-clients.examples.ts @@ -66,7 +66,7 @@ import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'; const sessions = new Map(); -const sseApp = createMcpExpressApp({ host: 'sse.example.com', jsonLimit: '4mb' }); +const sseApp = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['sse.example.com'], jsonLimit: '4mb' }); sseApp.get('/sse', async (_req, res) => { const transport = new SSEServerTransport('/messages', res); From aabf3c4f25e3dc494637c94c4dd50dc161f59012 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 18 Aug 2026 10:43:54 +0000 Subject: [PATCH 4/4] docs: assert the quoted output in the new guide companions No-Verification-Needed: docs and guide-companion examples only --- examples/guides/clients/calling.examples.ts | 3 +++ examples/guides/servers/elicitation.examples.ts | 4 ++++ examples/guides/servers/prompts.examples.ts | 4 ++++ examples/guides/servers/tools.examples.ts | 4 ++++ 4 files changed, 15 insertions(+) diff --git a/examples/guides/clients/calling.examples.ts b/examples/guides/clients/calling.examples.ts index 14e7ae80f6..95adab995a 100644 --- a/examples/guides/clients/calling.examples.ts +++ b/examples/guides/clients/calling.examples.ts @@ -206,6 +206,9 @@ console.log(exported.content); const pong = await client.ping({ timeout: 5000 }); console.log(pong); //#endregion ping_basic +if (Object.keys(pong).length !== 0) { + throw new Error(`calling.md claim failed: ping resolved with ${JSON.stringify(pong)}`); +} await client.close(); await server.close(); diff --git a/examples/guides/servers/elicitation.examples.ts b/examples/guides/servers/elicitation.examples.ts index 96396ee567..b41e8ba899 100644 --- a/examples/guides/servers/elicitation.examples.ts +++ b/examples/guides/servers/elicitation.examples.ts @@ -190,6 +190,10 @@ await defaultsServer.connect(defaultsServerTransport); await defaultsClient.connect(defaultsClientTransport); const exported = await defaultsClient.callTool({ name: 'export-report', arguments: { name: 'quarterly-sales' } }); console.log(exported.content); +const exportedText = Array.isArray(exported.content) && exported.content[0]?.type === 'text' ? exported.content[0].text : undefined; +if (exported.isError || exportedText !== 'Exported quarterly-sales as pdf.') { + throw new Error(`elicitation.md claim failed: applyDefaults round returned ${JSON.stringify(exported.content)}`); +} await defaultsClient.close(); await defaultsServer.close(); diff --git a/examples/guides/servers/prompts.examples.ts b/examples/guides/servers/prompts.examples.ts index 77505dd50d..7ed0182f5c 100644 --- a/examples/guides/servers/prompts.examples.ts +++ b/examples/guides/servers/prompts.examples.ts @@ -181,6 +181,10 @@ const described = await client.getPrompt({ arguments: { imageBase64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' } }); console.log(described.messages[0]); +const imageMessage = described.messages[0]?.content; +if (imageMessage?.type !== 'image' || imageMessage.mimeType !== 'image/png') { + throw new Error(`prompts.md claim failed: describe-image first message is ${JSON.stringify(imageMessage)}`); +} // "Embed a resource in a message" — the embedded-resource message the page quotes. const review = await client.getPrompt({ diff --git a/examples/guides/servers/tools.examples.ts b/examples/guides/servers/tools.examples.ts index bacd56fa30..0719c9c22c 100644 --- a/examples/guides/servers/tools.examples.ts +++ b/examples/guides/servers/tools.examples.ts @@ -140,6 +140,10 @@ console.log(details); // "Return other content types" — the three-block result the page quotes. const card = await client.callTool({ name: 'product-card', arguments: { name: 'Travel mug' } }); console.log(card.content); +const cardTypes = Array.isArray(card.content) ? card.content.map(block => block.type) : []; +if (card.isError || cardTypes.join(',') !== 'image,audio,resource') { + throw new Error(`tools.md claim failed: product-card returned ${JSON.stringify(card.content)}`); +} // Proof for the page's ::: tip — `.describe()` lands in the JSON Schema that // `tools/list` advertises for the `query` argument. Throws (non-zero exit) if