From e3f39c1a812b36a9ba51018c4a789279457cc830 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:34:31 +0100 Subject: [PATCH 1/3] docs: add ROADMAP, VERSIONING and DEPENDENCY_POLICY for the v2 line (#2680) --- DEPENDENCY_POLICY.md | 35 +++++++++++++++++++++++++++++++ ROADMAP.md | 35 +++++++++++++++++++++++++++++++ VERSIONING.md | 49 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 DEPENDENCY_POLICY.md create mode 100644 ROADMAP.md create mode 100644 VERSIONING.md diff --git a/DEPENDENCY_POLICY.md b/DEPENDENCY_POLICY.md new file mode 100644 index 0000000000..6be03850f3 --- /dev/null +++ b/DEPENDENCY_POLICY.md @@ -0,0 +1,35 @@ +# Dependency Policy + +As a library consumed by downstream projects, the MCP TypeScript SDK takes a conservative approach to dependency updates. Dependencies are kept stable unless there is a specific reason to update, such as a security vulnerability, a bug fix, or a need for new functionality. + +This policy applies to every published package in this monorepo (`@modelcontextprotocol/core`, `client`, `server`, `server-legacy`, `codemod`, `node`, `express`, `hono`, `fastify`) and to the `v1.x` maintenance line (`@modelcontextprotocol/sdk`). + +## Update Triggers + +Dependencies are updated when: + +- A **security vulnerability** is disclosed (via GitHub security alerts). +- A bug in a dependency directly affects the SDK. +- A new dependency feature is needed for SDK development. +- A dependency drops support for a Node.js version the SDK still targets. +- A new MCP specification revision requires it. + +Routine version bumps without a clear motivation are avoided to minimize churn for downstream consumers. + +## What We Don't Do + +The SDK does not run scheduled version bumps for npm dependencies. Updating a dependency can force downstream consumers to adopt that update transitively, which can be disruptive for projects with strict dependency policies. + +Dependencies are only updated when there is a concrete reason, not simply because a newer version is available. + +## Automated Tooling + +- **GitHub security updates** are enabled at the repository level and automatically open pull requests for npm packages with known vulnerabilities. This is a GitHub repo setting, separate from the `dependabot.yml` configuration. +- **GitHub Actions versions** are kept up to date via Dependabot on a weekly schedule (see `.github/dependabot.yml`). +- **Supply-chain cooldown**: pnpm's `minimumReleaseAge` (see `pnpm-workspace.yaml`) keeps newly published versions out of the lockfile for 7 days (`minimumReleaseAgeExclude` lists the exceptions, currently the MCP conformance suite), and only an allow-listed set of dependencies may run install scripts (`onlyBuiltDependencies`). + +## Pinning and Ranges + +Ranges shared by more than one package live in the pnpm workspace catalogs (`pnpm-workspace.yaml`), so a version is declared once. Runtime dependencies use caret ranges (`^`) to allow compatible updates within a major version; exact versions of third-party runtime dependencies are pinned only when necessary to work around a specific issue. Dependencies between the SDK's own packages (`workspace:*`) publish as exact pins by design, so a released `client` or `server` always resolves the `core` it was built against. Framework integrations (`express`, `hono`, `fastify`) declare the framework as a peer dependency rather than bundling a copy. + +Runtime dependencies of published packages are kept to a minimum; adding one is a significant change under the discuss-before-you-code rule in `CONTRIBUTING.md`. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000000..377fc86a97 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,35 @@ +# Roadmap + +## Spec Implementation Tracking + +The SDK tracks implementation of MCP spec components via GitHub Projects, with a dedicated project board for each spec revision: + +- [2026-07-28 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/41) — implemented in v2.0.0 (released 2026-07-27, alongside the spec). +- [2025-11-25 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/26) — implemented in v1.23.0 (2025-11-25) and carried into v2, minus the experimental tasks component (SEP-1686), which v2 does not serve. + +Conformance against the 2025-11-25 and 2026-07-28 revisions runs on each push to `main` (and against 2025-11-25 on `v1.x`) via the [conformance workflow](https://github.com/modelcontextprotocol/typescript-sdk/actions/workflows/conformance.yml) using the [MCP conformance suite](https://github.com/modelcontextprotocol/conformance). + +## Current Focus Areas + +### v2 hardening + +v2.0.0 is the stable release line (`main`). Post-release work is tracked as issues on this repository and released as 2.x patch and minor releases (see `VERSIONING.md`): + +- Migration tooling and guides (`@modelcontextprotocol/codemod`, `docs/migration/`). +- Runtime coverage beyond Node.js (Bun, Deno, Cloudflare Workers, Vercel) and the framework integrations (`node`, `express`, `hono`, `fastify`). +- Documentation completeness for every non-experimental spec feature at https://ts.sdk.modelcontextprotocol.io/v2/. + +### Next Spec Revision + +The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). The SDK implements accepted SEPs as they are finalized so that support ships with the spec release, with a dedicated project board tracking component-level progress for that revision. + +### Extensions + +Protocol extensions are implemented as they stabilize and are not part of the core tier requirements: + +- Tasks (`io.modelcontextprotocol/tasks`) — [#2189](https://github.com/modelcontextprotocol/typescript-sdk/issues/2189). +- Client authentication extensions: Workload Identity Federation (SEP-1933) — [#2576](https://github.com/modelcontextprotocol/typescript-sdk/issues/2576); DPoP (SEP-1932). + +### v1.x Maintenance + +The `v1.x` branch (`@modelcontextprotocol/sdk`) continues to receive bug fixes and security updates for at least six months after the v2 release (2026-07-27). It targets the 2025-11-25 spec revision; new spec revisions are implemented on `main` only. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000000..787da06f3a --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,49 @@ +# Versioning Policy + +The MCP TypeScript SDK follows [Semantic Versioning 2.0.0](https://semver.org/) for every published package. + +## Packages and Version Groups + +The v2 SDK is a monorepo. Versions are managed with [Changesets](https://github.com/changesets/changesets) (`.changeset/`): + +- `@modelcontextprotocol/core`, `client`, `server`, `server-legacy` and `codemod` form a **fixed group** and always release together with the same version. +- The framework integrations `@modelcontextprotocol/node`, `express`, `hono` and `fastify` are versioned through Changesets alongside the fixed group: they bump whenever their `@modelcontextprotocol/server` peer range has to move, and independently for their own changes. +- `@modelcontextprotocol/core-internal` is private and carries no compatibility promise; the `@modelcontextprotocol/core/internal` entry point is likewise not covered by this policy and may change in any release. +- The `v1.x` branch continues to publish `@modelcontextprotocol/sdk` 1.x under the same rules (patch releases on `release-X.Y` npm tags; see `CONTRIBUTING.md`). + +## Version Format + +`MAJOR.MINOR.PATCH` + +- **MAJOR**: Incremented for breaking changes (see below). +- **MINOR**: Incremented for new features that are backward-compatible. +- **PATCH**: Incremented for backward-compatible bug fixes. + +## What Constitutes a Breaking Change + +The following changes are considered breaking and require a major version bump: + +- Removing or renaming a public API export (class, function, type, or constant). +- Changing the signature of a public function or method in a way that breaks existing callers (removing parameters, changing required/optional status, changing types). +- Removing or renaming a public type or interface field. +- Changing the behavior of an existing API in a way that breaks documented contracts. +- Dropping support for a Node.js LTS version. +- Removing support for a transport type. +- Dropping support for an MCP protocol revision the SDK previously negotiated (see `docs/protocol-versions.md`). + +The following are **not** considered breaking: + +- Adding new optional parameters to existing functions. +- Adding new exports, types, or interfaces. +- Adding new optional fields to existing types. +- Bug fixes that correct behavior to match documented intent. +- Internal refactoring that does not affect the public API. +- Adding support for new MCP spec revisions or features. +- Changes to dev dependencies or build tooling. + +## How Breaking Changes Are Communicated + +1. **Changelog**: Every consumer-facing change ships with a changeset; the per-package `CHANGELOG.md` and the GitHub release for each package tag document breaking changes with migration instructions. +2. **Deprecation**: When feasible, APIs are deprecated for at least one minor release before removal using `@deprecated` JSDoc annotations, which surface warnings through TypeScript tooling and editors. Protocol features the specification deprecates stay available for as long as the specification keeps them. +3. **Migration guide**: Major version releases include a migration guide (see `docs/migration/`) and, where practical, a codemod (`@modelcontextprotocol/codemod`). +4. **PR labels**: Pull requests containing breaking changes are labeled with `breaking change`. From 68523682fde27d5b77b7d52afdda887a97f23e00 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:42:45 +0100 Subject: [PATCH 2/3] docs: cover tool content types, prompt image content, elicitation defaults, ping, legacy SSE serving (#2679) --- docs/clients/calling.md | 20 ++++++ docs/protocol-versions.md | 1 + docs/servers/elicitation.md | 40 ++++++++++++ docs/servers/prompts.md | 43 ++++++++++++- docs/servers/tools.md | 64 +++++++++++++++++++ docs/serving/legacy-clients.md | 37 ++++++++++- examples/guides/clients/calling.examples.ts | 9 +++ .../guides/servers/elicitation.examples.ts | 53 +++++++++++++++ examples/guides/servers/prompts.examples.ts | 34 ++++++++++ examples/guides/servers/tools.examples.ts | 40 ++++++++++++ .../guides/serving/legacy-clients.examples.ts | 34 ++++++++++ examples/package.json | 1 + examples/tsconfig.json | 1 + pnpm-lock.yaml | 3 + 14 files changed, 377 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..aff0fc430f 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -129,6 +129,69 @@ 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/${encodeURIComponent(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%20mug', + 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 +219,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..9c26d29080 100644 --- a/docs/serving/legacy-clients.md +++ b/docs/serving/legacy-clients.md @@ -91,7 +91,40 @@ 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. `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: '0.0.0.0', allowedHosts: ['sse.example.com'], jsonLimit: '4mb' }); + +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 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; + } + 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 +132,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..95adab995a 100644 --- a/examples/guides/clients/calling.examples.ts +++ b/examples/guides/clients/calling.examples.ts @@ -201,5 +201,14 @@ 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 +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 da63ba1ec4..b41e8ba899 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,28 @@ 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); +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(); + // "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..7ed0182f5c 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,18 @@ 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]); +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({ name: 'review-against-style', diff --git a/examples/guides/servers/tools.examples.ts b/examples/guides/servers/tools.examples.ts index 90548250da..0719c9c22c 100644 --- a/examples/guides/servers/tools.examples.ts +++ b/examples/guides/servers/tools.examples.ts @@ -76,6 +76,38 @@ 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/${encodeURIComponent(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 +137,14 @@ 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); +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 // the claim is false. diff --git a/examples/guides/serving/legacy-clients.examples.ts b/examples/guides/serving/legacy-clients.examples.ts index 1a66c470ff..aa65218612 100644 --- a/examples/guides/serving/legacy-clients.examples.ts +++ b/examples/guides/serving/legacy-clients.examples.ts @@ -56,6 +56,40 @@ 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({ host: '0.0.0.0', allowedHosts: ['sse.example.com'], jsonLimit: '4mb' }); + +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 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; + } + 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 a81ef34ac1e8503e6b8b7779b5d65d0a154329cd Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:56:55 +0100 Subject: [PATCH 3/3] docs: client log levels, elicitation completion notification; drive json-schema-2020-12-preservation on the alpha.11 referee (#2686) --- docs/servers/elicitation.md | 95 ++++++++++++++++- docs/servers/logging-progress-cancellation.md | 23 +++- .../guides/servers/elicitation.examples.ts | 100 ++++++++++++++++++ .../logging-progress-cancellation.examples.ts | 20 ++++ pnpm-lock.yaml | 30 ++++-- .../expected-failures.2026-07-28.yaml | 12 ++- test/conformance/expected-failures.yaml | 9 +- test/conformance/package.json | 2 +- test/conformance/src/everythingClient.ts | 56 ++++++++++ 9 files changed, 334 insertions(+), 13 deletions(-) diff --git a/docs/servers/elicitation.md b/docs/servers/elicitation.md index 737c4e55a0..0617562fd0 100644 --- a/docs/servers/elicitation.md +++ b/docs/servers/elicitation.md @@ -194,6 +194,99 @@ The client opens the URL and answers once the end user finishes there; whatever [ { type: 'text', text: 'Linked github.' } ] ``` +## Signal that the URL flow finished + +The client learns that the end user finished at the URL from a `notifications/elicitation/complete` notification that carries the same `elicitationId`. `server.server.createElicitationCompletionNotifier` returns the function that sends it — keep it where your callback endpoint can reach it, and pass `relatedRequestId` so the notification rides the in-flight tool call. Raise the request `timeout` too — the default is 60 seconds, and a person is on the other end of this one — and forward `ctx.mcpReq.signal` so a cancelled tool call also cancels the parked elicitation. + +```ts source="../../examples/guides/servers/elicitation.examples.ts#createElicitationCompletionNotifier_connectCalendar" +const pendingFlows = new Map Promise>(); + +server.registerTool( + 'connect-calendar', + { + description: 'Connect a calendar through a hosted consent flow', + inputSchema: z.object({ provider: z.string() }) + }, + async ({ provider }, ctx) => { + const elicitationId = crypto.randomUUID(); + pendingFlows.set( + elicitationId, + server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: ctx.mcpReq.id }) + ); + try { + const result = await ctx.mcpReq.elicitInput( + { + mode: 'url', + message: `Grant ${provider} calendar access`, + url: `https://calendar.example.com/consent/${encodeURIComponent(provider)}?state=${elicitationId}`, + elicitationId + }, + // a person is on the other end (the default timeout is 60 s); the signal + // cancels the parked elicitation if the tool call itself is cancelled + { timeout: 10 * 60_000, signal: ctx.mcpReq.signal } + ); + if (result.action !== 'accept') { + return { content: [{ type: 'text', text: `Consent ${result.action}.` }] }; + } + return { content: [{ type: 'text', text: `Connected ${provider}.` }] }; + } finally { + pendingFlows.delete(elicitationId); + } + } +); + +// The hosted flow redirects back to your server with the id in `state`; that +// endpoint sends the notification. +async function completeFlow(elicitationId: string): Promise { + await pendingFlows.get(elicitationId)?.(); +} +``` + +On the client, hold the `elicitation/create` answer until the notification names the `elicitationId` the request carried, and let `ctx.mcpReq.signal` release it when the server cancels — a timed-out or abandoned flow must not leave the handler waiting. + +```ts source="../../examples/guides/servers/elicitation.examples.ts#setNotificationHandler_elicitationComplete" +const finished = new Map void>(); + +client.setNotificationHandler('notifications/elicitation/complete', notification => { + console.log('URL flow finished:', notification.params.elicitationId); + finished.get(notification.params.elicitationId)?.(); + finished.delete(notification.params.elicitationId); +}); + +client.setRequestHandler('elicitation/create', async (request, ctx) => { + if (request.params.mode === 'url') { + // Open request.params.url in the user's browser; answer once the server signals completion. + const { elicitationId } = request.params; + const done = await new Promise<'complete' | 'cancelled'>(resolve => { + finished.set(elicitationId, () => resolve('complete')); + ctx.mcpReq.signal.addEventListener('abort', () => { + finished.delete(elicitationId); + resolve('cancelled'); + }); + }); + return { action: done === 'complete' ? 'accept' : 'cancel' }; + } + return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } }; +}); +``` + +The host's own `tools/call` has the same 60-second default, so the caller raises it as well: + +```ts source="../../examples/guides/servers/elicitation.examples.ts#callTool_connectCalendar_timeout" +const connecting = client.callTool({ name: 'connect-calendar', arguments: { provider: 'google' } }, { timeout: 10 * 60_000 }); +``` + +Let the callback endpoint run `completeFlow` with the id from `state`, and the client logs the notification before the tool result arrives (the id is fresh on every run): + +``` +URL flow finished: c9a7bcfc-acc9-494c-8ce5-44c921232ea6 +[ { type: 'text', text: 'Connected google.' } ] +``` + +::: info +This notification exists on 2025-11-25 connections only — the 2026-07-28 [input-required](./input-required.md) flow has no `elicitationId` and no completion signal; see [Protocol versions](../protocol-versions.md). +::: + ## Keep secrets out of forms Form answers travel back through the client and land in the model's context like any other tool result. @@ -221,5 +314,5 @@ Elicitation only works against a client that declared the `elicitation` capabili - 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. +- URL mode hands the end user a browser flow — use it for anything sensitive; `createElicitationCompletionNotifier` returns the function that sends `notifications/elicitation/complete` so the client can answer. - Calls against a client that never declared the `elicitation` capability fail before reaching the wire. diff --git a/docs/servers/logging-progress-cancellation.md b/docs/servers/logging-progress-cancellation.md index 95be3c0627..a23aff35b5 100644 --- a/docs/servers/logging-progress-cancellation.md +++ b/docs/servers/logging-progress-cancellation.md @@ -119,7 +119,27 @@ warning { invalid: [ 'b.txt' ] } [ { type: 'text', text: '1 of 2 records are valid' } ] ``` -How the client's log level reaches `ctx.mcpReq.log` differs by protocol era — see [Protocol versions](../protocol-versions.md). +## Let the client set the level + +Declaring `logging` also installs the `logging/setLevel` handler, so a client raises the threshold for its session with `setLoggingLevel` and `ctx.mcpReq.log` drops anything below it. + +```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#setLoggingLevel_warning" +await client.setLoggingLevel('warning'); + +const filtered = await client.callTool({ name: 'validate-records', arguments: { records: ['c.csv', 'd.txt'] } }); +console.log(filtered.content); +``` + +The same tool now delivers only the `warning`; the `info` message never leaves the server: + +``` +warning { invalid: [ 'd.txt' ] } +[ { type: 'text', text: '1 of 2 records are valid' } ] +``` + +::: info +On a 2026-07-28 request the client's level arrives per request, not per session — see [Protocol versions](../protocol-versions.md). +::: ## Stop work when the request is cancelled @@ -202,4 +222,5 @@ Resolve an identifier against a fixed list, as `fetch-source` does. A tool that - Every handler receives a context as its second argument; the request-scoped helpers live on `ctx.mcpReq`. - `ctx.mcpReq.notify` sends `notifications/progress` when the request carried a `progressToken`; `progress` must increase on each one. - `ctx.mcpReq.log(level, data)` sends `notifications/message` once the `logging` capability is declared; MCP logging is deprecated (SEP-2577). +- Declaring `logging` also installs `logging/setLevel`; after `client.setLoggingLevel(level)` the SDK drops messages below that level for the session. - `ctx.mcpReq.signal` aborts on cancellation and disconnect — check it in long loops and forward it to your own I/O. diff --git a/examples/guides/servers/elicitation.examples.ts b/examples/guides/servers/elicitation.examples.ts index b41e8ba899..4267ad98d5 100644 --- a/examples/guides/servers/elicitation.examples.ts +++ b/examples/guides/servers/elicitation.examples.ts @@ -132,6 +132,52 @@ server.registerTool( ); //#endregion registerTool_elicitUrl +// "Signal that the URL flow finished" — the server tells the client when the +// out-of-band flow completes, so the client can answer the pending request. +//#region createElicitationCompletionNotifier_connectCalendar +const pendingFlows = new Map Promise>(); + +server.registerTool( + 'connect-calendar', + { + description: 'Connect a calendar through a hosted consent flow', + inputSchema: z.object({ provider: z.string() }) + }, + async ({ provider }, ctx) => { + const elicitationId = crypto.randomUUID(); + pendingFlows.set( + elicitationId, + server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: ctx.mcpReq.id }) + ); + try { + const result = await ctx.mcpReq.elicitInput( + { + mode: 'url', + message: `Grant ${provider} calendar access`, + url: `https://calendar.example.com/consent/${encodeURIComponent(provider)}?state=${elicitationId}`, + elicitationId + }, + // a person is on the other end (the default timeout is 60 s); the signal + // cancels the parked elicitation if the tool call itself is cancelled + { timeout: 10 * 60_000, signal: ctx.mcpReq.signal } + ); + if (result.action !== 'accept') { + return { content: [{ type: 'text', text: `Consent ${result.action}.` }] }; + } + return { content: [{ type: 'text', text: `Connected ${provider}.` }] }; + } finally { + pendingFlows.delete(elicitationId); + } + } +); + +// The hosted flow redirects back to your server with the id in `state`; that +// endpoint sends the notification. +async function completeFlow(elicitationId: string): Promise { + await pendingFlows.get(elicitationId)?.(); +} +//#endregion createElicitationCompletionNotifier_connectCalendar + // --------------------------------------------------------------------------- // Harness (not shown on the page beyond the two regions below). An in-memory // client plays the end user; a real host renders UI instead. Imported @@ -175,6 +221,60 @@ client.setRequestHandler('elicitation/create', async () => ({ action: 'decline' const declined = await client.callTool({ name: 'delete-dataset', arguments: { name: 'staging-snapshots' } }); console.log(declined.content); +// "Signal that the URL flow finished" — the client holds its answer until the +// completion notification names the elicitationId it is waiting on. +//#region setNotificationHandler_elicitationComplete +const finished = new Map void>(); + +client.setNotificationHandler('notifications/elicitation/complete', notification => { + console.log('URL flow finished:', notification.params.elicitationId); + finished.get(notification.params.elicitationId)?.(); + finished.delete(notification.params.elicitationId); +}); + +client.setRequestHandler('elicitation/create', async (request, ctx) => { + if (request.params.mode === 'url') { + // Open request.params.url in the user's browser; answer once the server signals completion. + const { elicitationId } = request.params; + const done = await new Promise<'complete' | 'cancelled'>(resolve => { + finished.set(elicitationId, () => resolve('complete')); + ctx.mcpReq.signal.addEventListener('abort', () => { + finished.delete(elicitationId); + resolve('cancelled'); + }); + }); + return { action: done === 'complete' ? 'accept' : 'cancel' }; + } + return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } }; +}); +//#endregion setNotificationHandler_elicitationComplete + +// The harness plays the browser: once the server has parked the flow, the end +// user "finishes" at the URL and the callback endpoint fires the notification. +// The client only answers when the notification names the id its request +// carried, so the accept below proves the ids matched. +//#region callTool_connectCalendar_timeout +const connecting = client.callTool({ name: 'connect-calendar', arguments: { provider: 'google' } }, { timeout: 10 * 60_000 }); +//#endregion callTool_connectCalendar_timeout +const waitFor = async (label: string, ready: () => boolean): Promise => { + for (let attempt = 0; attempt < 400; attempt++) { + if (ready()) return; + await new Promise(resolve => setTimeout(resolve, 5)); + } + throw new Error(`elicitation.md claim failed: ${label} never happened`); +}; +await waitFor('the server parked the URL flow', () => pendingFlows.size > 0); +for (const parkedId of pendingFlows.keys()) { + await waitFor('the elicitation request reached the client handler', () => finished.has(parkedId)); + await completeFlow(parkedId); +} +const connected = await connecting; +console.log(connected.content); +const connectedText = Array.isArray(connected.content) && connected.content[0]?.type === 'text' ? connected.content[0].text : undefined; +if (connected.isError || connectedText !== 'Connected google.' || pendingFlows.size !== 0) { + throw new Error(`elicitation.md claim failed: completion round returned ${JSON.stringify(connected.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. diff --git a/examples/guides/servers/logging-progress-cancellation.examples.ts b/examples/guides/servers/logging-progress-cancellation.examples.ts index d0750e51a1..347d3f1e89 100644 --- a/examples/guides/servers/logging-progress-cancellation.examples.ts +++ b/examples/guides/servers/logging-progress-cancellation.examples.ts @@ -150,6 +150,26 @@ console.log(quiet.content); const validated = await client.callTool({ name: 'validate-records', arguments: { records: ['a.csv', 'b.txt'] } }); console.log(validated.content); +// "Let the client set the level" — the harness swaps in a handler that also +// records each level it sees, so the run can assert what the page claims. +const delivered: string[] = []; +client.setNotificationHandler('notifications/message', notification => { + delivered.push(notification.params.level); + console.log(notification.params.level, notification.params.data); +}); +//#region setLoggingLevel_warning +await client.setLoggingLevel('warning'); + +const filtered = await client.callTool({ name: 'validate-records', arguments: { records: ['c.csv', 'd.txt'] } }); +console.log(filtered.content); +//#endregion setLoggingLevel_warning +const filteredText = Array.isArray(filtered.content) && filtered.content[0]?.type === 'text' ? filtered.content[0].text : undefined; +if (delivered.join(',') !== 'warning' || filteredText !== '1 of 2 records are valid') { + throw new Error( + `logging-progress-cancellation.md claim failed: after setLoggingLevel('warning') the client received [${delivered.join(', ')}] and ${JSON.stringify(filtered.content)}` + ); +} + // "Stop work when the request is cancelled". //#region callTool_abort const controller = new AbortController(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85218d9ea0..c663ad7086 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1946,8 +1946,8 @@ importers: specifier: workspace:^ version: link:../../packages/client '@modelcontextprotocol/conformance': - specifier: 0.2.0-alpha.10 - version: 0.2.0-alpha.10(@cfworker/json-schema@4.1.1) + specifier: 0.2.0-alpha.11 + version: 0.2.0-alpha.11(@cfworker/json-schema@4.1.1) '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../../packages/core-internal @@ -3231,8 +3231,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/conformance@0.2.0-alpha.10': - resolution: {integrity: sha512-0V/HZDdWHcg6j0zVBzBsXcPZ571IVi6umKgTpnBhtTx/jm/LONmGF6cIWL2k4Xjyps0OiHV6B37nj2s0pUg0nQ==} + '@modelcontextprotocol/conformance@0.2.0-alpha.11': + resolution: {integrity: sha512-imPK9tx5gQsL6ZKQq4MrsyDYfSaIwpRmX6+ogjbeAXs9LGvxkBxWcY7KcS7TvwaBk/ZiVWl6b/naF4q83UwDRA==} hasBin: true '@modelcontextprotocol/sdk@1.29.0': @@ -4231,6 +4231,9 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + algoliasearch@5.55.0: resolution: {integrity: sha512-af+rI+tUVeS9KWHPAZQHIHPOIC3StPRR6IwQu2nz1aQoTL6Gs5Ty3KsHCgbXMHOpoh9QqSjq8F3KJ8xmaCZSBA==} engines: {node: '>= 14.0.0'} @@ -7854,10 +7857,12 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/conformance@0.2.0-alpha.10(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/conformance@0.2.0-alpha.11(@cfworker/json-schema@4.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) '@octokit/rest': 22.0.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) commander: 14.0.3 eventsource-parser: 3.0.8 express: 5.2.1 @@ -7872,8 +7877,8 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.11(hono@4.12.9) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 @@ -8759,6 +8764,10 @@ snapshots: optionalDependencies: ajv: 8.18.0 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -8773,6 +8782,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + algoliasearch@5.55.0: dependencies: '@algolia/abtesting': 1.21.0 diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index e6fbde1ced..0f7090a82c 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -12,8 +12,16 @@ # 2025 legs. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json. Newer conformance releases are adopted by -# deliberately bumping the pin and reconciling this file in the same change. +# release pinned in package.json (0.2.0-alpha.11). Newer conformance releases +# are adopted by deliberately bumping the pin and reconciling this file in the +# same change. +# +# alpha.10 -> alpha.11 reconciliation: `json-schema-2020-12-preservation` +# (client leg; everythingClient negotiates via server/discover like tools_call) +# passes at 2026-07-28 — the referee reports it as added-after-release, unscored +# on the frozen 2026-07-28 set — and `server-session-lifecycle` is not +# applicable at 2026-07-28 (removed in that revision, skipped by +# --spec-version), so both sections stay empty. # # NOTE: the SDK's modern-path rejection codes are aligned with what this # referee asserts — both sides have adopted the spec#2907 / conformance#353 diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 6711cdc30f..1e256bec6d 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -2,10 +2,17 @@ # CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json (0.2.0-alpha.10). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.11). Newer conformance releases # are adopted by deliberately bumping the package.json pin and reconciling # this file in the same change. # +# alpha.10 -> alpha.11 reconciliation: the referee added two scenarios, both +# passing, so neither is baselined — `json-schema-2020-12-preservation` +# (client; SEP-1613/SEP-2106 keyword round-trip, driven by everythingClient.ts) +# and `server-session-lifecycle` (server; Streamable HTTP session teardown). +# The referee now also sends HTTP DELETE after every session-bound scenario +# (conformance#316); the everything server already answers it. +# # NOTE: the SDK's modern-path rejection codes are aligned with what this # referee asserts — both sides have adopted the spec#2907 / conformance#353 # renumber (-32020 HeaderMismatch / -32021 MissingRequiredClientCapability / diff --git a/test/conformance/package.json b/test/conformance/package.json index 8558f92b50..f51e84d9ea 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -38,7 +38,7 @@ "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.10", + "@modelcontextprotocol/conformance": "0.2.0-alpha.11", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/core-internal": "workspace:^", diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 3ba48ccbf7..cf33555a4b 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -802,6 +802,62 @@ async function runJsonSchemaRefNoDerefClient(serverUrl: string): Promise { registerScenario('json-schema-ref-no-deref', runJsonSchemaRefNoDerefClient); +// ============================================================================ +// JSON Schema 2020-12 keyword preservation scenario (SEP-1613, SEP-2106) +// ============================================================================ + +/** The tool whose `inputSchema` carries the full JSON Schema 2020-12 fixture. */ +const JSON_SCHEMA_2020_12_TOOL = 'json_schema_2020_12_tool'; +/** The permissive echo tool that hands the observed schema back to the referee. */ +const JSON_SCHEMA_ECHO_TOOL = 'json_schema_echo'; + +/** + * The scenario advertises a focal tool whose inputSchema uses `$schema`, + * `$defs` (with `$anchor`), `additionalProperties`, composition + * (`allOf`/`anyOf`) and conditional (`if`/`then`/`else`) keywords. The client + * lists tools and passes that inputSchema back verbatim — exactly as + * `listTools()` exposes it — through `tools/call json_schema_echo`, so the + * referee can diff what survived the SDK's parsing against its fixture. + * + * The scenario spans both eras: under a 2026-07-28 run the client negotiates + * the modern lifecycle via server/discover (as tools_call does) and drives the + * same list → echo flow. + */ +async function runJsonSchema2020_12PreservationClient(serverUrl: string): Promise { + const client = new Client( + { name: 'json-schema-2020-12-preservation-client', version: '1.0.0' }, + isModernConformanceRun() ? { capabilities: {}, versionNegotiation: { mode: 'auto' } } : { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + const focal = tools.tools.find(t => t.name === JSON_SCHEMA_2020_12_TOOL); + if (!focal) { + throw new Error(`Tool '${JSON_SCHEMA_2020_12_TOOL}' not advertised by the server`); + } + logger.debug('Observed inputSchema:', JSON.stringify(focal.inputSchema, null, 2)); + + const result = await client.callTool({ + name: JSON_SCHEMA_ECHO_TOOL, + arguments: { schema: focal.inputSchema } + }); + logger.debug('Echo result:', JSON.stringify(result, null, 2)); + + await client.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('json-schema-2020-12-preservation', runJsonSchema2020_12PreservationClient); + // ============================================================================ // Main entry point // ============================================================================