diff --git a/CLAUDE.md b/CLAUDE.md
index c50547ba..c8d298e5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,11 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview
-This is a production-ready Fastify adapter for the Model Context Protocol (MCP). The project implements a Fastify plugin that enables MCP communication through the JSON-RPC 2.0 specification with full horizontal scaling capabilities. The codebase includes MCP protocol specifications in the `spec/` directory that define the messaging format, lifecycle management, and various protocol features.
+This is a production-ready Fastify adapter for the Model Context Protocol (MCP). The project implements a Fastify plugin that enables MCP communication through the JSON-RPC 2.0 specification with full horizontal scaling capabilities. The codebase includes MCP protocol specifications in the `spec/` directory (currently the **2026-07-28** revision) that define the messaging format, versioning, and various protocol features.
+
+The plugin is **dual-era**: it serves the stateless `2026-07-28` revision and the earlier handshake-based revisions (`2025-11-25` through `2024-11-05`) on the same endpoint. A request is treated as modern when `params._meta` carries `io.modelcontextprotocol/protocolVersion`; everything else takes the legacy path.
## Key Features
-- **Complete MCP Protocol Support**: Implements the full Model Context Protocol specification
+- **Complete MCP Protocol Support**: Implements the full Model Context Protocol specification (2026-07-28, plus the legacy handshake revisions)
+- **Stateless Core**: Per-request protocol version and capabilities, `server/discover`, no sessions on the modern path
+- **Multi Round-Trip Requests**: Handlers throw `InputRequired`; state travels through the client under HMAC
+- **Subscriptions**: `subscriptions/listen` long-lived notification streams with per-type opt-in
- **Server-Sent Events (SSE)**: Real-time streaming communication with session management
- **Horizontal Scaling**: Redis-backed session management and message broadcasting
- **Session Persistence**: Message history and reconnection support with Last-Event-ID
@@ -49,10 +54,33 @@ The main entry point is `src/index.ts` which exports a Fastify plugin built with
- Heartbeat mechanism for connection health monitoring
- Support for both GET and POST endpoints
+### Protocol Eras
+
+**Modern (2026-07-28)** — `src/modern/`:
+- `request-meta.ts` parses and validates the per-request `_meta`; `looksModern()` is the era switch
+- `headers.ts` reconciles `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` against the body, including the `=?base64?…?=` sentinel
+- `handlers.ts` dispatches modern requests, wraps results in the `resultType` envelope and adds caching hints
+- `input-required.ts` is the handler-facing MRTR API (`InputRequired`, `elicitForm`, …)
+- `request-state.ts` seals `requestState` with HMAC and binds it to principal, expiry and request digest
+- `subscriptions.ts` owns `subscriptions/listen` streams
+- `task-inputs.ts` delivers `tasks/update` responses to a running task
+
+**Legacy (2025-11-25 and earlier)** — `src/handlers.ts`, `src/stores/*session*`, SSE in `src/routes/mcp.ts`.
+
+Business logic is shared: the modern dispatcher calls the same `handleToolsList`, `executeToolCall`, `handleResourcesRead` and `handlePromptsGet` and only changes the envelope.
+
### File Structure
```
src/
+├── modern/ # 2026-07-28 protocol
+│ ├── request-meta.ts # Per-request _meta parsing, era detection
+│ ├── headers.ts # Header/body reconciliation
+│ ├── handlers.ts # Modern dispatch, caching, tasks extension
+│ ├── input-required.ts # Multi round-trip request API
+│ ├── request-state.ts # Sealed requestState
+│ ├── subscriptions.ts # subscriptions/listen streams
+│ └── task-inputs.ts # tasks/update delivery
├── brokers/
│ ├── message-broker.ts # Interface definition
│ ├── memory-message-broker.ts # MQEmitter implementation
@@ -67,7 +95,9 @@ src/
├── handlers.ts # MCP protocol handlers
├── routes.ts # SSE connection handling
├── index.ts # Plugin entry point with backend selection
-├── schema.ts # MCP protocol types
+├── schema.ts # MCP protocol types (legacy canonical + shared)
+├── schema-2026.ts # Types introduced or reshaped by 2026-07-28
+├── protocol-version.ts # Revision comparison helpers, era detection
└── types.ts # Plugin types
```
@@ -94,7 +124,10 @@ The project uses ESM modules (`"type": "module"`) and includes comprehensive MCP
- `serverInfo`: Server identification (name, version)
- `capabilities`: MCP capabilities configuration
- `instructions`: Optional server instructions
-- `enableSSE`: Enable Server-Sent Events support (default: false)
+- `enableSSE`: Enable Server-Sent Events support for legacy clients (default: false). Does not gate `subscriptions/listen`, which is core to 2026-07-28.
+- `caching`: Freshness hints (`ttlMs`, `cacheScope`) per cacheable operation. Defaults to `{ ttlMs: 0, cacheScope: 'private' }`.
+- `requestStateSecret`: Shared secret sealing MRTR `requestState`. Required when more than one instance can serve a retry.
+- `requestStateTtlMs`: How long sealed state stays valid (default 5 minutes).
- `redis`: Redis configuration for horizontal scaling (optional)
- `host`: Redis server hostname
- `port`: Redis server port
@@ -114,7 +147,8 @@ Uses a base TypeScript configuration (`tsconfig.base.json`) extended by the main
## Testing
The project includes comprehensive test coverage:
-- **369 tests total** covering all functionality including OAuth 2.1 authorization and tasks
+- **430+ tests total** covering all functionality including OAuth 2.1 authorization, tasks, and both protocol eras
+- **2026-07-28 tests**: `test/spec-2026-07-28.test.ts` (end-to-end) and `test/modern-units.test.ts` (header encoding, request-state sealing, subscription filters)
- **Memory backend tests**: Session management, message broadcasting, SSE handling
- **Redis backend tests**: Session persistence, cross-instance messaging, failover
- **Integration tests**: Full plugin lifecycle, multi-instance deployment
diff --git a/README.md b/README.md
index 0f05427c..268ec07f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Fastify MCP Server
-A Fastify plugin that implements the Model Context Protocol (MCP) server using JSON-RPC 2.0. This plugin enables Fastify applications to expose tools, resources, and prompts following the MCP 2025-06-18 specification with full elicitation support.
+A Fastify plugin that implements the Model Context Protocol (MCP) server using JSON-RPC 2.0. This plugin enables Fastify applications to expose tools, resources, and prompts following the MCP **2026-07-28** specification, while continuing to serve clients that speak the earlier handshake-based revisions on the same endpoint.
## Installation
@@ -18,8 +18,10 @@ npm install @sinclair/typebox
## Features
-- **Complete MCP 2025-11-25 Support**: Implements the current Model Context Protocol revision, negotiating down to `2025-06-18`, `2025-03-26` and `2024-11-05` for older clients
-- **Tasks (experimental)**: Task-augmented tool calls with polling, deferred result retrieval and cancellation
+- **MCP 2026-07-28 Support**: Stateless per-request protocol with `server/discover`, multi round-trip requests, `subscriptions/listen`, cacheable results and header-based routing
+- **Dual-era**: The same endpoint serves 2026-07-28 statelessly *and* answers `initialize` for `2025-11-25`, `2025-06-18`, `2025-03-26` and `2024-11-05` clients
+- **Multi Round-Trip Requests**: Handlers ask for elicitation, sampling or roots by throwing `InputRequired`; state travels through the client, integrity-protected
+- **Tasks Extension**: `io.modelcontextprotocol/tasks` with `tasks/get` polling, `tasks/update` and `tasks/cancel`
- **Elicitation Support**: Server-to-client information requests in both form and URL mode, with schema validation
- **Icons**: Optional icon metadata on tools, resources, resource templates and prompts
- **TypeBox Validation**: Type-safe schema validation with automatic TypeScript inference
@@ -128,7 +130,13 @@ await app.listen({ port: 3000 })
The plugin decorates the Fastify instance with `mcpClient()`, a client that talks to the
server through `app.inject()` — no port binding, so it's equally useful for tests or for
-driving the server from other in-process code:
+driving the server from other in-process code.
+
+It accepts both JSON and `text/event-stream` responses; notifications streamed before the
+result are returned in `response.notifications`. Pass extra `_meta` (such as a
+`progressToken`) with `callTool(name, args, { meta })`. A result with a `resultType` it does
+not understand is rejected. It does not retry on its own: on `-32022` pick a version from
+`error.data.supported`, and on `-32020` re-run `listTools()` before retrying.
```typescript
import { test } from 'node:test'
@@ -178,15 +186,52 @@ test('calls an MCP tool', async (t) => {
})
```
+The client defaults to `LATEST_LEGACY_PROTOCOL_VERSION`, so existing code keeps the
+handshake and session lifecycle shown above. To use the stateless revision, select it
+explicitly and call methods without `initialize()`:
+
+```typescript
+import { LATEST_PROTOCOL_VERSION } from '@platformatic/mcp'
+
+const modern = app.mcpClient({
+ protocolVersion: LATEST_PROTOCOL_VERSION,
+ clientInfo: { name: 'my-client', version: '1.0.0' },
+ clientCapabilities: {}
+})
+
+const discovery = await modern.discover()
+const tools = await modern.listTools()
+const result = await modern.callTool('echo', { message: 'hello' })
+```
+
+In modern mode the client adds the required per-request `_meta`, `Mcp-Method`, encoded
+`Mcp-Name`, and protocol-version headers. Calling `listTools()` also caches tool schemas so a
+later `callTool()` can mirror `x-mcp-header` arguments into encoded `Mcp-Param-*` headers.
+For a direct call before listing, pass those headers through `callTool(..., { headers })`.
+It does not create or send a session; `initialize()` rejects because that method was removed
+in `2026-07-28`.
+
+When a call returns `resultType: 'input_required'`, retry it with the returned state and the
+client's answers:
+
+```typescript
+await modern.callTool('interactive-tool', args, {
+ requestState: response.body.result.requestState,
+ inputResponses: {
+ confirmation: { action: 'accept', content: { confirmed: true } }
+ }
+})
+```
+
The client:
- Uses `app.inject()` only (no port binding).
- Manages sequential JSON-RPC request IDs per client instance.
-- `initialize()` performs the complete MCP lifecycle handshake (`initialize` plus `notifications/initialized`).
-- `initialize()` rejects when `notifications/initialized` is not accepted with an empty `202` or `204` response.
-- Commits `mcp-session-id` and negotiated protocol version only after the full initialization handshake succeeds.
+- In legacy mode, `initialize()` performs the complete lifecycle handshake (`initialize` plus `notifications/initialized`).
+- Rejects a legacy initialization when `notifications/initialized` is not accepted with an empty `202` or `204` response.
+- Commits `mcp-session-id` and negotiated protocol version only after the full legacy handshake succeeds.
- Forwards headers passed to `initialize()` to both lifecycle requests, except MCP-managed `mcp-session-id` and `mcp-protocol-version` on `notifications/initialized`.
-- Captures committed `mcp-session-id` from successful initialization and sends it automatically on later requests.
+- Captures committed `mcp-session-id` from successful legacy initialization and sends it automatically on later requests.
- Lets you pass custom headers (including authorization) globally or per request.
Responses are a discriminated union — narrow with `'result' in response.body` or
@@ -195,21 +240,109 @@ instead of returning.
Note: no OAuth/JWT credentials are generated for you.
-## Protocol Version Negotiation
+## Protocol Versions
-The server answers `initialize` with the client's requested revision when it is one it
-supports, and otherwise offers the newest one it has:
+This plugin is a **dual-era** server, in the spec's terminology. The `2026-07-28` revision
+removed the `initialize` handshake, protocol-level sessions, and SSE resumability; rather
+than drop the clients that still need them, the same `/mcp` endpoint serves both:
```typescript
-import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '@platformatic/mcp'
+import {
+ LATEST_PROTOCOL_VERSION,
+ LATEST_LEGACY_PROTOCOL_VERSION,
+ SUPPORTED_PROTOCOL_VERSIONS
+} from '@platformatic/mcp'
+
+LATEST_PROTOCOL_VERSION // '2026-07-28'
+LATEST_LEGACY_PROTOCOL_VERSION // '2025-11-25' — newest revision reachable via initialize
+SUPPORTED_PROTOCOL_VERSIONS // ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']
+```
+
+A request is served as **modern** when its `params._meta` carries
+`io.modelcontextprotocol/protocolVersion` or its `MCP-Protocol-Version` header names a modern
+revision. A request with neither takes the legacy path, so the two eras can interleave freely
+on one server. Header-based detection ensures modern routing headers can never bypass their
+required header/body validation.
+
+### Modern requests (2026-07-28)
+
+There is no negotiation step. Every request states what it speaks and what the client can do:
+
+```jsonc
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "tools/call",
+ "params": {
+ "name": "get_weather",
+ "arguments": { "location": "Seattle" },
+ "_meta": {
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
+ "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
+ "io.modelcontextprotocol/clientCapabilities": {}
+ }
+ }
+}
+```
+
+sent with these headers, which the server checks against the body:
-LATEST_PROTOCOL_VERSION // '2025-11-25'
-SUPPORTED_PROTOCOL_VERSIONS // ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']
+```http
+MCP-Protocol-Version: 2026-07-28
+Mcp-Method: tools/call
+Mcp-Name: get_weather
```
-On the HTTP transport, requests after `initialize` must carry the agreed revision in the
-`MCP-Protocol-Version` header. An unsupported value is answered with `400`; an absent header
-is treated as `2025-03-26`, which predates the header.
+`protocolVersion` and `clientCapabilities` are required — a request missing either is
+answered with `-32602` and HTTP 400. Every result comes back with `resultType`, the server's
+identity in `_meta`, and caching hints where the revision defines them.
+
+The status codes matter, because a dual-era *client* uses them to work out which kind of
+server it reached:
+
+| Condition | HTTP | JSON-RPC error |
+|---|---|---|
+| Headers disagree with the body, or a required one is missing | `400` | `-32020` `HeaderMismatch` |
+| Client did not declare a capability the request needs | `400` | `-32021` `MissingRequiredClientCapability` |
+| Version unknown or unsupported | `400` | `-32022` `UnsupportedProtocolVersion` |
+| Method not implemented, or removed in this revision | `404` | `-32601` `Method not found` |
+| Unknown tool, missing resource, failed handler | `200` | `-32602` / `-32603` |
+
+`server/discover` reports everything a client might want up front, and is the probe a
+dual-era client uses on stdio:
+
+```bash
+curl -X POST http://localhost:3000/mcp \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: application/json, text/event-stream' \
+ -H 'MCP-Protocol-Version: 2026-07-28' \
+ -H 'Mcp-Method: server/discover' \
+ -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{
+ "io.modelcontextprotocol/protocolVersion":"2026-07-28",
+ "io.modelcontextprotocol/clientCapabilities":{}}}}'
+```
+
+### What 2026-07-28 removed
+
+These are answered with `404` and `-32601` on the modern path, and continue to work
+unchanged for legacy clients:
+
+| Removed | Replacement |
+|---|---|
+| `initialize` / `notifications/initialized` | per-request `_meta` |
+| `Mcp-Session-Id`, HTTP `GET` and `DELETE` | nothing — the protocol is stateless |
+| `ping` | none; use transport-level health checks |
+| `logging/setLevel` | `io.modelcontextprotocol/logLevel` in a request's `_meta` |
+| `resources/subscribe` / `resources/unsubscribe` | `subscriptions/listen` |
+| Server-initiated requests on SSE streams | [multi round-trip requests](#multi-round-trip-requests-2026-07-28) |
+| `Last-Event-ID` resumability | re-issue the request with a new id |
+| `tasks/result`, `tasks/list` | `tasks/get` polling |
+
+### Legacy requests (2025-11-25 and earlier)
+
+The handshake path is unchanged. The server answers `initialize` with the client's requested
+revision when it supports it, and otherwise with `2025-11-25` — never with `2026-07-28`,
+which a client sending `initialize` by definition cannot speak.
**Responses are shaped to the revision the client negotiated.** A client on an older revision
never sees a field or method that revision does not define:
@@ -234,6 +367,182 @@ default. `initialize` is exempt, so a client may re-negotiate on an existing ses
> solely by its header. A client that omits it falls back to `2025-03-26` and will not see
> `2025-11-25` features. Compliant clients always send the header.
+## Multi Round-Trip Requests (2026-07-28)
+
+A stateless server cannot hold a request open while it asks the user something. Instead it
+ends the request with an interim result, and the client comes back with the answers on a new
+one. Handlers express this by throwing `InputRequired`:
+
+```typescript
+import mcpPlugin, { InputRequired, elicitForm } from '@platformatic/mcp'
+
+app.mcpAddTool({
+ name: 'create-issue',
+ inputSchema: Type.Object({ title: Type.String() })
+}, async (args, context) => {
+ const answer = context.inputResponses?.repo
+
+ if (!answer) {
+ throw new InputRequired({
+ inputRequests: {
+ repo: elicitForm('Which repository?', {
+ type: 'object',
+ properties: { name: { type: 'string' } },
+ required: ['name']
+ })
+ },
+ // Anything the handler needs to resume. It is signed, not encrypted: the
+ // client cannot change it, but can read it, so keep secrets out of it.
+ state: { title: args.title }
+ })
+ }
+
+ // On the retry, `requestState` is the value above, verified and unsealed.
+ const { title } = context.requestState as { title: string }
+ return { content: [{ type: 'text', text: `Created "${title}" in ${answer.content.name}` }] }
+})
+```
+
+`elicitForm`, `elicitUrl`, `requestSampling` and `requestRoots` build the entries. The
+dispatcher refuses to send a request the client did not declare support for, answering
+`-32021` instead — so a handler cannot accidentally elicit from a client that cannot elicit.
+
+### requestState security
+
+`requestState` passes through the client, so it is attacker-controlled by definition. The
+plugin seals it with HMAC-SHA256 and binds it to the authenticated principal, an expiry, and
+a digest of the originating request. State that is tampered with, expired, presented by
+another principal, or replayed onto a different call is refused with `-32602`.
+
+```typescript
+await app.register(mcpPlugin, {
+ // Required when more than one instance can serve a retry — the default is a
+ // per-process random key, so a retry landing on another replica would be refused.
+ requestStateSecret: process.env.MCP_REQUEST_STATE_SECRET,
+ requestStateTtlMs: 5 * 60 * 1000 // default
+})
+```
+
+> Replay is *bounded*, not eliminated. If a given `requestState` must be consumed at most
+> once, enforce that in your own handler.
+
+Applications that authenticate in their own Fastify hooks can expose that identity to MCP
+without enabling the built-in OAuth plugin:
+
+```typescript
+app.addHook('preHandler', async (request, reply) => {
+ request.user = await authenticateRequest(request, reply)
+})
+
+await app.register(mcpPlugin, {
+ resolveAuthorizationContext: (request) => ({
+ userId: request.user.id,
+ scopes: request.user.scopes
+ })
+})
+```
+
+The resolver runs once per MCP POST after Fastify preHandlers and is authoritative when
+configured. It only maps an already-authenticated request; it does **not** reject anonymous
+requests for you. Return a stable `userId` so `requestState`, modern and legacy tasks, tool
+access hooks, and handler contexts all use the same principal. If the resolver returns no
+`userId`, principal-bound request state is refused and the caller owns no tasks.
+
+## Subscriptions (2026-07-28)
+
+`subscriptions/listen` replaces both the standalone `GET` stream and `resources/subscribe`.
+The client names what it wants and the response *is* the stream:
+
+```jsonc
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "subscriptions/listen",
+ "params": {
+ "notifications": {
+ "toolsListChanged": true,
+ "resourceSubscriptions": ["file:///project/config.json"]
+ },
+ "_meta": { /* ... */ }
+ }
+}
+```
+
+The server acknowledges with `notifications/subscriptions/acknowledged`, reporting the subset
+it agreed to honour — an opt-in for something the server has no capability for is dropped.
+Every message on the stream carries `io.modelcontextprotocol/subscriptionId`, and
+`app.mcpBroadcastNotification()` feeds both these streams and legacy SSE sessions.
+
+A notification type is only acknowledged when the server's capabilities say it is emitted:
+`listChanged` for the list types, `resources.subscribe` for `resourceSubscriptions`. The default
+capabilities declare `listChanged: true` for tools, prompts and resources, and
+`resources.subscribe` once `app.mcpSetResourceSubscribeHandler()` is called; an explicit
+`capabilities` option is used exactly as given.
+
+Each stream holds a socket and buffers, so streams are bounded:
+`subscriptionMaxStreamsPerPrincipal` per caller (default 10, refused with HTTP `429`),
+`subscriptionMaxStreams` per instance (default 1000), and `subscriptionMaxResourceUris` per
+stream (default 1000, each at most 2048 characters). A caller is a user at an issuer, whichever
+OAuth client they use; when the deployment cannot identify callers, only the per-instance limit
+applies.
+
+With the default capabilities, adding a tool, resource or prompt after the server is ready
+broadcasts the matching `list_changed` notification. Legacy clients are only told
+`listChanged` when `enableSSE` is on, since without SSE they have no channel to receive it.
+
+Request-scoped notifications (`notifications/progress`, `notifications/message`) are never
+delivered here; they belong on the response stream of the request they relate to.
+
+## Progress and Logging (2026-07-28)
+
+Handlers report progress and log messages through their context:
+
+```typescript
+app.mcpAddTool({ name: 'import', inputSchema: Type.Object({}) }, async (_args, context) => {
+ context.sendProgress(0, 3, 'Reading')
+ context.log('info', { step: 'read' }, 'importer')
+ // ...
+ context.sendProgress(3, 3, 'Done')
+ return { content: [{ type: 'text', text: 'imported' }] }
+})
+```
+
+Both are sent only when the client asked for them, on the response stream of that request:
+
+- `context.sendProgress(progress, total?, message?)` emits `notifications/progress` when the
+ request carries `_meta.progressToken`. `progress` must increase; a value that does not is
+ dropped.
+- `context.log(level, data, logger?)` emits `notifications/message` when the request sets
+ `io.modelcontextprotocol/logLevel`, and only at or above that level.
+
+Notifications are held until the outcome is known, or until the handler has run for 200ms.
+A request that never reports, or that fails before then, answers with plain JSON and its
+proper status (for example `400` for a missing client capability). Otherwise the response is
+a `text/event-stream` carrying the notifications and then the result. Once streaming has
+started the status is committed to `200`, so a later error arrives inside the stream. Nothing
+is sent after the response. Both are no-ops on the legacy path and for tasks, whose creating
+request has already been answered.
+
+## Result Caching (2026-07-28)
+
+`server/discover`, the four list operations and `resources/read` must carry `ttlMs` and
+`cacheScope`. The default is `{ ttlMs: 0, cacheScope: 'private' }` — immediately stale and
+never shared between callers, which is safe for every server. Opt into caching explicitly:
+
+```typescript
+await app.register(mcpPlugin, {
+ caching: {
+ discover: { ttlMs: 3_600_000, cacheScope: 'public' },
+ toolsList: { ttlMs: 300_000, cacheScope: 'public' },
+ resourcesRead: { ttlMs: 30_000, cacheScope: 'private' }
+ }
+})
+```
+
+> `cacheScope: 'public'` lets shared proxies serve one caller's response to another, **even
+> from an authenticated endpoint**. Only use it for results that genuinely do not vary per
+> user, and never rely on it for access control.
+
## Origin Validation
Browser clients can be protected against DNS rebinding by allow-listing origins. A rejected
@@ -248,10 +557,27 @@ await app.register(mcpPlugin, {
Requests without an `Origin` header are always accepted — the header is set by browsers, so
its absence means the request did not come from one.
-## Tasks (MCP 2025-11-25, experimental)
+## Tasks
Tasks let a tool call return immediately with a task handle while the work continues in the
-background. The client then polls `tasks/get` and collects the result with `tasks/result`.
+background, and the client polls for the outcome.
+
+The shape differs by era. In `2026-07-28` tasks are the official
+`io.modelcontextprotocol/tasks` **extension**: the client declares it in its per-request
+capabilities, the server advertises it under `capabilities.extensions`, and polling is
+`tasks/get` alone. In `2025-11-25` tasks were part of the core protocol, with a per-call
+`task` field and a blocking `tasks/result`. One `enableTasks: true` turns on both.
+
+| | `2025-11-25` (core) | `2026-07-28` (extension) |
+|---|---|---|
+| Negotiated via | `capabilities.tasks` | `capabilities.extensions['io.modelcontextprotocol/tasks']` |
+| Client opts in | `task` field per call | extension in `clientCapabilities` |
+| Poll | `tasks/get` | `tasks/get` (result and error inlined) |
+| Await result | `tasks/result` (blocks) | — removed, poll instead |
+| Provide input | — | `tasks/update` |
+| Enumerate | `tasks/list` | — removed |
+| Cancel | `tasks/cancel` | `tasks/cancel` |
+| Handle field names | `ttl`, `pollInterval` | `ttlMs`, `pollIntervalMs` |
Enable the feature on the plugin, then opt individual tools in with `execution.taskSupport`:
@@ -268,7 +594,23 @@ app.mcpAddTool({
})
```
-A client opts in per call by adding a `task` field:
+On `2026-07-28`, a client that declared the extension may get a task handle back from any
+tool that permits one — the server decides, and there is no per-call opt-in:
+
+```jsonc
+// -> tools/call, with "io.modelcontextprotocol/tasks": {} in clientCapabilities.extensions
+// <- CreateTaskResult
+{ "resultType": "task", "taskId": "…", "status": "working", "ttlMs": 60000, "pollIntervalMs": 1000 }
+
+// -> tasks/get { "taskId": "…" }
+// <- the terminal state, with the original result inlined
+{ "resultType": "complete", "status": "completed", "result": { "content": [ ... ] } }
+```
+
+A tool declaring `taskSupport: 'required'` refuses a client that did not declare the
+extension, with `-32021`.
+
+On `2025-11-25`, the client opts in per call instead:
```jsonc
// -> tools/call
@@ -277,7 +619,7 @@ A client opts in per call by adding a `task` field:
{ "task": { "taskId": "…", "status": "working", "ttl": 60000, "pollInterval": 1000 } }
```
-Supported operations: `tasks/get`, `tasks/result` (blocks until the task is terminal),
+and may then use `tasks/get`, `tasks/result` (blocks until the task is terminal),
`tasks/list` and `tasks/cancel`, plus optional `notifications/tasks/status` pushes over SSE.
Retention defaults to 60 seconds when the client does not request a `ttl`. Tasks are meant
@@ -295,11 +637,11 @@ await app.register(mcpPlugin, {
Tasks are stored in memory by default and in Redis when a `redis` option is given, so any
instance can serve a poll for a task created on another.
-**Security**: when authorization is enabled, tasks are bound to the token subject and a
-requestor can only reach its own. Without authorization no requestor can be identified, so
-tasks are reachable by anyone holding the (random UUID) task id, and `tasks/list` is both
-unadvertised **and refused** — otherwise it would hand every anonymous task's id to any
-caller and defeat that model.
+**Security**: when built-in authorization or `resolveAuthorizationContext` is configured,
+tasks are bound to the resolved `userId` and a requestor can only reach its own. Without an
+identity resolver no requestor can be identified, so tasks are reachable by anyone holding
+the (random UUID) task id, and `tasks/list` is both unadvertised **and refused** — otherwise
+it would hand every anonymous task's id to any caller and defeat that model.
## Elicitation Support (MCP 2025-11-25)
@@ -1113,10 +1455,17 @@ The plugin includes a built-in stdio transport utility for MCP communication ove
### Key Features
-- **Complete MCP stdio transport implementation** following the official specification
+- **MCP stdio transport** following the official specification, for both the handshake
+ revisions and 2026-07-28
- **Fastify integration** using the `.inject()` method for consistency with HTTP routes
+- **Streaming**: `subscriptions/listen` and request-scoped progress or log notifications are
+ written to stdout as they happen
+- **Cancellation**: `notifications/cancelled` aborts the request's `context.signal`, and
+ nothing more is written for it
+- **Local trust**: stdio requests are not subject to HTTP bearer authorization, as the spec has
+ the stdio transport take credentials from its environment
- **Comprehensive error handling** with proper JSON-RPC error responses
-- **Batch request support** for processing multiple messages at once
+- **Batch request support** for the legacy revisions that allow it
- **Debug logging** to stderr without interfering with the stdio protocol
### Quick Start
@@ -1220,13 +1569,15 @@ The stdio transport follows the MCP stdio transport specification:
- Messages are delimited by newlines
- Messages must NOT contain embedded newlines
- Server logs can be written to stderr
-- Supports both single messages and batch requests
+- 2026-07-28 messages are always sent one per line; a batch containing one is refused with
+ `-32600`. Legacy revisions may still send batches.
+- 2026-07-28 has no header layer on stdio: everything travels in the body's `_meta`
### Error Handling
The stdio transport provides comprehensive error handling:
-- JSON parsing errors return appropriate JSON-RPC error responses
+- Unparseable lines are answered with a `-32700` parse error whose `id` is `null`
- Invalid method calls return "Method not found" errors
- Tool execution errors are captured and returned in the response
- Connection errors are logged to stderr
@@ -1842,6 +2193,7 @@ await app.register(import('@fastify/bearer-auth'), {
- `capabilities`: MCP capabilities configuration
- `instructions`: Optional server instructions
- `enableSSE`: Enable Server-Sent Events support (default: false)
+- `resolveAuthorizationContext`: Maps identities established by upstream Fastify authentication hooks into MCP authorization context (optional)
- `canAccessTool`: Per-request tool authorization hook consulted by `tools/list` and `tools/call` (optional)
- `onToolCallComplete`: Transport-neutral hook fired once after every tool call settles, across JSON-RPC, `mcpCallTool()`, and tasks (optional)
- `authorization`: OAuth 2.1 authorization configuration (optional)
@@ -1863,6 +2215,23 @@ await app.register(import('@fastify/bearer-auth'), {
- `checkIntervalMs`: Token refresh check interval
- `refreshBufferMinutes`: Minutes before expiry to refresh tokens
- `maxRetries`: Maximum refresh attempts
+- `requestStateSecret`: Secret (at least 32 bytes) that seals multi round-trip
+ `requestState`. Required when `redis` is configured, since any instance may serve a retry
+- `requestStateTtlMs`: How long sealed state stays valid (default 5 minutes)
+- `caching`: Freshness hints per cacheable operation (default `{ ttlMs: 0, cacheScope: 'private' }`)
+- `enableTasks`: Enable tasks (the 2025-11-25 core tasks and the 2026-07-28 extension)
+- `taskDefaultTtlMs` / `taskMaxTtlMs`: Task retention (defaults 60000 / 3600000)
+- `taskMaxConcurrent`: Most tasks one instance runs at once, for both protocol eras (default 1000)
+- `taskMaxPerPrincipal`: Most tasks one caller (a user at an issuer) runs on an instance
+ (default 100). Not applied to callers the deployment cannot identify
+- `taskStoreMaxTasks`: Capacity of the in-memory task store, finished tasks included until their
+ ttl (default 1000). A full store refuses new tasks
+- `taskLeaseMs`: Worker lease; a task whose worker stops renewing it is reported failed
+ (default 15000)
+- `taskShutdownTimeoutMs`: How long `close()` waits for running tasks before failing them
+ (default 5000; keep it below Fastify's `pluginTimeout`)
+- `subscriptionMaxStreams` / `subscriptionMaxStreamsPerPrincipal` /
+ `subscriptionMaxResourceUris`: Bounds on `subscriptions/listen` (defaults 1000 / 10 / 1000)
- `redis`: Redis configuration for horizontal scaling (optional)
- `host`: Redis server hostname
- `port`: Redis server port
@@ -2042,6 +2411,10 @@ All handlers receive a consistent context object containing:
- `context.reply`: Fastify reply object for setting response headers
- `context.sessionId`: Session identifier (when using SSE)
- `context.authContext`: Authorization context (when OAuth is enabled)
+- `context.signal`: `AbortSignal` that aborts when the request is cancelled: a 2026-07-28 client
+ disconnecting before the response, or `tasks/cancel` for a handler running as a task. Pass it
+ to `fetch` or check it between steps to stop work early. It never aborts on the legacy path,
+ where a disconnect is not a cancellation.
#### Backward Compatibility
@@ -2184,15 +2557,29 @@ These legacy server-side OAuth client endpoints do not proxy MCP client authoriz
## Supported MCP Methods
-- `initialize`: Server initialization
-- `ping`: Health check
+Served on both eras:
+
- `tools/list`: List available tools
- `tools/call`: Execute a tool (calls registered handler or returns error)
- `resources/list`: List available resources
+- `resources/templates/list`: List resource templates
- `resources/read`: Read a resource (calls registered handler or returns error)
- `prompts/list`: List available prompts
- `prompts/get`: Get a prompt (calls registered handler or returns error)
+`2026-07-28` only:
+
+- `server/discover`: Supported versions, capabilities and identity in one request
+- `subscriptions/listen`: Long-lived notification stream
+- `tasks/get`, `tasks/update`, `tasks/cancel`: Tasks extension
+
+`2025-11-25` and earlier only:
+
+- `initialize`: Server initialization
+- `ping`: Health check
+- `resources/subscribe`, `resources/unsubscribe`: Per-resource subscriptions
+- `tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`: Core tasks
+
## Security Best Practices
This section outlines security considerations and best practices when using the Fastify MCP plugin implementation.
@@ -2473,6 +2860,95 @@ Remember: Security is a layered approach. No single measure provides complete pr
## Migration from Earlier Versions
+### Breaking changes in 3.0.0
+
+The 2026-07-28 support ships as a major release. Legacy clients need no changes; these are
+the changes to the plugin's own API and defaults:
+
+- **`LATEST_PROTOCOL_VERSION` is `2026-07-28`**, and `SUPPORTED_PROTOCOL_VERSIONS` includes it.
+ Use `LATEST_LEGACY_PROTOCOL_VERSION` for the newest handshake revision (see below).
+- **`HandlerContext` gained required members**: `signal`, `sendProgress` and `log`. Code that
+ builds a context by hand (for example to unit-test a handler) must provide them.
+- **`ToolAccessContext.operation` is required**: `'list'` or `'call'`.
+- **`TaskStore` gained methods**: `updateInputResponses`, `acknowledgeInputResponses`,
+ `renewLease` and `expireStaleLease`. Custom stores must implement them.
+- **Redis task records moved** to the `mcp:task:v2:` keyspace, so instances of different
+ versions never misread each other's tasks. Tasks in flight during an upgrade are only
+ visible to instances of the version that created them.
+- **Tasks are bound to user, OAuth client and issuer**, not the user alone, in both eras.
+- **Task limits apply to both eras**: 2025-11-25 tasks count against `taskMaxConcurrent` and
+ `taskMaxPerPrincipal`, are aborted at their ttl and are drained on shutdown.
+- **A full in-memory task store refuses new tasks** instead of dropping finished results.
+- **`requestStateSecret` is required with `redis`**, and must be at least 32 bytes.
+- **Default capabilities declare `listChanged: true`** for tools, prompts and resources, and
+ registering one after ready broadcasts `list_changed`. Legacy clients only see `listChanged`
+ when `enableSSE` is on.
+- **A tool registered without `inputSchema`** is listed with `{ "type": "object" }`.
+- **`outputSchema` is enforced** (2025-06-18 and later): a result without conforming
+ `structuredContent` becomes a tool error, and an `outputSchema` declaring a dialect other
+ than JSON Schema 2020-12 is refused at registration.
+- **Invalid `caching` values throw at startup** instead of being clamped.
+- **An argument sanitization failure** is reported by `mcpCallTool()` as
+ `{ ok: false, reason: 'invalid-arguments' }`.
+- **Era detection**: a request whose `_meta` carries `io.modelcontextprotocol/protocolVersion`,
+ or whose `MCP-Protocol-Version` header names 2026-07-28, takes the modern path. With
+ authorization enabled, a POST with a bad version header now gets `401` before `400`.
+
+### Upgrading to MCP 2026-07-28
+
+Existing deployments keep working: the handshake path is untouched, and clients that speak
+`2025-11-25` or earlier need no changes. Adopting the new revision is opt-in per client.
+
+**What is new**
+
+- Requests carry `_meta` with `io.modelcontextprotocol/protocolVersion` and
+ `io.modelcontextprotocol/clientCapabilities`, plus `MCP-Protocol-Version`, `Mcp-Method`
+ and (where applicable) `Mcp-Name` headers that must agree with the body
+- `server/discover`, `subscriptions/listen`, multi round-trip requests, cacheable results
+- Tasks moved from the core protocol to the `io.modelcontextprotocol/tasks` extension
+
+**What changes for server authors**
+
+1. **Server-initiated requests are gone.** If you called `app.mcpElicit()` /
+ `app.mcpElicitUrl()` to prompt a user mid-call, that only reaches legacy clients. For
+ modern clients, throw `InputRequired` from the handler instead and read
+ `context.inputResponses` on the retry — see
+ [Multi Round-Trip Requests](#multi-round-trip-requests-2026-07-28).
+2. **Set `requestStateSecret`** if more than one instance can serve a retry. Without it each
+ process seals with its own random key and a retry landing elsewhere is refused. The
+ secret must be at least 32 bytes; a shorter or empty one is rejected at startup.
+3. **Decide your caching hints.** The default of `ttlMs: 0` is safe but means clients never
+ cache. See [Result Caching](#result-caching-2026-07-28).
+4. **Sessions do not exist for modern clients.** `context.sessionId` is `undefined` on that
+ path, and `app.mcpSendToSession()` cannot reach them. Anything that must span requests
+ needs an explicit handle passed as a tool argument.
+5. **`enableSSE` no longer gates broadcasts.** `app.mcpBroadcastNotification()` now always
+ publishes, because `subscriptions/listen` is core to the modern protocol; legacy SSE
+ delivery is still gated by the flag.
+
+**One gotcha for existing code**
+
+`LATEST_PROTOCOL_VERSION` now means `2026-07-28`, not `2025-11-25`. If you send it as the
+`MCP-Protocol-Version` header, that header alone commits the request to the modern path —
+and a body without `_meta` is then answered with `-32602`. Code that wants the newest
+*handshake* revision should use `LATEST_LEGACY_PROTOCOL_VERSION`:
+
+```diff
+-import { LATEST_PROTOCOL_VERSION } from '@platformatic/mcp'
+-headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION }
++import { LATEST_LEGACY_PROTOCOL_VERSION } from '@platformatic/mcp'
++headers: { 'mcp-protocol-version': LATEST_LEGACY_PROTOCOL_VERSION }
+```
+
+The header is deliberately era-determining rather than the body alone. The transport mirrors
+body fields into headers so gateways can route on them without parsing JSON; if the era were
+decided by the body, a caller could send modern headers with a body that omits `_meta` and
+slip past every header/body check onto the unvalidated legacy path — exactly the split-brain
+the spec's "Server Validation" section exists to prevent.
+
+**Deprecated upstream** (still functional, removal no earlier than twelve months): Roots,
+Sampling, Logging, the HTTP+SSE transport, and OAuth Dynamic Client Registration.
+
### Upgrading to MCP 2025-06-18
This version introduces elicitation support and enhanced security features:
diff --git a/spec/authorization.md b/spec/authorization.md
index fdab66ff..10ab7a9b 100644
--- a/spec/authorization.md
+++ b/spec/authorization.md
@@ -1,5 +1,6 @@
---
title: Authorization
+sidebarTitle: Overview
---
@@ -29,12 +30,19 @@ implements a selected subset of their features to ensure security and interopera
while maintaining simplicity:
- OAuth 2.1 IETF DRAFT ([draft-ietf-oauth-v2-1-13](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13))
+- OAuth 2.0 Bearer Token Usage
+ ([RFC6750](https://datatracker.ietf.org/doc/html/rfc6750))
- OAuth 2.0 Authorization Server Metadata
([RFC8414](https://datatracker.ietf.org/doc/html/rfc8414))
- OAuth 2.0 Dynamic Client Registration Protocol
([RFC7591](https://datatracker.ietf.org/doc/html/rfc7591))
+- Resource Indicators for OAuth 2.0
+ ([RFC8707](https://www.rfc-editor.org/rfc/rfc8707.html))
- OAuth 2.0 Protected Resource Metadata ([RFC9728](https://datatracker.ietf.org/doc/html/rfc9728))
+- OAuth 2.0 Authorization Server Issuer Identification ([RFC9207](https://datatracker.ietf.org/doc/html/rfc9207))
- OAuth Client ID Metadata Documents ([draft-ietf-oauth-client-id-metadata-document-00](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00))
+- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
+- OpenID Connect Dynamic Client Registration 1.0 ([OpenID Connect Registration](https://openid.net/specs/openid-connect-registration-1_0.html))
## Roles
@@ -46,7 +54,7 @@ making protected resource requests on behalf of a resource owner.
The _authorization server_ is responsible for interacting with the user (if necessary) and issuing access tokens for use at the MCP server.
The implementation details of the authorization server are beyond the scope of this specification. It may be hosted with the
-resource server or a separate entity. The [Authorization Server Discovery section](#authorization-server-discovery)
+resource server or a separate entity. [Authorization Server Discovery](/specification/2026-07-28/basic/authorization/authorization-server-discovery)
specifies how an MCP server indicates the location of its corresponding authorization server to a client.
## Overview
@@ -54,51 +62,39 @@ specifies how an MCP server indicates the location of its corresponding authoriz
1. Authorization servers **MUST** implement OAuth 2.1 with appropriate security
measures for both confidential and public clients.
-2. Authorization servers and MCP clients **SHOULD** support OAuth Client ID Metadata Documents
+2. Authorization servers and MCP clients **SHOULD** support [OAuth Client ID Metadata Documents](/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents)
([draft-ietf-oauth-client-id-metadata-document-00](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00)).
3. Authorization servers and MCP clients **MAY** support the OAuth 2.0 Dynamic Client Registration
- Protocol ([RFC7591](https://datatracker.ietf.org/doc/html/rfc7591)).
+ Protocol ([RFC7591](https://datatracker.ietf.org/doc/html/rfc7591)). Note that
+ [Dynamic Client Registration](/specification/2026-07-28/basic/authorization/client-registration#dynamic-client-registration)
+ is deprecated and retained for backwards compatibility with authorization servers that do not support Client ID Metadata Documents.
4. MCP servers **MUST** implement OAuth 2.0 Protected Resource Metadata ([RFC9728](https://datatracker.ietf.org/doc/html/rfc9728)).
- MCP clients **MUST** use OAuth 2.0 Protected Resource Metadata for authorization server discovery.
+ MCP clients **MUST** use OAuth 2.0 Protected Resource Metadata for [authorization server discovery](/specification/2026-07-28/basic/authorization/authorization-server-discovery).
5. MCP authorization servers **MUST** provide at least one of the following discovery mechanisms:
- OAuth 2.0 Authorization Server Metadata ([RFC8414](https://datatracker.ietf.org/doc/html/rfc8414))
- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
- MCP clients **MUST** support both discovery mechanisms to obtain the information required to interact with the authorization server.
+ MCP clients **MUST** support both [discovery mechanisms](/specification/2026-07-28/basic/authorization/authorization-server-discovery#authorization-server-metadata-discovery) to obtain the information required to interact with the authorization server.
## Authorization Server Discovery
-This section describes the mechanisms by which MCP servers advertise their associated
-authorization servers to MCP clients, as well as the discovery process through which MCP
-clients can determine authorization server endpoints and supported capabilities.
+MCP servers advertise their associated authorization servers through OAuth 2.0 Protected
+Resource Metadata, and MCP clients determine authorization server endpoints and supported
+capabilities through authorization server metadata discovery. Implementations **MUST**
+follow the normative discovery requirements defined in
+[Authorization Server Discovery](/specification/2026-07-28/basic/authorization/authorization-server-discovery).
-### Authorization Server Location
+## Client Registration
-MCP servers **MUST** implement the OAuth 2.0 Protected Resource Metadata ([RFC9728](https://datatracker.ietf.org/doc/html/rfc9728))
-specification to indicate the locations of authorization servers. The Protected Resource Metadata document returned by the MCP server **MUST** include
-the `authorization_servers` field containing at least one authorization server.
+Before initiating the authorization flow, MCP clients **MUST** obtain a client ID through
+one of three registration mechanisms: Client ID Metadata Documents, pre-registration, or
+Dynamic Client Registration, following the requirements and selection priority defined in
+[Client Registration](/specification/2026-07-28/basic/authorization/client-registration).
-The specific use of `authorization_servers` is beyond the scope of this specification; implementers should consult
-OAuth 2.0 Protected Resource Metadata ([RFC9728](https://datatracker.ietf.org/doc/html/rfc9728)) for
-guidance on implementation details.
-
-Implementors should note that Protected Resource Metadata documents can define multiple authorization servers. The responsibility for selecting which authorization server to use lies with the MCP client, following the guidelines specified in
-[RFC9728 Section 7.6 "Authorization Servers"](https://datatracker.ietf.org/doc/html/rfc9728#name-authorization-servers).
-
-### Protected Resource Metadata Discovery Requirements
-
-MCP servers **MUST** implement one of the following discovery mechanisms to provide authorization server location information to MCP clients:
-
-1. **WWW-Authenticate Header**: Include the resource metadata URL in the `WWW-Authenticate` HTTP header under `resource_metadata` when returning `401 Unauthorized` responses, as described in [RFC9728 Section 5.1](https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response).
-
-2. **Well-Known URI**: Serve metadata at a well-known URI as specified in [RFC9728](https://datatracker.ietf.org/doc/html/rfc9728). This can be either:
- - At the path of the server's MCP endpoint: `https://example.com/public/mcp` could host metadata at `https://example.com/.well-known/oauth-protected-resource/public/mcp`
- - At the root: `https://example.com/.well-known/oauth-protected-resource`
-
-MCP clients **MUST** support both discovery mechanisms and use the resource metadata URL from the parsed `WWW-Authenticate` headers when present; otherwise, they **MUST** fall back to constructing and requesting the well-known URIs in the order listed above.
+## Scope Selection Strategy
MCP servers **SHOULD** include a `scope` parameter in the `WWW-Authenticate` header as defined in
[RFC 6750 Section 3](https://datatracker.ietf.org/doc/html/rfc6750#section-3)
@@ -110,7 +106,10 @@ The scopes included in the `WWW-Authenticate` challenge **MAY** match `scopes_su
or superset of it, or an alternative collection that is neither a strict subset nor
superset. Clients **MUST NOT** assume any particular set relationship between the challenged
scope set and `scopes_supported`. Clients **MUST** treat the scopes provided in the
-challenge as authoritative for satisfying the current request. Servers **SHOULD** strive for
+challenge as authoritative for the current operation. These scopes are required to
+satisfy the current request. When re-authorizing, clients **SHOULD** include these scopes
+alongside any previously granted scopes to avoid losing permissions needed for other operations
+(see [Step-Up Authorization Flow](#step-up-authorization-flow)). Servers **SHOULD** strive for
consistency in how they construct scope sets but they are not required to surface every dynamically
issued scope through `scopes_supported`.
@@ -122,216 +121,6 @@ WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/
scope="files:read"
```
-MCP clients **MUST** be able to parse `WWW-Authenticate` headers and respond appropriately to `HTTP 401 Unauthorized` responses from the MCP server.
-
-If the `scope` parameter is absent, clients **SHOULD** apply the fallback behavior defined in the [Scope Selection Strategy](#scope-selection-strategy) section.
-
-### Authorization Server Metadata Discovery
-
-To handle different issuer URL formats and ensure interoperability with both OAuth 2.0 Authorization Server Metadata and OpenID Connect Discovery 1.0 specifications, MCP clients **MUST** attempt multiple well-known endpoints when discovering authorization server metadata.
-
-The discovery approach is based on [RFC8414 Section 3.1 "Authorization Server Metadata Request"](https://datatracker.ietf.org/doc/html/rfc8414#section-3.1) for OAuth 2.0 Authorization Server Metadata discovery and [RFC8414 Section 5 "Compatibility Notes"](https://datatracker.ietf.org/doc/html/rfc8414#section-5) for OpenID Connect Discovery 1.0 interoperability.
-
-For issuer URLs with path components (e.g., `https://auth.example.com/tenant1`), clients **MUST** try endpoints in the following priority order:
-
-1. OAuth 2.0 Authorization Server Metadata with path insertion: `https://auth.example.com/.well-known/oauth-authorization-server/tenant1`
-2. OpenID Connect Discovery 1.0 with path insertion: `https://auth.example.com/.well-known/openid-configuration/tenant1`
-3. OpenID Connect Discovery 1.0 path appending: `https://auth.example.com/tenant1/.well-known/openid-configuration`
-
-For issuer URLs without path components (e.g., `https://auth.example.com`), clients **MUST** try:
-
-1. OAuth 2.0 Authorization Server Metadata: `https://auth.example.com/.well-known/oauth-authorization-server`
-2. OpenID Connect Discovery 1.0: `https://auth.example.com/.well-known/openid-configuration`
-
-### Authorization Server Discovery Sequence Diagram
-
-The following diagram outlines an example flow:
-
-```mermaid
-sequenceDiagram
- participant C as Client
- participant M as MCP Server (Resource Server)
- participant A as Authorization Server
-
- Note over C: Attempt unauthenticated MCP request
- C->>M: MCP request without token
- M-->>C: HTTP 401 Unauthorized (may include WWW-Authenticate header)
-
- alt Header includes resource_metadata
- Note over C: Extract resource_metadata URL from header
- C->>M: GET resource_metadata URI
- M-->>C: Resource metadata with authorization server URL
- else No resource_metadata in header
- Note over C: Fallback to well-known URI probing
- Note over M: _Not applicable if the MCP server is at the root_
- C->>M: GET /.well-known/oauth-protected-resource/mcp
- alt Sub-path metadata found
- M-->>C: Resource metadata with authorization server URL
- else Sub-path not found
- C->>M: GET /.well-known/oauth-protected-resource
- alt Root metadata found
- M-->>C: Resource metadata with authorization server URL
- else Root metadata not found
- Note over C: Abort or use pre-configured values
- end
- end
- end
-
- Note over C: Validate RS metadata, build AS metadata URL
-
- C->>A: GET Authorization server metadata endpoint
- Note over C,A: Try OAuth 2.0 and OpenID Connect discovery endpoints in priority order
- A-->>C: Authorization server metadata
-
- Note over C,A: OAuth 2.1 authorization flow happens here
-
- C->>A: Token request
- A-->>C: Access token
-
- C->>M: MCP request with access token
- M-->>C: MCP response
- Note over C,M: MCP communication continues with valid token
-```
-
-## Client Registration Approaches
-
-MCP supports three client registration mechanisms. Choose based on your scenario:
-
-- **Client ID Metadata Documents**: When client and server have no prior relationship (most common)
-- **Pre-registration**: When client and server have an existing relationship
-- **Dynamic Client Registration**: For backwards compatibility or specific requirements
-
-Clients supporting all options **SHOULD** follow the following priority order:
-
-1. Use pre-registered client information for the server if the client has it available
-2. Use Client ID Metadata Documents if the Authorization Server indicates if the server supports it (via `client_id_metadata_document_supported` in OAuth Authorization Server Metadata)
-3. Use Dynamic Client Registration as a fallback if the Authorization Server supports it (via `registration_endpoint` in OAuth Authorization Server Metadata)
-4. Prompt the user to enter the client information if no other option is available
-
-### Client ID Metadata Documents
-
-MCP clients and authorization servers **SHOULD** support OAuth Client ID Metadata Documents as specified in
-[OAuth Client ID Metadata Document](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00).
-This approach enables clients to use HTTPS URLs as client identifiers, where the URL points to a JSON document
-containing client metadata. This addresses the common MCP scenario where servers and clients have
-no pre-existing relationship.
-
-#### Implementation Requirements
-
-MCP implementations supporting Client ID Metadata Documents **MUST** follow the requirements specified in
-[OAuth Client ID Metadata Document](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00).
-Key requirements include:
-
-**For MCP Clients:**
-
-- Clients **MUST** host their metadata document at an HTTPS URL following RFC requirements
-- The `client_id` URL **MUST** use the "https" scheme and contain a path component, e.g. `https://example.com/client.json`
-- The metadata document **MUST** include at least the following properties: `client_id`, `client_name`, `redirect_uris`
-- Clients **MUST** ensure the `client_id` value in the metadata matches the document URL exactly
-- Clients **MAY** use `private_key_jwt` for client authentication (e.g., for requests to the token endpoint) with appropriate JWKS configuration as described in [Section 6.2 of Client ID Metadata Document](https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html#section-6.2)
-
-**For Authorization Servers:**
-
-- **SHOULD** fetch metadata documents when encountering URL-formatted client_ids
-- **MUST** validate that the fetched document's `client_id` matches the URL exactly
-- **SHOULD** cache metadata respecting HTTP cache headers
-- **MUST** validate redirect URIs presented in an authorization request against those in the metadata document
-- **MUST** validate the document structure is valid JSON and contains required fields
-- **SHOULD** follow the security considerations in [Section 6 of Client ID Metadata Document](https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html#section-6)
-
-#### Example Metadata Document
-
-```json
-{
- "client_id": "https://app.example.com/oauth/client-metadata.json",
- "client_name": "Example MCP Client",
- "client_uri": "https://app.example.com",
- "logo_uri": "https://app.example.com/logo.png",
- "redirect_uris": [
- "http://127.0.0.1:3000/callback",
- "http://localhost:3000/callback"
- ],
- "grant_types": ["authorization_code"],
- "response_types": ["code"],
- "token_endpoint_auth_method": "none"
-}
-```
-
-#### Client ID Metadata Documents Flow
-
-The following diagram illustrates the complete flow when using Client ID Metadata Documents:
-
-```mermaid
-sequenceDiagram
- participant User
- participant Client as MCP Client
- participant Server as Authorization Server
- participant Metadata as Metadata Endpoint (Client's HTTPS URL)
- participant Resource as MCP Server
-
- Note over Client,Metadata: Client hosts metadata at https://app.example.com/oauth/metadata.json
-
- User->>Client: Initiates connection to MCP Server
- Client->>Server: Authorization Request client_id=https://app.example.com/oauth/metadata.json redirect_uri=http://localhost:3000/callback
-
- Server->>User: Authentication prompt
- User->>Server: Provides credentials
- Note over Server: Authenticates user
-
- Note over Server: Detects URL-formatted client_id
-
- Server->>Metadata: GET https://app.example.com/oauth/metadata.json
- Metadata-->>Server: JSON Metadata Document {client_id, client_name, redirect_uris, ...}
-
- Note over Server: Validates: 1. client_id matches URL 2. redirect_uri in allowed list 3. Document structure valid 4. (Optional) Domain allowed via trust policy
-
- alt Validation Success
- Server->>User: Display consent page with client_name
- User->>Server: Approves access
- Server->>Client: Authorization code via redirect_uri
- Client->>Server: Exchange code for token client_id=https://app.example.com/oauth/metadata.json
- Server-->>Client: Access token
- Client->>Resource: MCP requests with access token
- Resource-->>Client: MCP responses
- else Validation Failure
- Server->>User: Error response error=invalid_client or invalid_request
- end
-
- Note over Server: Cache metadata for future requests (respecting HTTP cache headers)
-```
-
-#### Discovery
-
-Authorization servers advertise that they support clients using Client ID Metadata Documents by including the following property in their OAuth Authorization Server metadata:
-
-```json
-{
- "client_id_metadata_document_supported": true
-}
-```
-
-MCP clients **SHOULD** check for this capability and **MAY** fall back to Dynamic Client Registration
-or pre-registration if unavailable.
-
-### Preregistration
-
-MCP clients **SHOULD** support an option for static client credentials such as those supplied by a preregistration flow. This could be:
-
-1. Hardcode a client ID (and, if applicable, client credentials) specifically for the MCP client to use when
- interacting with that authorization server, or
-2. Present a UI to users that allows them to enter these details, after registering an
- OAuth client themselves (e.g., through a configuration interface hosted by the
- server).
-
-### Dynamic Client Registration
-
-MCP clients and authorization servers **MAY** support the
-OAuth 2.0 Dynamic Client Registration Protocol [RFC7591](https://datatracker.ietf.org/doc/html/rfc7591)
-to allow MCP clients to obtain OAuth client IDs without user interaction.
-This option is included for backwards compatibility with earlier versions of the MCP authorization spec.
-
-## Scope Selection Strategy
-
When implementing authorization flows, MCP clients **SHOULD** follow the principle of least privilege by requesting
only the scopes necessary for their intended operations. During the initial authorization handshake, MCP clients
**SHOULD** follow this priority order for scope selection:
@@ -339,16 +128,16 @@ only the scopes necessary for their intended operations. During the initial auth
1. **Use `scope` parameter** from the initial `WWW-Authenticate` header in the 401 response, if provided
2. **If `scope` is not available**, use all scopes defined in `scopes_supported` from the Protected Resource Metadata document, omitting the `scope` parameter if `scopes_supported` is undefined.
-This approach accommodates the general-purpose nature of MCP clients, which typically lack domain-specific knowledge to make informed decisions about individual scope selection. Requesting all available scopes allows the authorization server and end-user to determine appropriate permissions during the consent process.
-
-This approach minimizes user friction while following the principle of least privilege.
The `scopes_supported` field is intended to represent the minimal set of scopes necessary
-for basic functionality (see [Scope Minimization](/specification/2025-11-25/basic/security_best_practices#scope-minimization)),
+for basic functionality (see [Scope Minimization](/docs/2026-07-28/tutorials/security/security_best_practices#scope-minimization)),
with additional scopes requested incrementally through the step-up authorization flow steps
described in the [Scope Challenge Handling](#scope-challenge-handling) section.
## Authorization Flow Steps
+The registration step shown in the flow uses one of the mechanisms defined in
+[Client Registration](/specification/2026-07-28/basic/authorization/client-registration).
+
The complete Authorization flow proceeds as follows:
```mermaid
@@ -384,12 +173,13 @@ sequenceDiagram
Note over C: Use existing client_id
end
- Note over C: Generate PKCE parameters Include resource parameter Apply scope selection strategy
+ Note over C: Generate PKCE parameters Include resource parameter Apply scope selection strategy Record expected issuer
C->>B: Open browser with authorization URL + code_challenge + resource
B->>A: Authorization request with resource parameter
Note over A: User authorizes
- A->>B: Redirect to callback with authorization code
+ A->>B: Redirect to callback with authorization code + iss
B->>C: Authorization code callback
+ Note over C: Validate iss against recorded issuer (RFC 9207)
C->>A: Token request + code_verifier + resource
A->>C: Access token (+ refresh token)
C->>M: MCP request with access token
@@ -397,6 +187,31 @@ sequenceDiagram
Note over C,M: MCP communication continues with valid token
```
+### Authorization Response Validation
+
+Before redirecting the user-agent, the client **MUST** record the `issuer` value from the selected authorization server's validated metadata document (see [Authorization Server Metadata Discovery](/specification/2026-07-28/basic/authorization/authorization-server-discovery#authorization-server-metadata-discovery)) and associate it with the same per-request record used to store the PKCE code verifier (and the `state` value, if used). The validation in this section depends on that recorded value being authentic; it provides no protection if the expected issuer was obtained from an unvalidated source.
+
+MCP authorization servers **SHOULD** include the `iss` parameter in authorization responses, including error responses, as defined in [RFC9207 Section 2](https://datatracker.ietf.org/doc/html/rfc9207#section-2). Authorization servers that include the `iss` parameter **MUST** advertise this by setting `authorization_response_iss_parameter_supported` to `true` in their metadata ([RFC9207 Section 2.3](https://datatracker.ietf.org/doc/html/rfc9207#section-2.3)).
+
+On receiving the authorization response, MCP clients **MUST** apply the validation in [RFC9207 Section 2.4](https://datatracker.ietf.org/doc/html/rfc9207#section-2.4) before transmitting the authorization code to any token endpoint:
+
+| `authorization_response_iss_parameter_supported` | `iss` in response | Client action |
+| ------------------------------------------------ | ----------------- | ------------------------------------------------------------------------------------------ |
+| `true` | present | Compare to the recorded issuer using simple string comparison ([RFC3986 Section 6.2.1][1]) |
+| `true` | absent | Reject the response |
+| `false` or absent | present | Compare to the recorded issuer using simple string comparison ([RFC3986 Section 6.2.1][1]) |
+| `false` or absent | absent | Proceed |
+
+[1]: https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.1
+
+The third row applies the local-policy provision in [RFC9207 Section 2.4](https://datatracker.ietf.org/doc/html/rfc9207#section-2.4): this specification compares a present `iss` against the recorded issuer regardless of metadata advertisement, to accommodate authorization servers that emit `iss` before updating their metadata.
+
+A future revision of this specification is expected to upgrade authorization server inclusion of `iss` from **SHOULD** to **MUST**. Implementers are encouraged to emit and validate `iss` now to ease that transition; client rejection behavior on `iss` absence will continue to be keyed on `authorization_response_iss_parameter_supported` until that revision defines the upgrade path.
+
+After decoding the `iss` value from the `application/x-www-form-urlencoded` response per [RFC 9207 Section 2.4](https://datatracker.ietf.org/doc/html/rfc9207#section-2.4), clients **MUST NOT** apply scheme or host case folding, default-port elision, trailing-slash, or percent-encoding normalization ([RFC 3986 Sections 6.2.2-6.2.3](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2)) before comparison.
+
+This validation applies equally to error responses - on mismatch the client **MUST NOT** act on or display `error`, `error_description`, or `error_uri`.
+
## Resource Parameter Implementation
MCP clients **MUST** implement Resource Indicators for OAuth 2.0 as defined in [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html)
@@ -451,8 +266,7 @@ Specifically:
Authorization: Bearer
```
-Note that authorization **MUST** be included in every HTTP request from client to server,
-even if they are part of the same logical session.
+Note that authorization **MUST** be included in every HTTP request from client to server.
2. Access tokens **MUST NOT** be included in the URI query string
@@ -482,6 +296,22 @@ own resources.
MCP servers **MUST NOT** accept or transit any other tokens.
+## Refresh Tokens
+
+This section provides guidance for MCP Clients and MCP Servers when handling or issuing
+refresh tokens for both OAuth and OpenID Connect.
+
+**MCP Clients** that desire refresh tokens:
+
+- **MUST** keep refresh tokens confidential in transit and storage as specified in [OAuth 2.1 Section 4.3](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-14#section-4.3)
+- **SHOULD** include `refresh_token` in their `grant_types` client metadata
+- **MAY** add `offline_access` to the `scope` parameter of the authorization and token requests when the Authorization Server metadata contains it in `scopes_supported`
+- **MUST NOT** assume refresh tokens will be issued; the AS retains discretion
+
+**MCP Servers** (Protected Resources) **SHOULD NOT** include `offline_access` in
+`WWW-Authenticate` scope or Protected Resource Metadata `scopes_supported`, as refresh
+tokens are not a resource requirement.
+
## Error Handling
Servers **MUST** return appropriate HTTP status codes for authorization errors:
@@ -512,30 +342,38 @@ scope during runtime operations, the server **SHOULD** respond with:
- `error_description` (optional) - human-readable description of the error
**Server Scope Management**: When responding with insufficient scope errors, servers
-**SHOULD** include the scopes needed to satisfy the current request in the `scope`
-parameter.
-
-Servers have flexibility in determining which scopes to include:
-
-- **Minimum approach**: Include the newly-required scopes for the specific operation. Include any existing granted scopes as well, if they are required, to prevent clients from losing previously granted permissions.
-- **Recommended approach**: Include both existing relevant scopes and newly required scopes to prevent clients from losing previously granted permissions
-- **Extended approach**: Include existing scopes, newly required scopes, and related scopes that commonly work together
-
-The choice depends on the server's assessment of user experience impact and authorization friction.
+**SHOULD** include the scopes needed to satisfy the current operation in the `scope`
+parameter, consistent with
+[RFC 6750 Section 3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1).
+The `scope` attribute describes the scopes necessary to access
+the requested resource — servers are not required to include
+the client's previously granted scopes.
+
+Whatever scope-inclusion strategy a server adopts, servers **SHOULD** include all
+scopes required for the current operation in a single challenge.
+Challenging incrementally (returning one missing scope, then another
+on the subsequent retry) forces multiple authorization round-trips
+for a single operation and degrades user experience. The required
+scopes may be determined dynamically based on the specific request
+arguments and context, but once determined, they should be emitted
+together.
Servers **SHOULD** be consistent in their scope inclusion strategy to provide predictable behavior for clients.
Servers **SHOULD** consider the user experience impact when determining which scopes to include in the
response, as misconfigured scopes may require frequent user interaction.
+Scope accumulation across operations is a client-side responsibility. See the
+[Step-Up Authorization Flow](#step-up-authorization-flow) for the scope-union requirement.
+
Example insufficient scope response:
```http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
- scope="files:read files:write user:profile",
+ scope="files:write",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
- error_description="Additional file write permission required"
+ error_description="File write permission required for this operation"
```
#### Step-Up Authorization Flow
@@ -548,153 +386,31 @@ Clients acting on behalf of a user **SHOULD** attempt the step-up authorization
The flow is as follows:
1. **Parse error information** from the authorization server response or `WWW-Authenticate` header
-2. **Determine required scopes** as outlined in [Scope Selection Strategy](#scope-selection-strategy).
+2. **Determine required scopes** by computing the union of the
+ client's previously requested scope set and the scopes from
+ the current challenge. This ensures previously granted
+ permissions are preserved when servers emit per-operation
+ scope challenges per
+ [RFC 6750 Section 3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1).
+ Clients **MAY** also consult the
+ [Scope Selection Strategy](#scope-selection-strategy) for
+ initial scope selection guidance.
3. **Initiate (re-)authorization** with the determined scope set
4. **Retry the original request** with the new authorization no more than a few times and treat this as a permanent authorization failure
Clients **SHOULD** implement retry limits and **SHOULD** track scope upgrade attempts to avoid
repeated failures for the same resource and operation combination.
-## Security Considerations
-
-Implementations **MUST** follow OAuth 2.1 security best practices as laid out in [OAuth 2.1 Section 7. "Security Considerations"](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#name-security-considerations).
-
-### Token Audience Binding and Validation
-
-[RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) Resource Indicators provide critical security benefits by binding tokens to their intended
-audiences **when the Authorization Server supports the capability**. To enable current and future adoption:
-
-- MCP clients **MUST** include the `resource` parameter in authorization and token requests as specified in the [Resource Parameter Implementation](#resource-parameter-implementation) section
-- MCP servers **MUST** validate that tokens presented to them were specifically issued for their use
-
-The [Security Best Practices document](/specification/2025-11-25/basic/security_best_practices#token-passthrough)
-outlines why token audience validation is crucial and why token passthrough is explicitly forbidden.
-
-### Token Theft
-
-Attackers who obtain tokens stored by the client, or tokens cached or logged on the server can access protected resources with
-requests that appear legitimate to resource servers.
-
-Clients and servers **MUST** implement secure token storage and follow OAuth best practices,
-as outlined in [OAuth 2.1, Section 7.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-7.1).
-
-Authorization servers **SHOULD** issue short-lived access tokens to reduce the impact of leaked tokens.
-For public clients, authorization servers **MUST** rotate refresh tokens as described in [OAuth 2.1 Section 4.3.1 "Token Endpoint Extension"](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-4.3.1).
-
-### Communication Security
-
-Implementations **MUST** follow [OAuth 2.1 Section 1.5 "Communication Security"](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-1.5).
-
-Specifically:
-
-1. All authorization server endpoints **MUST** be served over HTTPS.
-1. All redirect URIs **MUST** be either `localhost` or use HTTPS.
-
-### Authorization Code Protection
-
-An attacker who has gained access to an authorization code contained in an authorization response can try to redeem the authorization code for an access token or otherwise make use of the authorization code.
-(Further described in [OAuth 2.1 Section 7.5](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-7.5))
-
-To mitigate this, MCP clients **MUST** implement PKCE according to [OAuth 2.1 Section 7.5.2](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-7.5.2) and **MUST** verify PKCE support before proceeding with authorization.
-PKCE helps prevent authorization code interception and injection attacks by requiring clients to create a secret verifier-challenge pair, ensuring that only the original requestor can exchange an authorization code for tokens.
+Servers **MUST** account for scope hierarchies, where a broader scope implies narrower ones, when
+deciding whether a token is sufficient for an operation.
-MCP clients **MUST** use the `S256` code challenge method when technically capable, as required by [OAuth 2.1 Section 4.1.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-4.1.1).
-
-Since OAuth 2.1 and PKCE specifications do not define a mechanism for clients to discover PKCE support, MCP clients **MUST** rely on authorization server metadata to verify this capability:
-
-- **OAuth 2.0 Authorization Server Metadata**: If `code_challenge_methods_supported` is absent, the authorization server does not support PKCE and MCP clients **MUST** refuse to proceed.
-
-- **OpenID Connect Discovery 1.0**: While the [OpenID Provider Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata) does not define `code_challenge_methods_supported`, this field is commonly included by OpenID providers. MCP clients **MUST** verify the presence of `code_challenge_methods_supported` in the provider metadata response. If the field is absent, MCP clients **MUST** refuse to proceed.
-
-Authorization servers providing OpenID Connect Discovery 1.0 **MUST** include `code_challenge_methods_supported` in their metadata to ensure MCP compatibility.
-
-### Open Redirection
-
-An attacker may craft malicious redirect URIs to direct users to phishing sites.
-
-MCP clients **MUST** have redirect URIs registered with the authorization server.
-
-Authorization servers **MUST** validate exact redirect URIs against pre-registered values to prevent redirection attacks.
-
-MCP clients **SHOULD** use and verify state parameters in the authorization code flow
-and discard any results that do not include or have a mismatch with the original state.
-
-Authorization servers **MUST** take precautions to prevent redirecting user agents to untrusted URI's, following suggestions laid out in [OAuth 2.1 Section 7.12.2](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-7.12.2)
-
-Authorization servers **SHOULD** only automatically redirect the user agent if it trusts the redirection URI. If the URI is not trusted, the authorization server MAY inform the user and rely on the user to make the correct decision.
-
-### Client ID Metadata Document Security
-
-When implementing Client ID Metadata Documents, authorization servers **MUST** consider the security implications
-detailed in [OAuth Client ID Metadata Document, Section 6](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#name-security-considerations).
-Key considerations include:
-
-#### Authorization Server Abuse Protection
-
-The authorization server takes a URL as input from an unknown client and fetches that URL.
-A malicious client could use this to trigger the authorization server to make requests to arbitrary URLs,
-such as requests to private administration endpoints the authorization server has access to.
-
-Authorization servers fetching metadata documents **SHOULD** consider
-[Server-Side Request Forgery (SSRF)](https://developer.mozilla.org/docs/Web/Security/Attacks/SSRF) risks, as described in [OAuth Client ID Metadata Document: Server Side Request Forgery (SSRF) Attacks](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#name-server-side-request-forgery).
-
-#### Localhost Redirect URI Risks
-
-Client ID Metadata Documents cannot prevent `localhost` URL impersonation by themselves. An attacker can claim to be any client by:
-
-1. Providing the legitimate client's metadata URL as their `client_id`
-2. Binding to the any `localhost` port, and providing that address as the redirect_uri
-3. Receiving the authorization code via the redirect when the user approves
-
-The server will see the legitimate client's metadata document and the user will see the legitimate client's name, making attack detection difficult.
-
-Authorization servers:
-
-- **SHOULD** display additional warnings for `localhost`-only redirect URIs
-- **MAY** require additional attestation mechanisms for enhanced security
-- **MUST** clearly display the redirect URI hostname during authorization
-
-#### Trust Policies
-
-Authorization servers **MAY** implement domain-based trust policies:
-
-- Allowlists for trusted domains (for protected servers)
-- Accept any HTTPS `client_id` (for open servers)
-- Reputation checks for unknown domains
-- Restrictions based on domain age or certificate validation
-- Display the CIMD and other associated client hostnames prominently to prevent phishing
-
-Servers maintain full control over their access policies.
-
-### Confused Deputy Problem
-
-Attackers can exploit MCP servers acting as intermediaries to third-party APIs, leading to [confused deputy vulnerabilities](/specification/2025-11-25/basic/security_best_practices#confused-deputy-problem).
-By using stolen authorization codes, they can obtain access tokens without user consent.
-
-MCP proxy servers using static client IDs **MUST** obtain user consent for each dynamically
-registered client before forwarding to third-party authorization servers (which may require additional consent).
-
-### Access Token Privilege Restriction
-
-An attacker can gain unauthorized access or otherwise compromise an MCP server if the server accepts tokens issued for other resources.
-
-This vulnerability has two critical dimensions:
-
-1. **Audience validation failures.** When an MCP server doesn't verify that tokens were specifically intended for it (for example, via the audience claim, as mentioned in [RFC9068](https://www.rfc-editor.org/rfc/rfc9068.html)), it may accept tokens originally issued for other services. This breaks a fundamental OAuth security boundary, allowing attackers to reuse legitimate tokens across different services than intended.
-2. **Token passthrough.** If the MCP server not only accepts tokens with incorrect audiences but also forwards these unmodified tokens to downstream services, it can potentially cause the ["confused deputy" problem](#confused-deputy-problem), where the downstream API may incorrectly trust the token as if it came from the MCP server or assume the token was validated by the upstream API. See the [Token Passthrough section](/specification/2025-11-25/basic/security_best_practices#token-passthrough) of the Security Best Practices guide for additional details.
-
-MCP servers **MUST** validate access tokens before processing the request, ensuring the access token is issued specifically for the MCP server, and take all necessary steps to ensure no data is returned to unauthorized parties.
-
-A MCP server **MUST** follow the guidelines in [OAuth 2.1 - Section 5.2](https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-5.2) to validate inbound tokens.
-
-MCP servers **MUST** only accept tokens specifically intended for themselves and **MUST** reject tokens that do not include them in the audience claim or otherwise verify that they are the intended recipient of the token. See the [Security Best Practices Token Passthrough section](/specification/2025-11-25/basic/security_best_practices#token-passthrough) for details.
-
-If the MCP server makes requests to upstream APIs, it may act as an OAuth client to them. The access token used at the upstream API is a separate token, issued by the upstream authorization server. The MCP server **MUST NOT** pass through the token it received from the MCP client.
+## Security Considerations
-MCP clients **MUST** implement and use the `resource` parameter as defined in [RFC 8707 - Resource Indicators for OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8707.html)
-to explicitly specify the target resource for which the token is being requested. This requirement aligns with the recommendation in
-[RFC 9728 Section 7.4](https://datatracker.ietf.org/doc/html/rfc9728#section-7.4). This ensures that access tokens are bound to their intended resources and
-cannot be misused across different services.
+Implementations of this specification **MUST** follow the normative security
+requirements in [Security Considerations](/specification/2026-07-28/basic/authorization/security-considerations),
+covering token audience binding and validation, token theft, communication security,
+authorization code protection, mix-up and confused deputy attacks, open redirection,
+and Client ID Metadata Document security.
## MCP Authorization Extensions
diff --git a/spec/basic.md b/spec/basic.md
index 11c6fb75..ca663264 100644
--- a/spec/basic.md
+++ b/spec/basic.md
@@ -7,15 +7,15 @@ title: Overview
The Model Context Protocol consists of several key components that work together:
- **Base Protocol**: Core JSON-RPC message types
-- **Lifecycle Management**: Connection initialization, capability negotiation, and
- session control
+- **Versioning and Compatibility**: Protocol version negotiation, extension negotiation, and interoperability with earlier protocol revisions
+- **Message Patterns**: Messaging patterns supported by the core protocol including request and response, multi round-trip requests (MRTR), and subscribe and notify
- **Authorization**: Authentication and authorization framework for HTTP-based transports
- **Server Features**: Resources, prompts, and tools exposed by servers
-- **Client Features**: Sampling and root directory lists provided by clients
+- **Client Features**: Elicitation, sampling and root directory lists provided by clients
- **Utilities**: Cross-cutting concerns like logging and argument completion
-All implementations **MUST** support the base protocol and lifecycle management
-components. Other components **MAY** be implemented based on the specific needs of the
+All implementations **MUST** support the base protocol, versioning,
+and the message patterns. Other components **MAY** be implemented based on the specific needs of the
application.
These protocol layers establish clear separation of concerns while enabling rich
@@ -30,7 +30,7 @@ these types of messages:
### Requests
-[Requests](/specification/2025-11-25/schema#jsonrpcrequest) are sent from the client to the server or vice versa, to initiate an operation.
+[Requests](/specification/2026-07-28/schema#jsonrpcrequest) are sent from the client to the server, to initiate an operation.
```typescript
{
@@ -45,8 +45,8 @@ these types of messages:
- Requests **MUST** include a string or integer ID.
- Unlike base JSON-RPC, the ID **MUST NOT** be `null`.
-- The request ID **MUST NOT** have been previously used by the requestor within the same
- session.
+- The request ID **MUST NOT** match the ID of any other request the sender has issued and
+ not yet received a response for.
### Responses
@@ -54,25 +54,39 @@ Responses are sent in reply to requests, containing either the result or error o
#### Result Responses
-[Result responses](/specification/2025-11-25/schema#jsonrpcresultresponse) are sent when the operation completes successfully.
+[Result responses](/specification/2026-07-28/schema#jsonrpcresultresponse) are sent when the operation completes successfully.
```typescript
{
jsonrpc: "2.0";
id: string | number;
result: {
+ resultType: string;
[key: string]: unknown;
- }
+ };
}
```
- Result responses **MUST** include the same ID as the request they correspond to.
- Result responses **MUST** include a `result` field.
- The `result` **MAY** follow any JSON object structure.
+- The `result` **MUST** include a `resultType` field to indicate the type of the result.
+
+##### ResultType
+
+The `resultType` field in a result indicates the type of the result being returned. MCP supports polymorphic result types,
+allowing servers to return different structures based on the outcome of the request. The `resultType` field is a string that clients
+can use to determine how to parse and handle the `result` object.
+
+- A `resultType` of `"complete"` indicates the request completed successfully and the result contains the final content.
+- A `resultType` of `"input_required"` indicates the request is incomplete and more information is needed to process the request. The result contains an [`InputRequiredResult`](/specification/2026-07-28/basic/patterns/mrtr#inputrequiredresult) object with additional information needed.
+- Extensions **MAY** add additional `ResultType` values. The set of supported `ResultType` values **MUST** be created from the set defined in the core protocol and include any additional values of supported extensions that are advertised via capabilities.
+- A `resultType` of any value unrecognized by the client **MUST** be considered invalid.
+- For backward compatibility with servers implementing earlier protocol versions, which do not include `resultType`, clients **MUST** treat an absent `resultType` as `"complete"`.
#### Error Responses
-[Error responses](/specification/2025-11-25/schema#jsonrpcerrorresponse) are sent when the operation fails or encounters an error.
+[Error responses](/specification/2026-07-28/schema#jsonrpcerrorresponse) are sent when the operation fails or encounters an error.
```typescript
{
@@ -89,10 +103,60 @@ Responses are sent in reply to requests, containing either the result or error o
- Error responses **MUST** include the same ID as the request they correspond to (except in error cases where the ID could not be read due a malformed request).
- Error responses **MUST** include an `error` field with a `code` and `message`.
- Error codes **MUST** be integers.
+- Error responses **MAY** include a `data` member with additional information of any type, such
+ as nested errors.
+
+#### Error Codes
+
+MCP uses the standard JSON-RPC 2.0 error codes (`-32700`, `-32600` to `-32603`)
+for general protocol failures.
+
+JSON-RPC 2.0 reserves the range `-32000` to `-32099` for implementation-defined
+server errors. MCP partitions this range as follows:
+
+- **`-32000` to `-32019` — legacy.** Codes in this sub-range were allocated by
+ implementations before this policy was introduced. New codes **MUST NOT** be
+ allocated in this sub-range, and new implementations **SHOULD NOT** use codes
+ from this sub-range at all. Apart from `-32002` (see below), receivers
+ **MUST NOT** assume any specific meaning for these codes.
+- **`-32020` to `-32099` — reserved for the MCP specification.** Error codes
+ in this sub-range are defined exclusively by the MCP specification and
+ recorded in the [schema](/specification/2026-07-28/schema). Implementations
+ **MUST NOT** emit any code from this sub-range that is not defined by this
+ specification and **MUST** use defined codes only with their specified
+ meanings.
+
+MCP defines the following error codes:
+
+| Code | Name |
+| -------- | ---------------------------------------------------------------------------------------------------------- |
+| `-32020` | [`HeaderMismatch`](/specification/2026-07-28/schema#headermismatcherror) |
+| `-32021` | [`MissingRequiredClientCapability`](/specification/2026-07-28/schema#missingrequiredclientcapabilityerror) |
+| `-32022` | [`UnsupportedProtocolVersion`](/specification/2026-07-28/schema#unsupportedprotocolversionerror) |
+
+Codes defined by earlier protocol versions remain reserved and will not be
+reused. Implementations of this protocol version **MUST NOT** emit these codes:
+
+- `-32002` — resource not found (2025-11-25 and earlier; replaced by `-32602`).
+ Clients [**SHOULD** still
+ accept `-32002`](/specification/2026-07-28/server/resources#error-handling) from
+ servers implementing earlier versions.
+- `-32042` — URL elicitation required (2025-11-25 only).
+
+Errors that are purely local to an implementation (for example, a request
+timeout raised inside an SDK) are not currently assigned codes by this
+specification. Implementations surfacing local errors in JSON-RPC-shaped
+structures should ensure they cannot be mistaken for errors received from the
+peer. Future versions of the specification may define standard codes for
+common local error conditions in the reserved sub-range.
+
+New error codes for purposes not defined by this specification **SHOULD** be
+allocated outside the JSON-RPC reserved range (`-32768` to `-32000`); the
+remainder of the integer space is available for application-defined errors.
### Notifications
-[Notifications](/specification/2025-11-25/schema#jsonrpcnotification) are sent from the client to the server or vice versa, as a one-way message.
+[Notifications](/specification/2026-07-28/schema#jsonrpcnotification) are sent from the client to the server or vice versa, as a one-way message.
The receiver **MUST NOT** send a response.
```typescript
@@ -107,9 +171,56 @@ The receiver **MUST NOT** send a response.
- Notifications **MUST NOT** include an ID.
+### Message Patterns
+
+The Model Context Protocol (MCP) supports several [Message Patterns](/specification/2026-07-28/basic/patterns) that define how clients and servers interact:
+
+1. **[Request and Response](/specification/2026-07-28/basic/patterns#request-and-response)**: A client sends a request to the server, and the server responds with a result or error.
+2. **[Multi Round-Trip Requests (MRTR)](/specification/2026-07-28/basic/patterns#multi-round-trip-requests)**: A server requires additional client input (sampling, elicitation, or roots) to complete a request.
+3. **[Subscribe and Notify](/specification/2026-07-28/basic/patterns#subscribe-and-notify)**: A client subscribes to a stream of notifications from the server, which are sent as they occur.
+
+## Statelessness
+
+The Model Context Protocol (MCP) is a **stateless protocol**: all the
+information needed to process a request is contained in the request itself.
+A server processes each request independently; no state should be inferred
+from previous requests, even those on the same connection or stream.
+
+Specifically:
+
+- Servers **MUST NOT** rely on prior requests over the same connection to
+ establish context (e.g., capabilities, protocol version, client identity).
+ Every request supplies this metadata in its [`_meta`](#_meta) field.
+- Servers **SHOULD** be prepared to handle requests associated with multiple
+ tasks, threads, or conversations.
+- Servers **SHOULD NOT** require that a client reuse the same connection or process to
+ perform related operations.
+- Clients **SHOULD NOT** use an individual task, thread, or conversation as the
+ lifetime boundary for the stdio process.
+- State that needs to span multiple requests (e.g., long-running tasks,
+ application-level handles) **MUST** be referenced by an explicit identifier
+ the client passes on each request.
+
+
+ This implies that an open connection, such as a STDIO process, is not a
+ conversation or session: clients may interleave unrelated requests on the same
+ transport, and a server must not treat connection or process identity as a
+ proxy for conversation or session continuity.
+
+
+Long-lived requests like
+[`subscriptions/listen`](/specification/2026-07-28/basic/patterns/subscriptions)
+remain request/response; the response is just an open stream of notifications.
+Their state is scoped to the request itself, not to the connection underneath.
+
+
+ For a walkthrough of how the per-request model maps to SDK code, see the
+ [Architecture guide](/docs/2026-07-28/learn/architecture#example).
+
+
## Auth
-MCP provides an [Authorization](/specification/2025-11-25/basic/authorization) framework for use with HTTP.
+MCP provides an [Authorization](/specification/2026-07-28/basic/authorization) framework for use with HTTP.
Implementations using an HTTP-based transport **SHOULD** conform to this specification,
whereas implementations using STDIO transport **SHOULD NOT** follow this specification,
and instead retrieve credentials from the environment.
@@ -125,11 +236,11 @@ to help shape the future of the protocol!
## Schema
The full specification of the protocol is defined as a
-[TypeScript schema](https://github.com/modelcontextprotocol/specification/blob/main/schema/2025-11-25/schema.ts).
+[TypeScript schema](https://github.com/modelcontextprotocol/specification/blob/main/schema/2026-07-28/schema.ts).
This is the source of truth for all protocol messages and structures.
There is also a
-[JSON Schema](https://github.com/modelcontextprotocol/specification/blob/main/schema/2025-11-25/schema.json),
+[JSON Schema](https://github.com/modelcontextprotocol/specification/blob/main/schema/2026-07-28/schema.json),
which is automatically generated from the TypeScript source of truth, for use with
various automated tooling.
@@ -185,18 +296,36 @@ MCP supports JSON Schema with the following rules:
- Schemas **MUST** be valid according to their declared or default dialect
+### `$ref` Resolution
+
+JSON Schema 2020-12 permits `$ref` to point at an absolute URI. Implementations **MUST NOT**
+automatically dereference `$ref` values that resolve to a network URI.
+
+Implementations **MAY** offer an opt-in mode that fetches non-local `$ref`s but it
+**MUST** be disabled by default and **SHOULD** enforce an allowlist of hosts or at
+minimum reject loopback, link-local, and private network addresses, apply timeouts and
+size limits, and log dereferenced URIs.
+
+Schemas that fail to validate due to an unresolved external `$ref` **SHOULD** be rejected
+rather than silently treated as permissive.
+
+### Composition-Keyword Resource Use
+
+Composition keywords (`anyOf`, `oneOf`, `allOf`, `if`/`then`/`else`) and `$defs` enable
+expressive schemas but can be expensive to validate. Implementations **SHOULD** apply
+reasonable bounds, such as a maximum schema depth, a cap on the total number of subschemas,
+or a per-validation time budget, to prevent a malicious schema from acting as a Denial-of-Service
+vector against the validator.
+
## General fields
### `_meta`
-The `_meta` property/parameter is reserved by MCP to allow clients and servers
+The `_meta` property/parameter is used by MCP to allow clients and servers
to attach additional metadata to their interactions.
Certain key names are reserved by MCP for protocol-level metadata, as specified below;
-implementations MUST NOT make assumptions about values at these keys.
-
-Additionally, definitions in the [schema](https://github.com/modelcontextprotocol/specification/blob/main/schema/2025-11-25/schema.ts)
-may reserve particular names for purpose-specific metadata, as declared in those definitions.
+implementations **MUST NOT** make assumptions about values at these keys.
**Key name format:** valid `_meta` key names have two segments: an optional **prefix**, and a **name**.
@@ -214,6 +343,108 @@ may reserve particular names for purpose-specific metadata, as declared in those
- Unless empty, MUST begin and end with an alphanumeric character (`[a-z0-9A-Z]`).
- MAY contain hyphens (`-`), underscores (`_`), dots (`.`), and alphanumerics in between.
+**Reserved keys:**
+
+The following `_meta` keys are reserved by this specification:
+
+| Key | Description | Defined in |
+| -------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------- |
+| `progressToken` | Opts the request into progress notifications | [Progress](/specification/2026-07-28/basic/patterns/progress) |
+| `io.modelcontextprotocol/protocolVersion` | Protocol version for a request | Per-request protocol fields (below) |
+| `io.modelcontextprotocol/clientInfo` | Client name and version | Per-request protocol fields (below) |
+| `io.modelcontextprotocol/clientCapabilities` | Client capabilities relevant to a request | Per-request protocol fields (below) |
+| `io.modelcontextprotocol/logLevel` | Minimum log level the server should emit for a request | [Logging](/specification/2026-07-28/server/utilities/logging) |
+| `io.modelcontextprotocol/subscriptionId` | Correlates a notification with its originating subscription | [Subscriptions](/specification/2026-07-28/basic/patterns/subscriptions) |
+| `traceparent`, `tracestate`, `baggage` | OpenTelemetry trace context propagation | OpenTelemetry trace context (below) |
+
+Official [extensions](/specification/2026-07-28/basic/versioning#extension-negotiation)
+define additional `_meta` keys under the `io.modelcontextprotocol/` prefix, and
+third-party extensions use their own vendor prefix.
+In both cases the keys are specified in the extension's documentation.
+
+**Per-request protocol fields:**
+
+Client requests carry the following `io.modelcontextprotocol/*` fields in `_meta`;
+fields marked as required **MUST** be included on every request. Servers use these
+to identify the protocol version and capabilities in use without relying on any
+prior connection state. See
+[Versioning and Compatibility][lifecycle] for version negotiation rules.
+
+| Key | Type | Required | Description |
+| -------------------------------------------- | -------------------- | -------- | --------------------------------------------------------- |
+| `io.modelcontextprotocol/protocolVersion` | `string` | Yes | Protocol version for this request (e.g., `"2026-07-28"`) |
+| `io.modelcontextprotocol/clientInfo` | `Implementation` | No | Client name and version |
+| `io.modelcontextprotocol/clientCapabilities` | `ClientCapabilities` | Yes | Client capabilities relevant to this request |
+| `io.modelcontextprotocol/logLevel` | `LoggingLevel` | No | Minimum log level the server should emit for this request |
+
+A request missing any required field is malformed; the server **MUST** reject it with
+JSON-RPC error code `-32602` (Invalid params). On HTTP, the response status **MUST** be
+`400 Bad Request`.
+
+Clients **SHOULD** include `io.modelcontextprotocol/clientInfo` on every request
+unless specifically configured not to do so.
+
+A server **MUST NOT** rely on capabilities the client has not declared. If
+processing a request requires a capability the client did not include in
+`io.modelcontextprotocol/clientCapabilities`, the server **MUST** return a
+[`MissingRequiredClientCapabilityError`](/specification/2026-07-28/schema#missingrequiredclientcapabilityerror)
+(`-32021`) whose `data.requiredCapabilities` lists the missing capabilities. On
+HTTP, the response status **MUST** be `400 Bad Request`.
+
+**Per-response protocol fields:**
+
+Servers **SHOULD** include the following `io.modelcontextprotocol/*` field in
+every result's `_meta`, unless specifically configured not to do so, to
+identify themselves without relying on any prior connection state:
+
+| Key | Type | Required | Description |
+| ------------------------------------ | ---------------- | -------- | ----------------------- |
+| `io.modelcontextprotocol/serverInfo` | `Implementation` | No | Server name and version |
+
+
+ `io.modelcontextprotocol/clientInfo` and `io.modelcontextprotocol/serverInfo`
+ are self-reported by the sender and are not verified by the protocol. They are
+ intended for display, logging, and debugging. Implementations **SHOULD NOT**
+ use them to change the behavior of the client or server, and **SHOULD NOT**
+ rely on them for security decisions.
+
+
+On notifications delivered via a [`subscriptions/listen`][subscriptions-listen] stream,
+the server **MUST** include `io.modelcontextprotocol/subscriptionId` in `_meta` so the
+client can correlate the notification with the originating subscription request.
+
+[lifecycle]: /specification/2026-07-28/basic/versioning
+[subscriptions-listen]: /specification/2026-07-28/basic/patterns/subscriptions
+
+**OpenTelemetry trace context:**
+
+As an exception to the prefix requirement above, the keys `traceparent`, `tracestate`, and
+`baggage` are reserved for [OpenTelemetry](https://opentelemetry.io/) trace context propagation.
+When present, their values MUST follow [W3C Trace Context](https://www.w3.org/TR/trace-context/)
+and [W3C Baggage](https://www.w3.org/TR/baggage/) formats respectively.
+
+This exception exists to maintain compatibility with existing implementations and
+[OpenTelemetry semantic conventions for MCP](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/).
+
+Non-normative example of trace context in `_meta`:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {
+ "name": "get_weather",
+ "arguments": {
+ "location": "New York"
+ },
+ "_meta": {
+ "traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
+ }
+ }
+}
+```
+
### `icons`
The `icons` property provides a standardized way for servers to expose visual identifiers for their resources, tools, prompts, and implementations. Icons enhance user interfaces by providing visual context and improving the discoverability of available functionality.
diff --git a/spec/caching.md b/spec/caching.md
new file mode 100644
index 00000000..577c5ff5
--- /dev/null
+++ b/spec/caching.md
@@ -0,0 +1,179 @@
+---
+title: Caching
+---
+
+
+
+The Model Context Protocol (MCP) supports caching for some results. This allows clients to cache responses and reduce unnecessary re-fetching.
+Caching is complementary to [change notifications](#interaction-with-notifications)—both
+mechanisms can coexist.
+
+## Cacheable Results
+
+Servers MUST include caching hints on results with `resultType: "complete"` returned by
+the following operations:
+
+- `server/discover`
+- `tools/list`
+- `prompts/list`
+- `resources/list`
+- `resources/templates/list`
+- `resources/read`
+
+Interim results with `resultType: "input_required"` (see
+[multi round-trip requests](/specification/2026-07-28/basic/patterns/mrtr)) are not cacheable
+and carry no caching hints.
+
+## Cache Key
+
+A cached response is identified by the request method together with the request
+parameters that affect the result (for example, the `uri` for `resources/read`, or the
+`cursor` for paginated list requests). Clients **MUST NOT** serve a cached response for
+a request whose method or parameters differ from the request that produced it.
+
+Results produced by retrying a request through the
+[multi round-trip requests](/specification/2026-07-28/basic/patterns/mrtr) mechanism—that
+is, requests carrying `inputResponses` or `requestState`—**MUST NOT** be cached,
+as they depend on inputs that are not part of the cache key.
+
+## Cacheable Model
+
+Cacheable Results in MCP use two fields to provide caching hints to clients:
+
+- The Time-to-live (TTL) Field,`ttlMs`, is an integer value in milliseconds specifying how long the client MAY consider the result fresh.
+- The Cache Scope Field,`cacheScope`, indicates the intended scope of the cached response, either `"public"` or `"private"`.
+
+### Time-to-Live (TTL) Field
+
+The `ttlMs` field is a hint from the server indicating how long, in
+milliseconds, the client MAY consider the result fresh. Semantics are
+analogous to HTTP `Cache-Control: max-age`.
+
+- If `ttlMs` is `0`, the response **SHOULD** be considered immediately stale. The client
+ MAY re-fetch every time the result is needed.
+- If `ttlMs` is positive, the client **SHOULD** consider the result fresh for that many
+ milliseconds after receiving the response.
+- If `ttlMs` is absent, clients **SHOULD** assume a default of `0` (immediately stale)
+ and rely on their own caching heuristics or notifications. This should only occur in older server versions.
+- If `ttlMs` is negative, clients **SHOULD** ignore it and treat it as `0`.
+
+Servers **MUST** provide a `ttlMs` value that is `>= 0`.
+
+
+ TTL is a **freshness hint**, not a guarantee. Servers MAY change the
+ underlying data before the TTL expires. The TTL tells the client how long it
+ can reasonably avoid re-fetching, not how long the data is guaranteed to
+ remain unchanged.
+
+
+#### Freshness Calculation
+
+A client records the local time at which the response was received (`t_received`). The
+response is considered **fresh** while:
+
+```
+now < t_received + ttlMs
+```
+
+Once the TTL expires, the response is **stale** and the client **SHOULD** re-fetch on
+next access.
+
+Clients **SHOULD NOT** treat TTL as a polling interval that triggers automatic background
+refetches. The TTL is a freshness hint: the client checks freshness when it needs the
+data, and re-fetches only if stale. Implementations that do choose to poll **MUST**
+apply jitter and backoff.
+
+Clients **MAY** re-fetch before the TTL expires if they have reason to believe the data
+has changed (e.g., receiving an unexpected error on a tool call indicating the method was
+not found or the parameters were invalid).
+
+Clients **MAY** serve stale responses if errors occur during re-fetching (e.g., network
+issues, server downtime).
+
+### Cache Scope Field
+
+The `cacheScope` field controls who may cache a response, analogous to HTTP
+`Cache-Control: public` vs `Cache-Control: private`.
+
+| Value | Meaning |
+| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `"public"` | The response does not contain user-specific data. Any client, shared gateway, or caching proxy **MAY** store and serve the cached response to any user. |
+| `"private"` | The response contains private data that is not meant to be shared between callers. Cached responses **MAY** be reused for the same authorization context. Caches **MUST NOT** be shared across authorization contexts (e.g. a different access token requires a different cache). |
+
+#### Choosing a Cache Scope
+
+- **`"public"`** is appropriate for lists of tools, prompts, and resource templates when
+ they are identical for all users.
+- **`"private"`** is appropriate for `resources/read` results that depend on the
+ authenticated user, or for filtered list results that vary per user.
+
+## Interaction with Notifications
+
+TTL and server-push notifications are complementary:
+
+- A server **MAY** provide `ttlMs` without advertising `listChanged: true` in its
+ capabilities. In this case, the client relies entirely on TTL-based freshness.
+- A server **MAY** advertise `listChanged: true` **and** provide `ttlMs`. In this case,
+ the client can use the TTL to avoid unnecessary refetches between notifications, and
+ the notification acts as an immediate invalidation signal.
+
+When a relevant notification is received while a cached response is still fresh, the
+notification **invalidates** the cached response and it should be considered immediately stale.
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Server
+
+ Client->>Server: tools/list
+ Server-->>Client: { tools: [...], ttlMs: 300000 }
+ Note over Client: Cache response, fresh for 5 min
+
+ Note over Client: 2 minutes later...
+ Client->>Client: Need tools list → cache still fresh, use cached
+
+ Note over Client: 3 minutes later (TTL expired)...
+ Client->>Client: Need tools list → cache stale
+ Client->>Server: tools/list
+ Server-->>Client: { tools: [...], ttlMs: 300000 }
+
+ Note over Server: Tools change before TTL expires
+ Server-->>Client: notifications/tools/list_changed
+ Note over Client: Invalidate cache immediately
+ Client->>Server: tools/list
+ Server-->>Client: { tools: [...], ttlMs: 300000 }
+```
+
+## Interaction with Pagination
+
+When a list result is [paginated](/specification/2026-07-28/server/utilities/pagination), each
+page is an independently cacheable response—consistent with how HTTP
+`Cache-Control` treats paginated resources.
+
+- Each page response carries its own `ttlMs` value. The freshness clock for each page
+ starts at the time that page was received.
+- Servers **MAY** return different `ttlMs` values on different pages (e.g., a longer TTL
+ for early pages of a stable list, a shorter TTL for the final page).
+- When a cached page expires, the client **SHOULD** re-fetch that page using its cursor.
+- There is no cross-page consistency guarantee. If the underlying data changes between
+ page fetches, clients may observe duplicates or gaps.
+- Clients that require a consistent snapshot of the full list **SHOULD** re-fetch from
+ the beginning (without a cursor).
+- If a cursor becomes invalid (e.g., the server returns an error for a previously valid
+ cursor), the client **SHOULD** discard all cached pages and re-fetch from the
+ beginning.
+
+Servers **MUST** apply the same `cacheScope` to all response pages for a given list
+request. For example, if the first page of a `tools/list` response has
+`cacheScope: "private"`, all subsequent pages for that request **MUST** also be
+`"private"`.
+
+## Security Considerations
+
+A `cacheScope` of `"public"` indicates that the response does not contain user-specific data and can be safely shared. Servers MUST be aware that responses with a `"public"` `cacheScope` may be shared between callers even if the Result is coming from an authenticated endpoint. For example, the Result from an authenticated `tools/list` call with a `"public"` `cacheScope` may be cached by a client and may be shared outside of the initial requests authorization context. (i.e. different access tokens can leverage the same cache).
+
+Server implementors:
+
+- should ensure that the `cacheScope` correctly reflects the intended visibility of the primitive.
+- MUST apply appropriate per-primitive access controls, and MUST NOT rely on
+ `cacheScope` alone to prevent unauthorized access to primitives.
diff --git a/spec/cancellation.md b/spec/cancellation.md
index 1ac449d5..b8db4c83 100644
--- a/spec/cancellation.md
+++ b/spec/cancellation.md
@@ -5,12 +5,17 @@ title: Cancellation
The Model Context Protocol (MCP) supports optional cancellation of in-progress requests
-through notification messages. Either side can send a cancellation notification to
-indicate that a previously-issued request should be terminated.
+through notification messages. A client **SHOULD** send a cancellation notification
+to indicate that a request it previously issued should be terminated.
+
+A server **MUST** send `notifications/cancelled`
+referencing a `subscriptions/listen` request ID when it tears down that subscription
+stream (see [Subscriptions][subscriptions]). Servers **MUST NOT** send
+`notifications/cancelled` for any other purpose.
## Cancellation Flow
-When a party wants to cancel an in-progress request, it sends a `notifications/cancelled`
+When a client wants to cancel an in-progress request, it sends a `notifications/cancelled`
notification containing:
- The ID of the request to cancel
@@ -27,23 +32,54 @@ notification containing:
}
```
+## Transport-Specific Cancellation
+
+How a client signals cancellation depends on the transport:
+
+- **Streamable HTTP**: Closing the SSE response stream is the cancellation signal.
+ The server **MUST** treat a client disconnect as cancellation of that request. No
+ `notifications/cancelled` message is required or expected.
+- **stdio**: There is no per-request stream to close. The client **MUST** send a
+ `notifications/cancelled` notification referencing the request ID.
+
+## Timeouts
+
+Implementations **SHOULD** establish timeouts for all sent requests, to prevent hung
+connections and resource exhaustion. When the request has not received a success or error
+response within the timeout period, the sender **SHOULD** cancel the request and stop
+waiting for a response. As described in
+[Transport-Specific Cancellation](#transport-specific-cancellation), this means:
+
+- **Streamable HTTP**: closing the response stream for the request, which constitutes
+ cancellation.
+- **stdio**: sending a `notifications/cancelled` notification referencing the request ID.
+
+SDKs and other middleware **SHOULD** allow these timeouts to be configured on a
+per-request basis.
+
+Implementations **MAY** choose to reset the timeout clock when receiving a
+[progress notification](/specification/2026-07-28/basic/patterns/progress) corresponding to
+the request, as this implies that work is actually happening. However, implementations
+**SHOULD** always enforce a maximum timeout, regardless of progress notifications, to
+limit the impact of a misbehaving client or server.
+
## Behavior Requirements
1. Cancellation notifications **MUST** only reference requests that:
- - Were previously issued in the same direction
+ - Were previously issued by the client
- Are believed to still be in-progress
-1. The `initialize` request **MUST NOT** be cancelled by clients
-1. For [task-augmented requests](./tasks), the `tasks/cancel` request **MUST** be used instead of the `notifications/cancelled` notification. Tasks have their own dedicated cancellation mechanism that returns the final task state.
-1. Receivers of cancellation notifications **SHOULD**:
+1. Server-sent cancellation notifications **MUST** reference a
+ `subscriptions/listen` request, to terminate that subscription stream
+1. Servers receiving cancellation notifications **SHOULD**:
- Stop processing the cancelled request
- Free associated resources
- Not send a response for the cancelled request
-1. Receivers **MAY** ignore cancellation notifications if:
+1. Servers **MAY** ignore cancellation notifications if:
- The referenced request is unknown
- Processing has already completed
- The request cannot be cancelled
-1. The sender of the cancellation notification **SHOULD** ignore any response to the
- request that arrives afterward
+1. The client **SHOULD** ignore any response to the cancelled request that arrives
+ afterward
## Timing Considerations
@@ -82,3 +118,5 @@ Invalid cancellation notifications **SHOULD** be ignored:
This maintains the "fire and forget" nature of notifications while allowing for race
conditions in asynchronous communication.
+
+[subscriptions]: /specification/2026-07-28/basic/patterns/subscriptions
diff --git a/spec/changelog.md b/spec/changelog.md
new file mode 100644
index 00000000..424b2653
--- /dev/null
+++ b/spec/changelog.md
@@ -0,0 +1,121 @@
+---
+title: Key Changes
+---
+
+
+
+This document lists changes made to the Model Context Protocol (MCP) specification since
+the previous revision, [2025-11-25](/specification/2025-11-25).
+
+## Major changes
+
+1. Remove protocol-level sessions and the `Mcp-Session-Id` header from the Streamable HTTP transport. List endpoints (`tools/list`, `resources/list`, `prompts/list`) no longer vary per-connection. Servers that need cross-call state use explicit, server-minted handles passed as ordinary tool arguments ([SEP-2567](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567)).
+
+2. Make MCP stateless: remove the `initialize`/`notifications/initialized` handshake. Every request now carries its protocol version and client capabilities in `_meta` (`io.modelcontextprotocol/protocolVersion`, `io.modelcontextprotocol/clientCapabilities`). Clients SHOULD identify themselves on each request (`io.modelcontextprotocol/clientInfo`), and servers SHOULD identify themselves in each result's `_meta` (`io.modelcontextprotocol/serverInfo`). Version mismatches return `UnsupportedProtocolVersionError` ([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)).
+
+3. Add `server/discover`: servers MUST implement this RPC to advertise their supported protocol versions, capabilities, and identity. Clients MAY call it before any other request for up-front version selection, or use it as a backward-compatibility probe on STDIO ([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)).
+
+4. Replace the HTTP GET endpoint and `resources/subscribe`/`resources/unsubscribe` with `subscriptions/listen`: a single long-lived POST-response stream for opted-in server-to-client change notifications. Clients opt in to specific types (`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, `resourceSubscriptions`); the server acknowledges and tags notifications with `io.modelcontextprotocol/subscriptionId`. Request-scoped notifications such as `notifications/progress` and `notifications/message` continue to flow on the response stream of the request they relate to, not the `subscriptions/listen` stream ([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)).
+
+5. Remove `ping`, `logging/setLevel`, and `notifications/roots/list_changed`. Log level is now set per-request via `io.modelcontextprotocol/logLevel` in `_meta`; servers MUST NOT emit `notifications/message` for requests that did not include this field ([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)).
+
+6. Move experimental tasks out of the core protocol and into an official extension (`io.modelcontextprotocol/tasks`). The redesigned extension replaces the blocking `tasks/result` method with polling via `tasks/get` and a new `tasks/update` for client-to-server input, removes `tasks/list`, and allows servers to return task handles unsolicited without per-request opt-in ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)).
+
+7. Multi Round-Trip Requests (MRTR) pattern introduced which replaces the previous approach of sending server-initiated requests, such as `roots/list`, `sampling/createMessage`, or `elicitation/create`. Servers return an `InputRequiredResult` (`resultType: "input_required"`) whose `inputRequests` field carries the requests for the additional information needed to process the request. Clients respond with `inputResponses` on a retry of the original request providing the requested information. ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)).
+
+8. All results now carry a required `resultType` field: `"complete"` for ordinary results and `"input_required"` for [multi round-trip request](/specification/2026-07-28/basic/patterns/mrtr) interim results. Clients **MUST** treat results from earlier-protocol servers that omit the field as `"complete"` ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)).
+
+9. Remove SSE stream resumability and message redelivery (the `Last-Event-ID` header and SSE event IDs) from the Streamable HTTP transport. A broken response stream loses the in-flight request; clients **MUST** re-issue it as a new request with a new request ID ([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)).
+
+## Minor changes
+
+1. Add `extensions` field to `ClientCapabilities` and `ServerCapabilities` to support optional [extensions](/docs/extensions/overview) beyond the core protocol.
+2. Document OpenTelemetry trace context propagation conventions for `_meta` keys (`traceparent`, `tracestate`, `baggage`) ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)).
+3. Servers **SHOULD** return tools from `tools/list` in a deterministic order to enable client-side caching and improve LLM prompt cache hit rates.
+4. Require standard MCP request headers (`Mcp-Method`, `Mcp-Name`) on Streamable HTTP POST requests, and add support for custom headers from tool parameters via `x-mcp-header` ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)).
+5. Require `ttlMs` and `cacheScope` fields on results returned by `tools/list`, `prompts/list`, `resources/list`, `resources/read`, and `resources/templates/list` via a new `CacheableResult` interface. `ttlMs` is a freshness hint (in milliseconds) allowing clients to cache responses and reduce polling; `cacheScope` (`"public"` or `"private"`) controls whether shared intermediaries may cache the response. Both fields complement existing `listChanged` notifications ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)).
+6. Change resource not found error code from `-32002` to `-32602` (Invalid Params) to align with JSON-RPC specification.
+7. Authorization servers **SHOULD** include the `iss` parameter in authorization responses per
+ [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207), and MCP clients **MUST** validate a
+ present `iss` against the recorded issuer before redeeming the authorization code
+ ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)).
+8. Require MCP clients to specify an appropriate `application_type` during Dynamic Client
+ Registration to avoid OpenID Connect redirect URI conflicts
+ ([SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)).
+9. Clarify that client credentials are bound to the authorization server that issued them:
+ clients **MUST** key persisted credentials by the issuer identifier, **MUST NOT** reuse them
+ with a different authorization server, and **MUST** re-register when the authorization server
+ changes ([SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)).
+10. Loosen `inputSchema` and `outputSchema` to allow any JSON Schema 2020-12 keywords, and
+ `structuredContent` to allow any JSON value. Add `$ref` resolution requirements and
+ composition-keyword resource bounds
+ ([SEP-2106](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2106)).
+11. Remove the `notifications/elicitation/complete` notification and the
+ `elicitationId` field of URL mode elicitation requests, both introduced in
+ `2025-11-25`. Under the
+ [Multi Round-Trip Requests](/specification/2026-07-28/basic/patterns/mrtr) pattern, the
+ client learns the outcome of an out-of-band interaction by retrying the original
+ request, so a server-initiated completion signal — and the identifier used to
+ correlate it — no longer fit the protocol. Servers needing to correlate an
+ elicitation across retries encode their own identifier in `requestState`.
+12. Define an [error code allocation policy](/specification/2026-07-28/basic/index#error-codes)
+ partitioning the JSON-RPC server-error range: `-32000` to `-32019` remains
+ implementation-defined (existing SDK usage is grandfathered), `-32020` to `-32099` is
+ reserved for the MCP specification. Renumber the error codes introduced in this draft
+ accordingly — `HeaderMismatch` `-32001` → `-32020`, `MissingRequiredClientCapability`
+ `-32003` → `-32021`, `UnsupportedProtocolVersion` `-32004` → `-32022` — and add
+ `HeaderMismatchError` to the schema, which previously existed only in transport prose.
+
+## Deprecated
+
+Features listed here remain part of the specification but are scheduled for removal under the [feature lifecycle and deprecation policy](/community/feature-lifecycle). New implementations should not adopt them. The [deprecated features registry](/specification/2026-07-28/deprecated) tracks every feature currently in the Deprecated state.
+
+1. Deprecate the Roots, Sampling, and Logging features
+ ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)).
+ These features remain fully functional during the deprecation window but new
+ implementations should not add support for them. Suggested migrations: pass
+ directories or files via tool parameters, resource URIs, or server
+ configuration instead of Roots; integrate directly with LLM provider APIs
+ instead of Sampling; log to `stderr` (stdio) or use OpenTelemetry instead of
+ Logging.
+
+2. Reclassify the HTTP+SSE transport (deprecated since protocol version
+ `2025-03-26`) as Deprecated under the feature lifecycle policy
+ ([SEP-2596](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2596)).
+ Migrate to [Streamable HTTP](/specification/2026-07-28/basic/transports/streamable-http).
+
+3. Reclassify the `includeContext` values `"thisServer"` and `"allServers"`
+ (soft-deprecated since protocol version `2025-11-25`) as Deprecated
+ ([SEP-2596](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2596)).
+ Omit the field or use `"none"`; these values will be removed no later than
+ the Sampling feature itself.
+
+4. Deprecate the OAuth 2.0 Dynamic Client Registration Protocol
+ ([RFC7591](https://datatracker.ietf.org/doc/html/rfc7591)) as a client registration
+ mechanism in favor of
+ [Client ID Metadata Documents](/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents)
+ ([PR #2858](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2858)).
+ It remains available for backwards compatibility with authorization servers that do
+ not support Client ID Metadata Documents.
+
+## Other schema changes
+
+1. `schema.json` now correctly reflects that the Typescript definition of minimum/maximum/default are `number`'s and not just `integers`. This was caused by running the generator using `--defaultNumberType integer` ([PR#2710](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2710)).
+
+## Governance and process updates
+
+1. Adopt a specification
+ [feature lifecycle and deprecation policy](/community/feature-lifecycle)
+ defining the Active, Deprecated, and Removed feature states, a minimum
+ twelve-month deprecation window, and a
+ [registry of deprecated features](/specification/2026-07-28/deprecated)
+ ([SEP-2596](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2596)).
+
+## Process changes
+
+1. Formalize PR-based SEP workflow with markdown files in `seps/` directory, PR-derived numbering, sponsor responsibilities, and status management via PR labels ([SEP-1850](https://github.com/modelcontextprotocol/specification/pull/1850)).
+
+## Full changelog
+
+For a complete list of all changes that have been made since the last protocol revision,
+[see GitHub](https://github.com/modelcontextprotocol/specification/compare/2025-11-25...2026-07-28).
diff --git a/spec/deprecated.md b/spec/deprecated.md
new file mode 100644
index 00000000..69b77b7a
--- /dev/null
+++ b/spec/deprecated.md
@@ -0,0 +1,41 @@
+---
+title: Deprecated Features
+---
+
+
+
+This page is the registry of specification features that are currently in the
+**Deprecated** state under the
+[feature lifecycle and deprecation policy](/community/feature-lifecycle)
+([SEP-2596](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2596)).
+
+A Deprecated feature remains part of the specification but is scheduled for
+removal: new implementations **SHOULD NOT** adopt it, and existing
+implementations **SHOULD** migrate before the feature's earliest removal. The
+earliest removal marks when a feature becomes _eligible_ for removal; the
+actual removal is a Core Maintainer decision taken during release preparation
+and may happen later.
+
+This registry is a derived view kept consistent with the per-feature
+deprecation notices and changelog entries, which are the normative records.
+
+## Deprecated
+
+| Feature | Deprecation SEP | Deprecated in | Migration path | Earliest removal |
+| ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
+| [Roots](/specification/2026-07-28/client/roots) | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) | `2026-07-28` | Pass directories or files via tool parameters, resource URIs, or server configuration | First revision released on or after 2027-07-28 |
+| [Sampling](/specification/2026-07-28/client/sampling) | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) | `2026-07-28` | Integrate directly with LLM provider APIs | First revision released on or after 2027-07-28 |
+| [Logging](/specification/2026-07-28/server/utilities/logging) | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) | `2026-07-28` | Log to `stderr` for stdio transports; use [OpenTelemetry](https://opentelemetry.io/) for observability | First revision released on or after 2027-07-28 |
+| [Dynamic Client Registration](/specification/2026-07-28/basic/authorization/client-registration#dynamic-client-registration) | [PR #2858](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2858) | `2026-07-28` | [Client ID Metadata Documents](/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents) | First revision released on or after 2027-07-28 |
+| `includeContext: "thisServer"` / `"allServers"` ([Sampling](/specification/2026-07-28/client/sampling#capabilities)) | [SEP-2596](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2596) | `2025-11-25` | Omit the field or use `"none"` | Follows Sampling ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)) |
+| [HTTP+SSE transport](/specification/2024-11-05/basic/transports#http-with-sse) | [SEP-2596](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2596) | `2025-03-26` | [Streamable HTTP](/specification/2026-07-28/basic/transports/streamable-http) | Three months after SEP-2596 reaches Final |
+
+The HTTP+SSE transport and the `includeContext` values were already described
+as deprecated before the lifecycle policy existed; SEP-2596 reclassifies them
+as Deprecated under its [transition provisions](/community/feature-lifecycle).
+
+## Removed
+
+No features have been removed under this policy yet. When a Deprecated feature
+is removed, its row moves to this section with a link to the changelog entry
+recording the removal.
diff --git a/spec/discover.md b/spec/discover.md
new file mode 100644
index 00000000..f6fc1acf
--- /dev/null
+++ b/spec/discover.md
@@ -0,0 +1,108 @@
+---
+title: Discovery
+---
+
+
+
+`server/discover` lets a client query a server's supported protocol versions,
+capabilities, and identity before sending any other requests. Servers **MUST**
+implement it.
+
+## Request
+
+The request carries no body parameters beyond the standard `_meta`:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "discover-1",
+ "method": "server/discover",
+ "params": {
+ "_meta": {
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
+ "io.modelcontextprotocol/clientInfo": {
+ "name": "ExampleClient",
+ "version": "1.0.0"
+ },
+ "io.modelcontextprotocol/clientCapabilities": {}
+ }
+ }
+}
+```
+
+## Response
+
+The server replies with its supported protocol versions, capabilities, and
+identity. This operation supports [caching](/specification/2026-07-28/server/utilities/caching).
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "discover-1",
+ "result": {
+ "resultType": "complete",
+ "supportedVersions": ["2026-07-28"],
+ "capabilities": {
+ "tools": {},
+ "resources": {}
+ },
+ "_meta": {
+ "io.modelcontextprotocol/serverInfo": {
+ "name": "ExampleServer",
+ "version": "1.0.0"
+ }
+ },
+ "instructions": "This server provides weather and resource utilities.",
+ "ttlMs": 3600000,
+ "cacheScope": "public"
+ }
+}
+```
+
+## When to Call
+
+Calling `server/discover` is optional for clients — a client may invoke any
+RPC inline and handle
+[`UnsupportedProtocolVersionError`](/specification/2026-07-28/schema#unsupportedprotocolversionerror)
+if the server does not support the requested version. However, `server/discover`
+is useful in two scenarios:
+
+- **Presenting server information.** While a client doesn't need to call
+ `server/discover` to use the server, it's a convenient way to retrieve the
+ server's identity, capabilities, and supported versions in a single request.
+ For example, a client can present the capabilities a server supports from a
+ single `server/discover` response instead of probing with separate
+ `tools/list`, `prompts/list`, and `resources/list` requests.
+- **stdio backward-compatibility probe.** On stdio, there is no per-request
+ HTTP status code to drive fallback. A client that supports both modern
+ (per-request `_meta`) and legacy (`initialize` handshake) servers **SHOULD**
+ send `server/discover` first; see
+ [stdio: Backward Compatibility](/specification/2026-07-28/basic/transports/stdio#backward-compatibility)
+ for the fallback rules.
+
+See [Protocol Version Negotiation](/specification/2026-07-28/basic/versioning#protocol-version-negotiation)
+for the full version-selection flow. For HTTP-specific status codes returned for
+unknown methods, see the [Protocol Version Header](/specification/2026-07-28/basic/transports/streamable-http#protocol-version-header)
+section in Transports.
+
+## Data Types
+
+### DiscoverResult
+
+A discovery result includes:
+
+- `supportedVersions`: Protocol versions the server supports. The client should
+ choose one of these for subsequent requests.
+- `capabilities`: Capabilities the server supports (tools, resources, prompts,
+ etc.)
+- `_meta['io.modelcontextprotocol/serverInfo']`: Name and version of the server
+ software. Servers **SHOULD** include this field.
+- `instructions`: Optional natural-language guidance for LLMs on how to use
+ this server effectively
+
+
+ `serverInfo` is self-reported by the server and is not verified by the
+ protocol. It is intended for display, logging, and debugging. Clients **SHOULD
+ NOT** use it to change their behavior, and **SHOULD NOT** rely on it for
+ security decisions.
+
diff --git a/spec/elicitation.md b/spec/elicitation.md
index d38ca265..13dbbb5a 100644
--- a/spec/elicitation.md
+++ b/spec/elicitation.md
@@ -48,15 +48,17 @@ MCP clients **MUST**:
## Capabilities
-Clients that support elicitation **MUST** declare the `elicitation` capability during
-[initialization](../basic/lifecycle#initialization):
+Clients that support elicitation **MUST** declare the `elicitation` capability in
+`_meta.io.modelcontextprotocol/clientCapabilities` on each request:
```json
{
- "capabilities": {
- "elicitation": {
- "form": {},
- "url": {}
+ "_meta": {
+ "io.modelcontextprotocol/clientCapabilities": {
+ "elicitation": {
+ "form": {},
+ "url": {}
+ }
}
}
}
@@ -66,8 +68,10 @@ For backwards compatibility, an empty capabilities object is equivalent to decla
```jsonc
{
- "capabilities": {
- "elicitation": {}, // Equivalent to { "form": {} }
+ "_meta": {
+ "io.modelcontextprotocol/clientCapabilities": {
+ "elicitation": {}, // Equivalent to { "form": {} }
+ },
},
}
```
@@ -80,7 +84,8 @@ Servers **MUST NOT** send elicitation requests with modes that are not supported
### Elicitation Requests
-To request information from a user, servers send an `elicitation/create` request.
+Servers **MAY** request information from a user during the processing of a client request, by sending an [`InputRequiredResult`](/specification/2026-07-28/basic/patterns/mrtr#inputrequiredresult)
+containing an `elicitation/create` request.
All elicitation requests **MUST** include the following parameters:
@@ -125,7 +130,6 @@ The schema is restricted to these primitive types:
"description": "Description text",
"minLength": 3,
"maxLength": 50,
- "pattern": "^[A-Za-z]+$",
"format": "email",
"default": "user@example.com"
}
@@ -236,12 +240,10 @@ Note that complex nested structures, arrays of objects (beyond enums), and other
#### Example: Simple Text Request
-**Request:**
+**Input request (delivered inside [`InputRequiredResult.inputRequests`](/specification/2026-07-28/basic/patterns/mrtr#inputrequests)):**
```json
{
- "jsonrpc": "2.0",
- "id": 1,
"method": "elicitation/create",
"params": {
"mode": "form",
@@ -259,29 +261,23 @@ Note that complex nested structures, arrays of objects (beyond enums), and other
}
```
-**Response:**
+**Client result (returned inside `inputResponses` on the retried request):**
```json
{
- "jsonrpc": "2.0",
- "id": 1,
- "result": {
- "action": "accept",
- "content": {
- "name": "octocat"
- }
+ "action": "accept",
+ "content": {
+ "name": "octocat"
}
}
```
#### Example: Structured Data Request
-**Request:**
+**Input request (delivered inside `InputRequiredResult.inputRequests`):**
```json
{
- "jsonrpc": "2.0",
- "id": 2,
"method": "elicitation/create",
"params": {
"mode": "form",
@@ -310,19 +306,15 @@ Note that complex nested structures, arrays of objects (beyond enums), and other
}
```
-**Response:**
+**Client result (returned inside `inputResponses` on the retried request):**
```json
{
- "jsonrpc": "2.0",
- "id": 2,
- "result": {
- "action": "accept",
- "content": {
- "name": "Monalisa Octocat",
- "email": "octocat@github.com",
- "age": 30
- }
+ "action": "accept",
+ "content": {
+ "name": "Monalisa Octocat",
+ "email": "octocat@github.com",
+ "age": 30
}
}
```
@@ -339,10 +331,9 @@ URL mode elicitation enables servers to direct users to external URLs for out-of
URL mode elicitation requests **MUST** specify `mode: "url"`, a `message`, and include these additional parameters:
-| Name | Type | Description |
-| --------------- | ------ | ----------------------------------------- |
-| `url` | string | The URL that the user should navigate to. |
-| `elicitationId` | string | A unique identifier for the elicitation. |
+| Name | Type | Description |
+| ----- | ------ | ----------------------------------------- |
+| `url` | string | The URL that the user should navigate to. |
The `url` parameter **MUST** contain a valid URL.
@@ -361,96 +352,35 @@ The `url` parameter **MUST** contain a valid URL.
This example shows a URL mode elicitation request directing the user to a secure URL where they can provide sensitive information (an API key, for example).
The same request could direct the user into an OAuth authorization flow, or a payment flow. The only difference is the URL and the message.
-**Request:**
+**Input request (delivered inside `InputRequiredResult.inputRequests`):**
```json
{
- "jsonrpc": "2.0",
- "id": 3,
"method": "elicitation/create",
"params": {
"mode": "url",
- "elicitationId": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://mcp.example.com/ui/set_api_key",
"message": "Please provide your API key to continue."
}
}
```
-**Response:**
+**Client result (returned inside `inputResponses` on the retried request):**
```json
{
- "jsonrpc": "2.0",
- "id": 3,
- "result": {
- "action": "accept"
- }
+ "action": "accept"
}
```
The response with `action: "accept"` indicates that the user has consented to the
interaction. It does not mean that the interaction is complete. The interaction occurs out
-of band and the client is not aware of the outcome until and unless the server sends a notification indicating completion.
-
-### Completion Notifications for URL Mode Elicitation
-
-Servers **MAY** send a `notifications/elicitation/complete` notification when an
-out-of-band interaction started by URL mode elicitation is completed. This allows clients to react programmatically if appropriate.
-
-Servers sending notifications:
-
-- **MUST** only send the notification to the client that initiated the elicitation request.
-- **MUST** include the `elicitationId` established in the original `elicitation/create` request.
-
-Clients:
-
-- **MUST** ignore notifications referencing unknown or already-completed IDs.
-- **MAY** wait for this notification to automatically retry requests that received a [URLElicitationRequiredError](#error-handling), update the user interface, or otherwise continue an interaction.
-- **SHOULD** still provide manual controls that let the user retry or cancel the original request (or otherwise resume interacting with the client) if the notification never arrives.
-
-#### Example
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "notifications/elicitation/complete",
- "params": {
- "elicitationId": "550e8400-e29b-41d4-a716-446655440000"
- }
-}
-```
-
-### URL Elicitation Required Error
-
-When a request cannot be processed until an elicitation is completed, the server **MAY** return a [`URLElicitationRequiredError`](#error-handling) (code `-32042`) to indicate to the client that a URL mode elicitation is required. The server **MUST NOT** return this error except when URL mode elicitation is required.
-
-The error **MUST** include a list of elicitations that are required to complete before the original can be retried.
-
-Any elicitations returned in the error **MUST** be URL mode elicitations and have an `elicitationId` property.
-
-**Error Response:**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2,
- "error": {
- "code": -32042, // URL_ELICITATION_REQUIRED
- "message": "This request requires more information.",
- "data": {
- "elicitations": [
- {
- "mode": "url",
- "elicitationId": "550e8400-e29b-41d4-a716-446655440000",
- "url": "https://mcp.example.com/connect?elicitationId=550e8400-e29b-41d4-a716-446655440000",
- "message": "Authorization is required to access your Example Co files."
- }
- ]
- }
- }
-}
-```
+of band and the client is not directly informed of the outcome. When the client retries
+the original request, the server determines from the echoed `requestState` (or its own
+stored state) whether the out-of-band interaction has completed, and either returns the
+final result or responds with another `InputRequiredResult`. Clients **SHOULD** provide
+manual controls that let the user retry or cancel the original request (or otherwise
+resume interacting with the client).
## Message Flow
@@ -462,16 +392,16 @@ sequenceDiagram
participant Client
participant Server
- Note over Server: Server initiates elicitation
- Server->>Client: elicitation/create (mode: form)
+ Client->>Server: tools/call(id: 1)
+ note over Server: Server needs more info
+ Server-->>Client: InputRequiredResult(elicitation/create (mode: form))
Note over User,Client: Present elicitation UI
User-->>Client: Provide requested information
- Note over Server,Client: Complete request
- Client->>Server: Return user response
-
- Note over Server: Continue processing with new information
+ Note over Server,Client: Retry request with new information
+ Client->>Server: tools/call(id: 2, user response)
+ Server-->>Client: Result(id: 2, result)
```
### URL Mode Flow
@@ -483,48 +413,22 @@ sequenceDiagram
participant Client
participant Server
- Note over Server: Server initiates elicitation
- Server->>Client: elicitation/create (mode: url)
+ Client->>Server: tools/call(id: 1)
+ Note over Server: Server needs more info Server creates requestState encoding url info.
+ Server-->>Client: InputRequiredResult(elicitation/create (mode: url), requestState)
Client->>User: Present consent to open URL
User-->>Client: Provide consent
Client->>UserAgent: Open URL
- Client->>Server: Accept response
+ Client->>Server: tools/call(id: 2, Accept Response, requestState))
+ Note over Server: Server uses requestState to discover url info. It may need to block until the request is fulfilled.
Note over User,UserAgent: User interaction
UserAgent-->>Server: Interaction complete
- Server-->>Client: notifications/elicitation/complete (optional)
Note over Server: Continue processing with new information
-```
-
-### URL Mode With Elicitation Required Error Flow
-
-```mermaid
-sequenceDiagram
- participant UserAgent as User Agent (Browser)
- participant User
- participant Client
- participant Server
-
- Client->>Server: tools/call
-
- Note over Server: Server needs authorization
- Server->>Client: URLElicitationRequiredError
- Note over Client: Client notes the original request can be retried after elicitation
-
- Client->>User: Present consent to open URL
- User-->>Client: Provide consent
-
- Client->>UserAgent: Open URL
-
- Note over User,UserAgent: User interaction
-
- UserAgent-->>Server: Interaction complete
- Server-->>Client: notifications/elicitation/complete (optional)
-
- Client->>Server: Retry tools/call (optional)
+ Server-->Client: Result(id: 2, result)
```
## Response Actions
@@ -533,14 +437,10 @@ Elicitation responses use a three-action model to clearly distinguish between di
```json
{
- "jsonrpc": "2.0",
- "id": 1,
- "result": {
- "action": "accept", // or "decline" or "cancel"
- "content": {
- "propertyName": "value",
- "anotherProperty": 42
- }
+ "action": "accept", // or "decline" or "cancel"
+ "content": {
+ "propertyName": "value",
+ "anotherProperty": 42
}
}
```
@@ -570,14 +470,10 @@ Servers should handle each state appropriately:
### Statefulness
-Most practical uses of elicitation require that the server maintain state about users:
-
-- Whether required information has been collected (e.g., the user's display name via form mode elicitation)
-- Status of resource access (e.g., API keys or a payment flow via URL mode elicitation)
+Elicitations do not require that the server maintain state about users with the [multi round-trip requests](/specification/2026-07-28/basic/patterns/mrtr#multi-round-trip-requests) mechanism.
-Servers implementing elicitation **MUST** securely associate this state with individual users following the guidelines in the [security best practices](../basic/security_best_practices) document. Specifically:
+However, if state is stored, servers implementing elicitation **MUST** securely associate this state with individual users following the guidelines in the [security best practices](/docs/2026-07-28/tutorials/security/security_best_practices) document. Specifically:
-- State **MUST NOT** be associated with session IDs alone
- State storage **MUST** be protected against unauthorized access
- For remote MCP servers, user identification **MUST** be derived from credentials acquired via [MCP authorization](../basic/authorization) when possible (e.g. `sub` claim)
@@ -625,7 +521,7 @@ Example scenario:
The critical security requirements are:
1. **The third-party credentials MUST NOT transit through the MCP client**: The client must never see third-party credentials to protect the security boundary
-2. **The MCP server MUST NOT use the client's credentials for the third-party service**: That would be [token passthrough](../basic/security_best_practices#token-passthrough), which is forbidden
+2. **The MCP server MUST NOT use the client's credentials for the third-party service**: That would be [token passthrough](/docs/2026-07-28/tutorials/security/security_best_practices#token-passthrough), which is forbidden
3. **The user MUST authorize the MCP server directly**: The interaction happens outside the MCP protocol, without involving the MCP client
4. **The MCP server is responsible for tokens**: The MCP server is responsible for storing and managing the third-party tokens obtained through the URL mode elicitation (in other words, the MCP server must be stateful).
@@ -633,9 +529,9 @@ Credentials obtained via URL mode elicitation are distinct from the MCP server c
For additional background, refer to the [token passthrough
- section](../basic/security_best_practices#token-passthrough) of the Security
- Best Practices document to understand why MCP servers cannot act as
- pass-through proxies.
+ section](/docs/2026-07-28/tutorials/security/security_best_practices#token-passthrough)
+ of the Security Best Practices document to understand why MCP servers cannot
+ act as pass-through proxies.
#### Implementation Pattern
@@ -644,7 +540,7 @@ When implementing external authorization via URL mode elicitation:
1. The MCP server generates an authorization URL, acting as an OAuth client to the third-party service
2. The MCP server stores internal state that associates (binds) the elicitation request with the user's identity.
-3. The MCP server sends a URL mode elicitation request to the client with a URL that can start the authorization flow.
+3. The MCP server sends a URL mode elicitation request to the client with a URL that can start the authorization flow and an optional `requestState` that encodes information about the elicitation request and user (if needed).
4. The user completes the OAuth flow directly with the third-party authorization server
5. The third-party authorization server redirects back to the MCP server
6. The MCP server securely stores the third-party tokens, bound to the user's identity
@@ -664,13 +560,15 @@ sequenceDiagram
Client->>Server: tools/call
Note over Server: Needs 3rd-party authorization for user
Note over Server: Store state (bind the elicitation request to the user)
- Server->>Client: URLElicitationRequiredError (mode: "url", url: "https://mcp.example.com/connect?...")
- Note over Client: Client notes the tools/call request can be retried later
+ Note over Server: generate requestState that encodes information about the original request and user.
+ Server->>Client: InputRequiredResult (mode: "url", url: "https://mcp.example.com/connect?...", requestState)
+
Client->>User: Present consent to open URL
User->>Client: Provide consent
Client->>UserAgent: Open URL
Client->>Server: Accept response
UserAgent->>Server: Load connect route
+
Note over Server: Confirm: user is logged into MCP Server or MCP AS Confirm: elicitation user matches session user
Server->>UserAgent: Redirect to third-party authorization endpoint
UserAgent->>3AS: Load authorize route
@@ -681,8 +579,7 @@ sequenceDiagram
Server->>3AS: Exchange authorization code for OAuth tokens
3AS->>Server: Grants tokens
Note over Server: Bind tokens to MCP user identity
- Server-->>Client: notifications/elicitation/complete (optional)
- Client->>Server: Retry tools/call
+ Client->>Server: tools/call (ElicitResults, requestState)
Note over Server: Retrieve token bound to user identity
Server->>3RS: Call 3rd-party API
```
@@ -691,13 +588,7 @@ This pattern maintains clear security boundaries while enabling rich integration
## Error Handling
-Servers **MUST** return standard JSON-RPC errors for common failure cases:
-
-- When a request cannot be processed until an elicitation is completed: `-32042` (`URLElicitationRequiredError`)
-
-Clients **MUST** return standard JSON-RPC errors for common failure cases:
-
-- Server sends an `elicitation/create` request with a mode not declared in client capabilities: `-32602` (Invalid params)
+Servers **SHOULD NOT** assume that elicitation requests will always succeed, and **MUST** handle cases where the user declines or cancels the elicitation, or where the client fails to process the request.
## Security Considerations
@@ -705,14 +596,13 @@ Clients **MUST** return standard JSON-RPC errors for common failure cases:
1. Clients **MUST** provide clear indication of which server is requesting information
1. Clients **SHOULD** implement user approval controls
1. Clients **SHOULD** allow users to decline elicitation requests at any time
-1. Clients **SHOULD** implement rate limiting
1. Clients **SHOULD** present elicitation requests in a way that makes it clear what information is being requested and why
### Safe URL Handling
MCP servers requesting elicitation:
-1. **MUST NOT** include sensitive information about the end-user, including credentials, personal identifiable information, etc., in the URL sent to the client in a URL elicitation request.
+1. **MUST NOT** include sensitive information about the end-user, including credentials, personally identifiable information, etc., in the URL sent to the client in a URL elicitation request.
2. **MUST NOT** provide a URL which is pre-authenticated to access a protected resource, as the URL could be used to impersonate the user by a malicious client.
3. **SHOULD NOT** include URLs intended to be clickable in any field of a form mode elicitation request.
4. **SHOULD** use HTTPS URLs for non-development environments.
@@ -735,7 +625,7 @@ When handling URL mode elicitation requests, MCP clients:
### Identifying the User
Servers **MUST NOT** rely on client-provided user identification without server verification, as this can be forged.
-Instead, servers **SHOULD** follow [security best practices](../basic/security_best_practices).
+Instead, servers **SHOULD** follow [security best practices](/docs/2026-07-28/tutorials/security/security_best_practices).
Non-normative examples:
@@ -761,7 +651,7 @@ For example, URL mode elicitation may be used to perform OAuth flows where the s
3. Alice's client displays the URL and asks for consent
4. Instead of clicking on the link, Alice tricks a victim user (Bob) of the same benign server into clicking it
5. Bob opens the link and completes the authorization, thinking they are authorizing their own connection to the benign server
-6. The benign server receives a callback/redirect form the third-party authorization server, and assumes it's Alice's request
+6. The benign server receives a callback/redirect from the third-party authorization server, and assumes it's Alice's request
7. The tokens for the third-party server are bound to Alice's session and identity, instead of Bob's, resulting in an account takeover
To prevent this attack, the server **MUST** ensure that the user who started the elicitation request (the end-user who is accessing the server via the MCP client) is the same user who completes the authorization flow.
@@ -769,13 +659,13 @@ To prevent this attack, the server **MUST** ensure that the user who started the
There are many ways to achieve this and the best way will depend on the specific implementation.
As a common, non-normative example, consider a case where the MCP server is accessible via the web and desires to perform a third-party authorization code flow.
-To prevent the phishing attack, the server would create a URL mode elicitation to `https://mcp.example.com/connect?elicitationId=...` rather than the third-party authorization endpoint.
-This "connect URL" must ensure the user who opened the page is the same user who the elicitation was generated for.
+To prevent the phishing attack, the server would create a URL mode elicitation to `https://mcp.example.com/connect?...` rather than the third-party authorization endpoint.
+This "connect URL" must ensure the user who opened the page is the same user for whom the elicitation was generated.
It would, for example, check that the user has a valid session cookie and that the session cookie is for the same user who was using the MCP client to generate the URL mode elicitation.
This could be done by comparing the authoritative subject (`sub` claim) from the MCP server's authorization server to the subject from the session cookie.
Once that page ensures the same user, it can send the user to the third-party authorization server at `https://example.com/authorize?...` where a normal OAuth flow can be completed.
In other cases, the server may not be accessible via the web and may not be able to use a session cookie to identify the user.
-In this case, the server must use a different mechanism to identify the user who opens the elicitation URL is the same user who the elicitation was generated for.
+In this case, the server must use a different mechanism to identify that the user who opens the elicitation URL is the same user for whom the elicitation was generated.
In all implementations, the server **MUST** ensure that the mechanism to determine the user's identity is resilient to attacks where an attacker can modify the elicitation URL.
diff --git a/spec/lifecycle.md b/spec/lifecycle.md
deleted file mode 100644
index 027b9edd..00000000
--- a/spec/lifecycle.md
+++ /dev/null
@@ -1,286 +0,0 @@
----
-title: Lifecycle
----
-
-
-
-The Model Context Protocol (MCP) defines a rigorous lifecycle for client-server
-connections that ensures proper capability negotiation and state management.
-
-1. **Initialization**: Capability negotiation and protocol version agreement
-2. **Operation**: Normal protocol communication
-3. **Shutdown**: Graceful termination of the connection
-
-```mermaid
-sequenceDiagram
- participant Client
- participant Server
-
- Note over Client,Server: Initialization Phase
- activate Client
- Client->>+Server: initialize request
- Server-->>Client: initialize response
- Client--)Server: initialized notification
-
- Note over Client,Server: Operation Phase
- rect rgb(200, 220, 250)
- note over Client,Server: Normal protocol operations
- end
-
- Note over Client,Server: Shutdown
- Client--)-Server: Disconnect
- deactivate Server
- Note over Client,Server: Connection closed
-```
-
-## Lifecycle Phases
-
-### Initialization
-
-The initialization phase **MUST** be the first interaction between client and server.
-During this phase, the client and server:
-
-- Establish protocol version compatibility
-- Exchange and negotiate capabilities
-- Share implementation details
-
-The client **MUST** initiate this phase by sending an `initialize` request containing:
-
-- Protocol version supported
-- Client capabilities
-- Client implementation information
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1,
- "method": "initialize",
- "params": {
- "protocolVersion": "2025-11-25",
- "capabilities": {
- "roots": {
- "listChanged": true
- },
- "sampling": {},
- "elicitation": {
- "form": {},
- "url": {}
- },
- "tasks": {
- "requests": {
- "elicitation": {
- "create": {}
- },
- "sampling": {
- "createMessage": {}
- }
- }
- }
- },
- "clientInfo": {
- "name": "ExampleClient",
- "title": "Example Client Display Name",
- "version": "1.0.0",
- "description": "An example MCP client application",
- "icons": [
- {
- "src": "https://example.com/icon.png",
- "mimeType": "image/png",
- "sizes": ["48x48"]
- }
- ],
- "websiteUrl": "https://example.com"
- }
- }
-}
-```
-
-The server **MUST** respond with its own capabilities and information:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1,
- "result": {
- "protocolVersion": "2025-11-25",
- "capabilities": {
- "logging": {},
- "prompts": {
- "listChanged": true
- },
- "resources": {
- "subscribe": true,
- "listChanged": true
- },
- "tools": {
- "listChanged": true
- },
- "tasks": {
- "list": {},
- "cancel": {},
- "requests": {
- "tools": {
- "call": {}
- }
- }
- }
- },
- "serverInfo": {
- "name": "ExampleServer",
- "title": "Example Server Display Name",
- "version": "1.0.0",
- "description": "An example MCP server providing tools and resources",
- "icons": [
- {
- "src": "https://example.com/server-icon.svg",
- "mimeType": "image/svg+xml",
- "sizes": ["any"]
- }
- ],
- "websiteUrl": "https://example.com/server"
- },
- "instructions": "Optional instructions for the client"
- }
-}
-```
-
-After successful initialization, the client **MUST** send an `initialized` notification
-to indicate it is ready to begin normal operations:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "notifications/initialized"
-}
-```
-
-- The client **SHOULD NOT** send requests other than
- [pings](/specification/2025-11-25/basic/utilities/ping) before the server has responded to the
- `initialize` request.
-- The server **SHOULD NOT** send requests other than
- [pings](/specification/2025-11-25/basic/utilities/ping) and
- [logging](/specification/2025-11-25/server/utilities/logging) before receiving the `initialized`
- notification.
-
-#### Version Negotiation
-
-In the `initialize` request, the client **MUST** send a protocol version it supports.
-This **SHOULD** be the _latest_ version supported by the client.
-
-If the server supports the requested protocol version, it **MUST** respond with the same
-version. Otherwise, the server **MUST** respond with another protocol version it
-supports. This **SHOULD** be the _latest_ version supported by the server.
-
-If the client does not support the version in the server's response, it **SHOULD**
-disconnect.
-
-
-If using HTTP, the client **MUST** include the `MCP-Protocol-Version:
-` HTTP header on all subsequent requests to the MCP
-server.
-For details, see [the Protocol Version Header section in Transports](/specification/2025-11-25/basic/transports#protocol-version-header).
-
-
-#### Capability Negotiation
-
-Client and server capabilities establish which optional protocol features will be
-available during the session.
-
-Key capabilities include:
-
-| Category | Capability | Description |
-| -------- | -------------- | --------------------------------------------------------------------------------------------- |
-| Client | `roots` | Ability to provide filesystem [roots](/specification/2025-11-25/client/roots) |
-| Client | `sampling` | Support for LLM [sampling](/specification/2025-11-25/client/sampling) requests |
-| Client | `elicitation` | Support for server [elicitation](/specification/2025-11-25/client/elicitation) requests |
-| Client | `tasks` | Support for [task-augmented](/specification/2025-11-25/basic/utilities/tasks) client requests |
-| Client | `experimental` | Describes support for non-standard experimental features |
-| Server | `prompts` | Offers [prompt templates](/specification/2025-11-25/server/prompts) |
-| Server | `resources` | Provides readable [resources](/specification/2025-11-25/server/resources) |
-| Server | `tools` | Exposes callable [tools](/specification/2025-11-25/server/tools) |
-| Server | `logging` | Emits structured [log messages](/specification/2025-11-25/server/utilities/logging) |
-| Server | `completions` | Supports argument [autocompletion](/specification/2025-11-25/server/utilities/completion) |
-| Server | `tasks` | Support for [task-augmented](/specification/2025-11-25/basic/utilities/tasks) server requests |
-| Server | `experimental` | Describes support for non-standard experimental features |
-
-Capability objects can describe sub-capabilities like:
-
-- `listChanged`: Support for list change notifications (for prompts, resources, and
- tools)
-- `subscribe`: Support for subscribing to individual items' changes (resources only)
-
-### Operation
-
-During the operation phase, the client and server exchange messages according to the
-negotiated capabilities.
-
-Both parties **MUST**:
-
-- Respect the negotiated protocol version
-- Only use capabilities that were successfully negotiated
-
-### Shutdown
-
-During the shutdown phase, one side (usually the client) cleanly terminates the protocol
-connection. No specific shutdown messages are defined—instead, the underlying transport
-mechanism should be used to signal connection termination:
-
-#### stdio
-
-For the stdio [transport](/specification/2025-11-25/basic/transports), the client **SHOULD** initiate
-shutdown by:
-
-1. First, closing the input stream to the child process (the server)
-2. Waiting for the server to exit, or sending `SIGTERM` if the server does not exit
- within a reasonable time
-3. Sending `SIGKILL` if the server does not exit within a reasonable time after `SIGTERM`
-
-The server **MAY** initiate shutdown by closing its output stream to the client and
-exiting.
-
-#### HTTP
-
-For HTTP [transports](/specification/2025-11-25/basic/transports), shutdown is indicated by closing the
-associated HTTP connection(s).
-
-## Timeouts
-
-Implementations **SHOULD** establish timeouts for all sent requests, to prevent hung
-connections and resource exhaustion. When the request has not received a success or error
-response within the timeout period, the sender **SHOULD** issue a [cancellation
-notification](/specification/2025-11-25/basic/utilities/cancellation) for that request and stop waiting for
-a response.
-
-SDKs and other middleware **SHOULD** allow these timeouts to be configured on a
-per-request basis.
-
-Implementations **MAY** choose to reset the timeout clock when receiving a [progress
-notification](/specification/2025-11-25/basic/utilities/progress) corresponding to the request, as this
-implies that work is actually happening. However, implementations **SHOULD** always
-enforce a maximum timeout, regardless of progress notifications, to limit the impact of a
-misbehaving client or server.
-
-## Error Handling
-
-Implementations **SHOULD** be prepared to handle these error cases:
-
-- Protocol version mismatch
-- Failure to negotiate required capabilities
-- Request [timeouts](#timeouts)
-
-Example initialization error:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1,
- "error": {
- "code": -32602,
- "message": "Unsupported protocol version",
- "data": {
- "supported": ["2024-11-05"],
- "requested": "1.0.0"
- }
- }
-}
-```
diff --git a/spec/mrtr.md b/spec/mrtr.md
new file mode 100644
index 00000000..a66d6670
--- /dev/null
+++ b/spec/mrtr.md
@@ -0,0 +1,272 @@
+---
+title: Multi Round-Trip Requests
+---
+
+
+
+
+ Multi Round-Trip Requests (MRTR) was introduced in this version of the MCP
+ specification. This replaces the previous approach of sending server-initiated
+ requests. Servers **MUST** send server-to-client requests (such as
+ `roots/list`, `sampling/createMessage`, or `elicitation/create`) using the
+ MRTR pattern. The previous pattern of server-initiated requests is no longer
+ supported. This is a breaking change.
+
+
+
+ For brevity, the request examples on this page omit the `_meta` request
+ metadata (`io.modelcontextprotocol/protocolVersion`,
+ `io.modelcontextprotocol/clientInfo`, and
+ `io.modelcontextprotocol/clientCapabilities`). Every request **MUST** include
+ the required `_meta` fields; see
+ [`_meta`](/specification/2026-07-28/basic/index#meta).
+
+
+## Multi Round-Trip Requests
+
+The Model Context Protocol (MCP) defines several ways for servers to request additional information
+from users during the processing of client requests (such as
+`roots/list`, `sampling/createMessage`, or `elicitation/create`). The **multi round-trip requests** pattern
+provides a standardized way to handle these server-requests without requiring a shared storage layer across
+server instances or requiring stateful load balancing.
+
+The high level flow functions as follows:
+
+1. Client sends an initial request to the server with the parameters needed to perform the operation.
+1. Server determines that additional information is required to fulfill the request and responds requesting more information.
+1. Client gathers the requested information from the user or other sources, then retries the original request including the additional requested information.
+1. Server determines it has sufficient information to complete the operation, and responds with the final result.
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant S as Server
+ C->>S: client request (id: 1, request params)
+ note over S: Server needs more info to process request.
+ S-->>C: Request for additional input.
+
+ note over C: Client gathers input and retries initial request.
+ C->>S: client request (id: 2, request params, requested input)
+ note over S: Server has enough information to complete the request.
+ S-->>C: Result (id: 2, result)
+```
+
+### Core Types
+
+This flow is implemented in MCP using the following Types.
+
+#### InputRequests
+
+An [`InputRequests`](/specification/2026-07-28/schema#inputrequests) object is a map of server-client requests.
+Keys are server-assigned string identifiers;
+values are request objects (e.g., [`ElicitRequest`](/specification/2026-07-28/schema#elicitrequest), [`CreateMessageRequest`](/specification/2026-07-28/schema#createmessagerequest), or [`ListRootsRequest`](/specification/2026-07-28/schema#listrootsrequest)).
+
+```json
+{
+ "github_login": {
+ "method": "elicitation/create",
+ "params": {
+ "mode": "form",
+ "message": "Please provide your GitHub username",
+ "requestedSchema": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" }
+ },
+ "required": ["name"]
+ }
+ }
+ },
+ "capital_of_france": {
+ "method": "sampling/createMessage",
+ "params": {
+ "messages": [
+ {
+ "role": "user",
+ "content": {
+ "type": "text",
+ "text": "What is the capital of France?"
+ }
+ }
+ ],
+ "systemPrompt": "You are a helpful assistant.",
+ "maxTokens": 100
+ }
+ }
+}
+```
+
+#### InputResponses
+
+An [`InputResponses`](/specification/2026-07-28/schema#inputresponses) object is a map of client responses to the server requests.
+Keys correspond to the keys in the `InputRequests` map; values are the client's result for each request (e.g., [`ElicitResult`](/specification/2026-07-28/schema#elicitresult), [`CreateMessageResult`](/specification/2026-07-28/schema#createmessageresult), or [`ListRootsResult`](/specification/2026-07-28/schema#listrootsresult)).
+
+```json
+{
+ "github_login": {
+ "action": "accept",
+ "content": {
+ "name": "octocat"
+ }
+ },
+ "capital_of_france": {
+ "role": "assistant",
+ "content": {
+ "type": "text",
+ "text": "The capital of France is Paris."
+ },
+ "model": "claude-3-sonnet-20240307",
+ "stopReason": "endTurn"
+ }
+}
+```
+
+#### InputRequiredResult
+
+An [`InputRequiredResult`](/specification/2026-07-28/schema#inputrequiredresult) is a type of [`Result`](/specification/2026-07-28/basic#responses),
+indicating that additional input is needed before the request can be completed.
+
+- `inputRequests` _(optional)_: An [`InputRequests`](/specification/2026-07-28/schema#inputrequests) map of server-initiated requests that the client must fulfill.
+- `requestState` _(optional)_: An opaque string meaningful only to the server. Clients **MUST NOT** inspect, parse, modify, or make any assumptions about its contents.
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "resultType": "input_required",
+ "inputRequests": {
+ // Elicitation request.
+ "github_login": {
+ "method": "elicitation/create",
+ "params": {
+ "mode": "form",
+ "message": "Please provide your GitHub username",
+ "requestedSchema": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" }
+ },
+ "required": ["name"]
+ }
+ }
+ },
+ // Sampling request.
+ "capital_of_france": {
+ "method": "sampling/createMessage",
+ "params": {
+ "messages": [
+ {
+ "role": "user",
+ "content": {
+ "type": "text",
+ "text": "What is the capital of France?"
+ }
+ }
+ ],
+ "modelPreferences": {
+ "hints": [{ "name": "claude-3-sonnet" }],
+ "intelligencePriority": 0.8,
+ "speedPriority": 0.5
+ },
+ "systemPrompt": "You are a helpful assistant.",
+ "maxTokens": 100
+ }
+ }
+ },
+ "requestState": "AEAD-protected blob"
+ }
+}
+```
+
+### Supported Requests
+
+Servers **MAY** send `InputRequiredResult` responses on the following client requests:
+
+| Client Request | Supports InputRequiredResult |
+| -------------------------------------------------------------------------------- | ---------------------------- |
+| [`prompts/get`](/specification/2026-07-28/server/prompts#getting-a-prompt) | Yes |
+| [`resources/read`](/specification/2026-07-28/server/resources#reading-resources) | Yes |
+| [`tools/call`](/specification/2026-07-28/server/tools#calling-tools) | Yes |
+
+Servers **MUST NOT** send `InputRequiredResult` responses on any other client requests.
+
+### Basic Workflow
+
+The basic workflow describes how a server can request additional input from the client as part of a client-server request.
+In this example we use `tools/call` as the client request, but the same pattern applies to any of the supported requests listed above.
+
+Notably, it allows servers to request additional information without maintaining any server-side state.
+The server encodes any needed context into the `requestState` field, which the client echoes back on retry.
+
+```mermaid
+sequenceDiagram
+ participant U as User
+ participant C as Client
+ participant S as Server
+ C->>S: tools/call (id: 1)
+ note over S: Server needs more info via Elicitation
+ S-->>C: InputRequiredResult (id: 1, ElicitRequest, requestState)
+ note over C,S: Initial Request Terminated
+
+ C->>U: Prompts user for input
+ U-->>C: Provides responses
+
+ note over C: Client retries tool call with inputResponses and requestState
+ C->>S: tools/call (id: 2, ElicitResult, requestState)
+ note over S: Server reconstitutes state Completes execution
+ S-->>C: Result (id: 2, ToolCallResult)
+```
+
+Note that the requests in each step are completely independent: the server processing the retry does not need any information beyond
+what is directly present in the retry request.
+
+#### Server Requirements (Basic Workflow)
+
+1. Servers **MAY** respond to any [supported client request](#supported-requests) with an `InputRequiredResult`.
+1. The `InputRequiredResult` **MAY** include an `inputRequests` field.
+ - `inputRequests` keys are server assigned identifiers and **MUST** be unique within the scope of the request.
+ - `inputRequests` values are request objects that **MUST** be one of [`ElicitRequest`](/specification/2026-07-28/schema#elicitrequest), [`CreateMessageRequest`](/specification/2026-07-28/schema#createmessagerequest), or [`ListRootsRequest`](/specification/2026-07-28/schema#listrootsrequest)
+
+1. The `InputRequiredResult` **MAY** include a `requestState` field. If specified, this field is an opaque string meaningful only to the server. Servers are free to encode the state in any format (e.g. base64-encoded JSON, encrypted JWT, serialized binary).
+1. If a client request contains a `requestState` field, servers **MUST** treat `requestState` as an attacker-controlled input. If `requestState` influences authorization, resource access, or business logic, servers **MUST** protect its integrity (e.g. HMAC or AEAD)
+ and **MUST** reject state that fails verification. Integrity protection **MAY** be omitted only when tampering can cause nothing worse than request failure.
+1. To prevent replay, servers **SHOULD** include the following inside the integrity-protected `requestState` payload and verify each on receipt:
+ - the authenticated principal, rejecting state presented by a different principal.
+ - a short expiry (TTL), rejecting state presented after it lapses;
+ - an identifier for the originating request, e.g. the method name and a digest of its salient parameters, rejecting state presented on a request that does not match.
+
+ Note that these measures bound the replay window and prevent cross-user
+ and cross-request reuse, but do not by themselves guarantee single-use.
+ Servers for which a given `requestState` must be consumed at most once
+ (e.g., one-time redemptions) **MUST** enforce that invariant server-side.
+
+
+1. Servers **MUST** include at least one of `inputRequests` or `requestState` in every `InputRequiredResult` response.
+1. Servers **MUST NOT** send an `inputRequests` that the client has not declared support for in its capabilities. For example, if a client does not declare support for `elicitation`, the server **MUST NOT** include any `elicitation/create` requests in the `inputRequests` field.
+1. Servers **MUST NOT** assume that clients will fulfill the `inputRequests` or retry the original request. Servers **MAY** choose to return an `InputRequiredResult` on multiple attempts at the same request if they want to repeatedly prompt the user for information until they have what they need to complete the request.
+
+#### Client Requirements (Basic Workflow)
+
+1. If a client receives an `InputRequiredResult` that contains the `inputRequests` field, the client **MUST** construct the requested
+ inputs before retrying the original request. If the `InputRequiredResult` does _not_ contain the `inputRequests` field,
+ the client **MAY** retry the original request immediately.
+1. If an `InputRequiredResult` contains the `requestState` field, the client **MUST** echo back the exact value of that field when retrying the original request.
+ Clients **MUST NOT** inspect, parse, modify, or make any assumptions about the `requestState` contents. If the `InputRequiredResult` does not contain a `requestState` field, the client **MUST NOT** include one in the retry.
+1. The JSON-RPC `id` **MUST** be different between the initial request and the retry, as they are independent requests.
+1. Both the `inputRequests` and `requestState` fields affect only the client's retry of the original request. They **MUST NOT** be used for any other request that the client may be sending in parallel.
+
+### Error Handling
+
+Servers **SHOULD** validate that the data provided by the client is a valid `InputResponses` object and that the information inside can be correctly parsed.
+Protocol errors (malformed JSON, invalid schema, internal server errors) **SHOULD** return a JSON-RPC error response with an appropriate error code and message.
+
+If additional, unexpected parameters are provided in the `InputResponses` object, the server **SHOULD** ignore any information it does not recognize or need.
+
+If the client fails to send all the information requested in a previous `InputRequests`, and the missing information is necessary for the server to process the request,
+the server **SHOULD** respond with a new `InputRequiredResult` requesting the missing information again, rather than returning an error.
+
+### Security Considerations
+
+Because `requestState` passes through the client, malicious or compromised clients could attempt to modify it to alter server behavior,
+bypass authorization checks, or corrupt server logic. Servers **MUST** validate request state as described in the [server requirements](#server-requirements-basic-workflow) above.
diff --git a/spec/ping.md b/spec/ping.md
deleted file mode 100644
index a5e6981d..00000000
--- a/spec/ping.md
+++ /dev/null
@@ -1,66 +0,0 @@
----
-title: Ping
----
-
-
-
-The Model Context Protocol includes an optional ping mechanism that allows either party
-to verify that their counterpart is still responsive and the connection is alive.
-
-## Overview
-
-The ping functionality is implemented through a simple request/response pattern. Either
-the client or server can initiate a ping by sending a `ping` request.
-
-## Message Format
-
-A ping request is a standard JSON-RPC request with no parameters:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": "123",
- "method": "ping"
-}
-```
-
-## Behavior Requirements
-
-1. The receiver **MUST** respond promptly with an empty response:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": "123",
- "result": {}
-}
-```
-
-2. If no response is received within a reasonable timeout period, the sender **MAY**:
- - Consider the connection stale
- - Terminate the connection
- - Attempt reconnection procedures
-
-## Usage Patterns
-
-```mermaid
-sequenceDiagram
- participant Sender
- participant Receiver
-
- Sender->>Receiver: ping request
- Receiver->>Sender: empty response
-```
-
-## Implementation Considerations
-
-- Implementations **SHOULD** periodically issue pings to detect connection health
-- The frequency of pings **SHOULD** be configurable
-- Timeouts **SHOULD** be appropriate for the network environment
-- Excessive pinging **SHOULD** be avoided to reduce network overhead
-
-## Error Handling
-
-- Timeouts **SHOULD** be treated as connection failures
-- Multiple failed pings **MAY** trigger connection reset
-- Implementations **SHOULD** log ping failures for diagnostics
diff --git a/spec/progress.md b/spec/progress.md
index cb5f887f..f6dc5c04 100644
--- a/spec/progress.md
+++ b/spec/progress.md
@@ -5,16 +5,16 @@ title: Progress
The Model Context Protocol (MCP) supports optional progress tracking for long-running
-operations through notification messages. Either side can send progress notifications to
-provide updates about operation status.
+operations through notification messages. The server **MAY** send progress notifications
+to report the status of requests the client has issued.
## Progress Flow
-When a party wants to _receive_ progress updates for a request, it includes a
+When a client wants to _receive_ progress updates for a request, it includes a
`progressToken` in the request metadata.
- Progress tokens **MUST** be a string or integer value
-- Progress tokens can be chosen by the sender using any means, but **MUST** be unique
+- Progress tokens can be chosen by the client using any means, but **MUST** be unique
across all active requests.
```json
@@ -30,7 +30,7 @@ When a party wants to _receive_ progress updates for a request, it includes a
}
```
-The receiver **MAY** then send progress notifications containing:
+The server **MAY** then send progress notifications containing:
- The original progress token
- The current progress value so far
@@ -61,34 +61,30 @@ The receiver **MAY** then send progress notifications containing:
- Were provided in an active request
- Are associated with an in-progress operation
-2. Receivers of progress requests **MAY**:
+2. Servers receiving a request with a progress token **MAY**:
- Choose not to send any progress notifications
- Send notifications at whatever frequency they deem appropriate
- Omit the total value if unknown
-3. For [task-augmented requests](./tasks), the `progressToken` provided in the original request **MUST** continue to be used for progress notifications throughout the task's lifetime, even after the `CreateTaskResult` has been returned. The progress token remains valid and associated with the task until the task reaches a terminal status.
- - Progress notifications for tasks **MUST** use the same `progressToken` that was provided in the initial task-augmented request
- - Progress notifications for tasks **MUST** stop after the task reaches a terminal status (`completed`, `failed`, or `cancelled`)
-
```mermaid
sequenceDiagram
- participant Sender
- participant Receiver
+ participant Client
+ participant Server
- Note over Sender,Receiver: Request with progress token
- Sender->>Receiver: Method request with progressToken
+ Note over Client,Server: Request with progress token
+ Client->>Server: Method request with progressToken
- Note over Sender,Receiver: Progress updates
- Receiver-->>Sender: Progress notification (0.2/1.0)
- Receiver-->>Sender: Progress notification (0.6/1.0)
- Receiver-->>Sender: Progress notification (1.0/1.0)
+ Note over Client,Server: Progress updates
+ Server-->>Client: Progress notification (0.2/1.0)
+ Server-->>Client: Progress notification (0.6/1.0)
+ Server-->>Client: Progress notification (1.0/1.0)
- Note over Sender,Receiver: Operation complete
- Receiver->>Sender: Method response
+ Note over Client,Server: Operation complete
+ Server->>Client: Method response
```
## Implementation Notes
-- Senders and receivers **SHOULD** track active progress tokens
+- Clients and servers **SHOULD** track active progress tokens
- Both parties **SHOULD** implement rate limiting to prevent flooding
- Progress notifications **MUST** stop after completion
diff --git a/spec/prompts.md b/spec/prompts.md
new file mode 100644
index 00000000..5a6574be
--- /dev/null
+++ b/spec/prompts.md
@@ -0,0 +1,336 @@
+---
+title: Prompts
+---
+
+
+
+The Model Context Protocol (MCP) provides a standardized way for servers to expose prompt
+templates to clients. Prompts allow servers to provide structured messages and
+instructions for interacting with language models. Clients can discover available
+prompts, retrieve their contents, and provide arguments to customize them.
+
+
+ For brevity, the request examples on this page omit the `_meta` request
+ metadata (`io.modelcontextprotocol/protocolVersion`,
+ `io.modelcontextprotocol/clientInfo`, and
+ `io.modelcontextprotocol/clientCapabilities`). Every request **MUST** include
+ the required `_meta` fields; see
+ [`_meta`](/specification/2026-07-28/basic/index#meta).
+
+
+## User Interaction Model
+
+Prompts are designed to be **user-controlled**, meaning they are exposed from servers to
+clients with the intention of the user being able to explicitly select them for use.
+This refers to who decides when the prompt is used, not who authors its content. Prompt
+content is defined by the server.
+
+Typically, prompts would be triggered through user-initiated commands in the user
+interface, which allows users to naturally discover and invoke available prompts.
+
+For example, as slash commands:
+
+
+
+However, implementors are free to expose prompts through any interface pattern that suits
+their needs—the protocol itself does not mandate any specific user interaction
+model.
+
+## Capabilities
+
+Servers that support prompts **MUST** declare the `prompts` capability in their
+[`DiscoverResult`](/specification/2026-07-28/schema#discoverresult):
+
+```json
+{
+ "capabilities": {
+ "prompts": {
+ "listChanged": true
+ }
+ }
+}
+```
+
+`listChanged` indicates whether the server will emit notifications when the list of
+available prompts changes.
+
+Servers that declare the `prompts` capability **MUST** respond to `prompts/list` requests
+with the set of prompts currently available to the requesting client. This set **MAY** be
+empty and **MAY** change over time (see
+[List Changed Notification](#list-changed-notification)), but **MUST NOT** vary
+per-connection or as a side effect of other requests on the connection. The set
+**MAY** vary by the authorization presented on the request — for example, returning
+only the prompts the caller's granted scopes permit — since credentials are
+per-request input, not connection state.
+
+## Protocol Messages
+
+### Listing Prompts
+
+To retrieve available prompts, clients send a `prompts/list` request. This operation
+supports [pagination](/specification/2026-07-28/server/utilities/pagination) and [caching](/specification/2026-07-28/server/utilities/caching).
+
+**Request:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "prompts/list",
+ "params": {
+ "cursor": "optional-cursor-value"
+ }
+}
+```
+
+**Response:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "resultType": "complete",
+ "prompts": [
+ {
+ "name": "code_review",
+ "title": "Request Code Review",
+ "description": "Asks the LLM to analyze code quality and suggest improvements",
+ "arguments": [
+ {
+ "name": "code",
+ "description": "The code to review",
+ "required": true
+ }
+ ],
+ "icons": [
+ {
+ "src": "https://example.com/review-icon.svg",
+ "mimeType": "image/svg+xml",
+ "sizes": ["any"]
+ }
+ ]
+ }
+ ],
+ "nextCursor": "next-page-cursor",
+ "ttlMs": 600000,
+ "cacheScope": "public"
+ }
+}
+```
+
+### Getting a Prompt
+
+To retrieve a specific prompt, clients send a `prompts/get` request. Arguments may be
+auto-completed through [the completion API](/specification/2026-07-28/server/utilities/completion).
+
+**Request:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "prompts/get",
+ "params": {
+ "name": "code_review",
+ "arguments": {
+ "code": "def hello():\n print('world')"
+ }
+ }
+}
+```
+
+**Response:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "result": {
+ "resultType": "complete",
+ "description": "Code review prompt",
+ "messages": [
+ {
+ "role": "user",
+ "content": {
+ "type": "text",
+ "text": "Please review this Python code:\ndef hello():\n print('world')"
+ }
+ }
+ ]
+ }
+}
+```
+
+Servers **MAY** also respond to `prompts/get` with an [`InputRequiredResult`](/specification/2026-07-28/basic/patterns/mrtr#inputrequiredresult) to indicate that additional input is needed before the prompt can be resolved. This follows the [multi round-trip requests](/specification/2026-07-28/basic/patterns/mrtr#multi-round-trip-requests) mechanism. When retrying the request, clients include `inputResponses` and, if provided by the server, `requestState` in the request parameters.
+
+### List Changed Notification
+
+When the list of available prompts changes, servers that declared the `listChanged`
+capability **SHOULD** send a notification to clients that have opened a
+[`subscriptions/listen`](/specification/2026-07-28/basic/patterns/subscriptions) stream with
+`promptsListChanged: true`:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "notifications/prompts/list_changed"
+}
+```
+
+## Message Flow
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Server
+
+ Note over Client,Server: Discovery
+ Client->>Server: prompts/list
+ Server-->>Client: List of prompts
+
+ Note over Client,Server: Usage
+ Client->>Server: prompts/get
+ Server-->>Client: Prompt content
+
+ opt listChanged
+ Client->>Server: subscriptions/listen (promptsListChanged: true)
+ Server--)Client: notifications/subscriptions/acknowledged
+ Note over Client,Server: Changes
+ Server--)Client: notifications/prompts/list_changed
+ Client->>Server: prompts/list
+ Server-->>Client: Updated prompts
+ end
+```
+
+## Data Types
+
+### Prompt
+
+A prompt definition includes:
+
+- `name`: Unique identifier for the prompt
+- `title`: Optional human-readable name of the prompt for display purposes.
+- `description`: Optional human-readable description
+- `icons`: Optional array of icons for display in user interfaces
+- `arguments`: Optional list of arguments for customization
+
+### PromptMessage
+
+Messages in a prompt can contain:
+
+- `role`: Either "user" or "assistant" to indicate the speaker
+- `content`: One of the following content types:
+
+
+ All content types in prompt messages support optional
+ [annotations](/specification/2026-07-28/server/resources#annotations) for
+ metadata about audience, priority, and modification times.
+
+
+#### Text Content
+
+Text content represents plain text messages:
+
+```json
+{
+ "type": "text",
+ "text": "The text content of the message"
+}
+```
+
+This is the most common content type used for natural language interactions.
+
+#### Image Content
+
+Image content allows including visual information in messages:
+
+```json
+{
+ "type": "image",
+ "data": "base64-encoded-image-data",
+ "mimeType": "image/png"
+}
+```
+
+The image data **MUST** be base64-encoded and include a valid MIME type. This enables
+multi-modal interactions where visual context is important.
+
+#### Audio Content
+
+Audio content allows including audio information in messages:
+
+```json
+{
+ "type": "audio",
+ "data": "base64-encoded-audio-data",
+ "mimeType": "audio/wav"
+}
+```
+
+The audio data MUST be base64-encoded and include a valid MIME type. This enables
+multi-modal interactions where audio context is important.
+
+#### Resource Links
+
+Prompt messages **MAY** include links to
+[Resources](/specification/2026-07-28/server/resources), to provide additional context or
+data without embedding the resource contents directly. In this case, the prompt message
+returns a URI that can be fetched by the client:
+
+```json
+{
+ "type": "resource_link",
+ "uri": "file:///project/src/main.rs",
+ "name": "main.rs",
+ "description": "Primary application entry point",
+ "mimeType": "text/x-rust"
+}
+```
+
+Resource links support the same [Resource annotations](/specification/2026-07-28/server/resources#annotations)
+as regular resources to help clients understand how to use them.
+
+#### Embedded Resources
+
+Embedded resources allow referencing server-side resources directly in messages:
+
+```json
+{
+ "type": "resource",
+ "resource": {
+ "uri": "resource://example",
+ "mimeType": "text/plain",
+ "text": "Resource content"
+ }
+}
+```
+
+Resources can contain either text or binary (blob) data and **MUST** include:
+
+- A valid resource URI
+- The appropriate MIME type
+- Either text content or base64-encoded blob data
+
+Embedded resources enable prompts to seamlessly incorporate server-managed content like
+documentation, code samples, or other reference materials directly into the conversation
+flow.
+
+## Error Handling
+
+Servers **SHOULD** return standard JSON-RPC errors for common failure cases:
+
+- Invalid prompt name: `-32602` (Invalid params)
+- Missing required arguments: `-32602` (Invalid params)
+- Internal errors: `-32603` (Internal error)
+
+## Implementation Considerations
+
+1. Servers **SHOULD** validate prompt arguments before processing
+2. Clients **SHOULD** handle pagination for large prompt lists
+3. Both parties **SHOULD** respect capability negotiation
+
+## Security
+
+Implementations **MUST** carefully validate all prompt inputs and outputs to prevent
+injection attacks or unauthorized access to resources.
diff --git a/spec/resources.md b/spec/resources.md
new file mode 100644
index 00000000..f49dd8e6
--- /dev/null
+++ b/spec/resources.md
@@ -0,0 +1,435 @@
+---
+title: Resources
+---
+
+
+
+The Model Context Protocol (MCP) provides a standardized way for servers to expose
+resources to clients. Resources allow servers to share data that provides context to
+language models, such as files, database schemas, or application-specific information.
+Each resource is uniquely identified by a
+[URI](https://datatracker.ietf.org/doc/html/rfc3986).
+
+
+ For brevity, the request examples on this page omit the `_meta` request
+ metadata (`io.modelcontextprotocol/protocolVersion`,
+ `io.modelcontextprotocol/clientInfo`, and
+ `io.modelcontextprotocol/clientCapabilities`). Every request **MUST** include
+ the required `_meta` fields; see
+ [`_meta`](/specification/2026-07-28/basic/index#meta).
+
+
+## User Interaction Model
+
+Resources in MCP are designed to be **application-driven**, with host applications
+determining how to incorporate context based on their needs.
+
+For example, applications could:
+
+- Expose resources through UI elements for explicit selection, in a tree or list view
+- Allow the user to search through and filter available resources
+- Implement automatic context inclusion, based on heuristics or the AI model's selection
+
+
+
+However, implementations are free to expose resources through any interface pattern that
+suits their needs—the protocol itself does not mandate any specific user
+interaction model.
+
+## Capabilities
+
+Servers that support resources **MUST** declare the `resources` capability:
+
+```json
+{
+ "capabilities": {
+ "resources": {
+ "listChanged": true,
+ "subscribe": true
+ }
+ }
+}
+```
+
+The capability supports two optional features:
+
+- `listChanged`: whether the server will emit notifications when the list of available
+ resources changes.
+- `subscribe` : whether the server supports resource-specific update notifications
+ for resources requested through subscriptions/listen using the resourceSubscriptions
+ filter.
+
+Servers may advertise either feature independently, together or neither.
+
+Serves that support neither `listChanged` or `subscribe` may omit it:
+
+```json
+{
+ "capabilities": {
+ "resources": {}
+ }
+}
+```
+
+Servers that declare the `resources` capability **MUST** respond to `resources/list`
+requests with the set of resources currently available to the requesting client. This set
+**MAY** be empty and **MAY** change over time (see
+[List Changed Notification](#list-changed-notification)), but **MUST NOT** vary
+per-connection or as a side effect of other requests on the connection. The set
+**MAY** vary by the authorization presented on the request — for example, returning
+only the resources the caller's granted scopes permit — since credentials are
+per-request input, not connection state.
+
+## Protocol Messages
+
+### Listing Resources
+
+To discover available resources, clients send a `resources/list` request. This operation
+supports [pagination](/specification/2026-07-28/server/utilities/pagination) and [caching](/specification/2026-07-28/server/utilities/caching).
+
+**Request:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "resources/list",
+ "params": {
+ "cursor": "optional-cursor-value"
+ }
+}
+```
+
+**Response:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "resultType": "complete",
+ "resources": [
+ {
+ "uri": "file:///project/src/main.rs",
+ "name": "main.rs",
+ "title": "Rust Software Application Main File",
+ "description": "Primary application entry point",
+ "mimeType": "text/x-rust",
+ "icons": [
+ {
+ "src": "https://example.com/rust-file-icon.png",
+ "mimeType": "image/png",
+ "sizes": ["48x48"]
+ }
+ ]
+ }
+ ],
+ "nextCursor": "next-page-cursor",
+ "ttlMs": 300000,
+ "cacheScope": "public"
+ }
+}
+```
+
+### Reading Resources
+
+To retrieve resource contents, clients send a `resources/read` request. This operation
+supports [caching](/specification/2026-07-28/server/utilities/caching).
+
+**Request:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "resources/read",
+ "params": {
+ "uri": "file:///project/src/main.rs"
+ }
+}
+```
+
+**Response:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "result": {
+ "resultType": "complete",
+ "contents": [
+ {
+ "uri": "file:///project/src/main.rs",
+ "mimeType": "text/x-rust",
+ "text": "fn main() {\n println!(\"Hello world!\");\n}"
+ }
+ ],
+ "ttlMs": 60000,
+ "cacheScope": "private"
+ }
+}
+```
+
+Servers **MAY** return multiple resource contents in response to a single
+`resources/read` request. For example, a server could return the contents of
+several files when a directory resource is read.
+
+Servers **MAY** also respond to `resources/read` with an [`InputRequiredResult`](/specification/2026-07-28/basic/patterns/mrtr#inputrequiredresult) to indicate that additional input is needed before the resource can be read. This follows the [multi round-trip requests](/specification/2026-07-28/basic/patterns/mrtr#multi-round-trip-requests) mechanism. When retrying the request, clients include `inputResponses` and, if provided by the server, `requestState` in the request parameters.
+
+Alternatively, if the scheme of `uri` is `https://`, clients may fetch the resource directly from the web. See the [Common URI Schemes section](#https%3A%2F%2F) for more information.
+
+### Resource Templates
+
+Resource templates allow servers to expose parameterized resources using
+[URI templates](https://datatracker.ietf.org/doc/html/rfc6570). Arguments may be
+auto-completed through [the completion API](/specification/2026-07-28/server/utilities/completion).
+This operation supports [pagination](/specification/2026-07-28/server/utilities/pagination) and [caching](/specification/2026-07-28/server/utilities/caching).
+
+**Request:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 3,
+ "method": "resources/templates/list",
+ "params": {
+ "cursor": "optional-cursor-value"
+ }
+}
+```
+
+**Response:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 3,
+ "result": {
+ "resultType": "complete",
+ "resourceTemplates": [
+ {
+ "uriTemplate": "file:///{path}",
+ "name": "Project Files",
+ "title": "📁 Project Files",
+ "description": "Access files in the project directory",
+ "mimeType": "application/octet-stream",
+ "icons": [
+ {
+ "src": "https://example.com/folder-icon.png",
+ "mimeType": "image/png",
+ "sizes": ["48x48"]
+ }
+ ]
+ }
+ ],
+ "nextCursor": "next-page-cursor",
+ "ttlMs": 300000,
+ "cacheScope": "public"
+ }
+}
+```
+
+### List Changed Notification
+
+When the list of available resources changes, servers that declared the `listChanged`
+capability **SHOULD** send a notification:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "notifications/resources/list_changed"
+}
+```
+
+### Subscriptions
+
+Clients subscribe to change notifications for specific resources by sending a
+[`subscriptions/listen`][subscriptions-listen] request with the resource URIs listed in
+`notifications.resourceSubscriptions`. The server delivers
+`notifications/resources/updated` on the resulting stream whenever a watched resource
+changes.
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "notifications/resources/updated",
+ "params": {
+ "_meta": { "io.modelcontextprotocol/subscriptionId": 4 },
+ "uri": "file:///project/src/main.rs"
+ }
+}
+```
+
+See [Subscriptions][subscriptions] for the full protocol mechanics (acknowledgment,
+`subscriptionId` correlation, and cancellation).
+
+[subscriptions-listen]: /specification/2026-07-28/schema#subscriptionslistenrequest
+[subscriptions]: /specification/2026-07-28/basic/patterns/subscriptions
+
+## Message Flow
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Server
+
+ Note over Client,Server: Resource Discovery
+ Client->>Server: resources/list
+ Server-->>Client: List of resources
+
+ Note over Client,Server: Resource Template Discovery
+ Client->>Server: resources/templates/list
+ Server-->>Client: List of resource templates
+
+ Note over Client,Server: Resource Access
+ Client->>Server: resources/read
+ Server-->>Client: Resource contents
+
+ Note over Client,Server: Subscribe to changes
+ Client->>Server: subscriptions/listen (resourceSubscriptions)
+ Server--)Client: notifications/subscriptions/acknowledged
+
+ Note over Client,Server: Resource updated
+ Server--)Client: notifications/resources/updated
+ Client->>Server: resources/read
+ Server-->>Client: Updated contents
+```
+
+## Data Types
+
+### Resource
+
+A resource definition includes:
+
+- `uri`: Unique identifier for the resource
+- `name`: The name of the resource.
+- `title`: Optional human-readable name of the resource for display purposes.
+- `description`: Optional description
+- `icons`: Optional array of icons for display in user interfaces
+- `mimeType`: Optional MIME type
+- `size`: Optional size in bytes
+
+### Resource Contents
+
+Resources can contain either text or binary data:
+
+#### Text Content
+
+```json
+{
+ "uri": "file:///example.txt",
+ "mimeType": "text/plain",
+ "text": "Resource content"
+}
+```
+
+#### Binary Content
+
+```json
+{
+ "uri": "file:///example.png",
+ "mimeType": "image/png",
+ "blob": "base64-encoded-data"
+}
+```
+
+### Annotations
+
+Resources, resource templates and content blocks support optional annotations that provide hints to clients about how to use or display the resource:
+
+- **`audience`**: An array indicating the intended audience(s) for this resource. Valid values are `"user"` and `"assistant"`. For example, `["user", "assistant"]` indicates content useful for both.
+- **`priority`**: A number from 0.0 to 1.0 indicating the importance of this resource. A value of 1 means "most important" (effectively required), while 0 means "least important" (entirely optional).
+- **`lastModified`**: An ISO 8601 formatted timestamp indicating when the resource was last modified (e.g., `"2025-01-12T15:00:58Z"`).
+
+Example resource with annotations:
+
+```json
+{
+ "uri": "file:///project/README.md",
+ "name": "README.md",
+ "title": "Project Documentation",
+ "mimeType": "text/markdown",
+ "annotations": {
+ "audience": ["user"],
+ "priority": 0.8,
+ "lastModified": "2025-01-12T15:00:58Z"
+ }
+}
+```
+
+Clients can use these annotations to:
+
+- Filter resources based on their intended audience
+- Prioritize which resources to include in context
+- Display modification times or sort by recency
+
+## Common URI Schemes
+
+The protocol defines several standard URI schemes. This list is not
+exhaustive—implementations are always free to use additional, custom URI schemes.
+
+### https://
+
+Used to represent a resource available on the web.
+
+Servers **SHOULD** use this scheme only when the client is able to fetch and load the
+resource directly from the web on its own—that is, it doesn’t need to read the resource
+via the MCP server.
+
+For other use cases, servers **SHOULD** prefer to use another URI scheme, or define a
+custom one, even if the server will itself be downloading resource contents over the
+internet.
+
+### file://
+
+Used to identify resources that behave like a filesystem. However, the resources do not
+need to map to an actual physical filesystem.
+
+MCP servers **MAY** identify file:// resources with an
+[XDG MIME type](https://specifications.freedesktop.org/shared-mime-info-spec/0.14/ar01s02.html#id-1.3.14),
+like `inode/directory`, to represent non-regular files (such as directories) that don’t
+otherwise have a standard MIME type.
+
+### git://
+
+Git version control integration.
+
+### Custom URI Schemes
+
+Custom URI schemes **MUST** be in accordance with [RFC3986](https://datatracker.ietf.org/doc/html/rfc3986),
+taking the above guidance in to account.
+
+## Error Handling
+
+If the requested resource does not exist, servers **MUST** return a JSON-RPC error with
+code `-32602` (Invalid Params). Servers **SHOULD** return `-32603` for internal errors.
+
+For backwards compatibility, clients **SHOULD** also accept `-32002` as a
+resource not found error, as earlier protocol versions used this code.
+
+Servers **MUST NOT** return an empty `contents` array for a non-existent resource. An empty array is ambiguous—it could mean the resource exists but has no content, or that it doesn't exist at all.
+
+Example error:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 5,
+ "error": {
+ "code": -32602,
+ "message": "Resource not found",
+ "data": {
+ "uri": "file:///nonexistent.txt"
+ }
+ }
+}
+```
+
+## Security Considerations
+
+1. Servers **MUST** validate all resource URIs
+2. Access controls **SHOULD** be implemented for sensitive resources
+3. Binary data **MUST** be properly encoded
+4. Resource permissions **SHOULD** be checked before operations
+5. Servers **MUST** sanitize file paths to prevent directory traversal attacks
+ when serving `file://` resources
diff --git a/spec/subscriptions.md b/spec/subscriptions.md
new file mode 100644
index 00000000..866898e1
--- /dev/null
+++ b/spec/subscriptions.md
@@ -0,0 +1,165 @@
+---
+title: Subscriptions
+---
+
+
+
+`subscriptions/listen` opens a long-lived notification stream from the server to the
+client. Unlike one-off requests, the stream stays open and delivers notifications until
+the client cancels it. It replaces the former `resources/subscribe` RPC and the HTTP GET
+endpoint.
+
+## Opening a Stream
+
+The client sends a `subscriptions/listen` request with a `notifications` filter
+specifying which event types it wants to receive. The server **MUST NOT** send
+notification types the client has not explicitly requested.
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "subscriptions/listen",
+ "params": {
+ "_meta": {
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
+ "io.modelcontextprotocol/clientInfo": {
+ "name": "ExampleClient",
+ "version": "1.0.0"
+ },
+ "io.modelcontextprotocol/clientCapabilities": {}
+ },
+ "notifications": {
+ "toolsListChanged": true,
+ "resourceSubscriptions": ["file:///project/config.json"]
+ }
+ }
+}
+```
+
+### Notification Filter
+
+| Field | Type | Description |
+| ----------------------- | ---------- | ----------------------------------------------------------------- |
+| `toolsListChanged` | `boolean` | Receive `notifications/tools/list_changed` when tools change |
+| `promptsListChanged` | `boolean` | Receive `notifications/prompts/list_changed` when prompts change |
+| `resourcesListChanged` | `boolean` | Receive `notifications/resources/list_changed` when list changes |
+| `resourceSubscriptions` | `string[]` | Receive `notifications/resources/updated` for these resource URIs |
+
+All fields are optional. Omitting a field is equivalent to not subscribing to that
+notification type.
+
+## Acknowledgment
+
+The server **MUST** send `notifications/subscriptions/acknowledged` as the first message
+carrying the subscription's ID in `_meta` under `io.modelcontextprotocol/subscriptionId`,
+and **MUST NOT** send any notification on the
+subscription before it. On stdio, where every subscription shares one channel, this
+ordering is defined per subscription ID and not per channel: messages belonging to other
+subscriptions **MAY** be interleaved before it.
+
+The `notifications` field in the acknowledgment reflects the subset the server agreed to
+honor. Notification types the server does not support are omitted.
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "notifications/subscriptions/acknowledged",
+ "params": {
+ "_meta": {
+ "io.modelcontextprotocol/subscriptionId": 1
+ },
+ "notifications": {
+ "toolsListChanged": true,
+ "resourceSubscriptions": ["file:///project/config.json"]
+ }
+ }
+}
+```
+
+The client **SHOULD** check the acknowledged filter against what it requested and handle
+any unsupported types gracefully.
+
+## Receiving Notifications
+
+All notifications delivered on the stream carry
+`io.modelcontextprotocol/subscriptionId` in `_meta`, identifying the
+`subscriptions/listen` request that opened the stream. The value is the JSON-RPC ID of
+the `subscriptions/listen` request. In the examples above, the request used `"id": 1`,
+so the acknowledgment and all subsequent notifications carry the subscription ID `1`.
+On stdio, where all messages
+share a single channel, clients **MUST** use this field to correlate notifications
+with their originating subscription.
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "notifications/resources/updated",
+ "params": {
+ "_meta": {
+ "io.modelcontextprotocol/subscriptionId": 1
+ },
+ "uri": "file:///project/config.json"
+ }
+}
+```
+
+## Multiple Concurrent Subscriptions
+
+A client **MAY** have multiple active subscriptions concurrently — for example,
+one listening for tools-list changes and another for resource updates. Each
+subscription is identified by the JSON-RPC request ID of its
+`subscriptions/listen` request, and every notification on the stream carries
+that ID in
+`io.modelcontextprotocol/subscriptionId` so clients can demultiplex them.
+
+## Cancellation
+
+A subscription ends when:
+
+- The **client** cancels it — close the SSE stream (HTTP) or send
+ `notifications/cancelled` referencing the `subscriptions/listen` request ID (stdio).
+- The **server** tears it down (e.g., during shutdown) — it **SHOULD** send the
+ empty `subscriptions/listen` response to signal a graceful end (see
+ [Graceful Closure](#graceful-closure)), then close the stream.
+- The underlying transport closes (HTTP timeout, TCP disconnect, stdio process
+ exit).
+
+### Graceful Closure
+
+When the server ends a subscription on its own initiative (for example, during
+shutdown), it **SHOULD** respond to the original `subscriptions/listen` request
+with an empty result before closing the stream. This is the JSON-RPC response to
+the long-lived request, correlated by its `id`, and signals that the subscription
+ended gracefully — as opposed to an abrupt transport drop, which carries no
+response.
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "resultType": "complete",
+ "_meta": {
+ "io.modelcontextprotocol/subscriptionId": 1
+ }
+ }
+}
+```
+
+Like every other message on the stream, the response carries
+`io.modelcontextprotocol/subscriptionId` in `_meta`, identifying which
+subscription it closes. The value matches the JSON-RPC `id` of the originating
+`subscriptions/listen` request.
+
+A client that receives this response knows the subscription closed cleanly; a
+transport that closes without it indicates an unexpected disconnect, which the
+client **MAY** treat as a trigger to reconnect.
+
+On **stdio**, if the connection is terminated and then re-established, the
+client **MUST** re-send `subscriptions/listen` to re-establish its
+subscriptions — the server holds no subscription state across reconnections.
+
+See [Cancellation][cancellation] for the full rules.
+
+[cancellation]: /specification/2026-07-28/basic/patterns/cancellation
diff --git a/spec/tasks-extension.md b/spec/tasks-extension.md
new file mode 100644
index 00000000..c265b2b6
--- /dev/null
+++ b/spec/tasks-extension.md
@@ -0,0 +1,278 @@
+---
+title: Tasks
+sidebarTitle: Overview
+description: Asynchronous task execution for long-running MCP operations
+---
+
+The [experimental-ext-tasks repository](https://github.com/modelcontextprotocol/experimental-ext-tasks) contains the full specification and documentation for MCP Tasks.
+
+
+ Full specification and documentation for MCP Tasks.
+
+
+Not every tool call returns instantly. Some operations — CI pipelines, batch
+processing, human approvals — take seconds, minutes, or longer. MCP Tasks let
+servers return a durable handle instead of blocking, so clients can poll for
+progress, provide input when needed, and retrieve the final result after
+reconnecting.
+
+## Why not just block?
+
+You could hold the connection open until the work finishes. Tasks solve
+problems that blocking cannot:
+
+- **No long-lived connections.** Blocking ties up a connection for the duration
+ of the operation. Many clients and transport intermediaries impose timeouts
+ that make this impractical beyond a few seconds.
+- **Crash resilience.** A task ID is a durable handle. If the client
+ disconnects or restarts, it can resume polling with the same ID.
+- **Progress visibility.** Tasks carry status metadata (`working`,
+ `input_required`, `completed`, `failed`, `cancelled`) and optional status
+ messages, giving clients visibility into progress.
+- **Mid-flight interaction.** When a task needs input (e.g., an elicitation for
+ user confirmation), it moves to `input_required` and surfaces the request.
+ The client responds via `tasks/update` — no second connection or unsolicited
+ server-to-client messages required.
+- **Server-directed.** The server decides per-request whether to create a task.
+ Clients opt in once via the extension capability and handle whichever result
+ shape arrives. No per-tool warmup or per-request flag.
+
+## How Tasks work
+
+Tasks extend the standard request flow. When a server decides a request will be
+long-running, it returns a task handle instead of the final result. The client
+polls for completion.
+
+1. **Capability negotiation.** The client includes
+ `io.modelcontextprotocol/tasks` in its per-request capabilities. The server
+ advertises the same extension in its own `server/discover` capabilities.
+
+2. **Task creation.** In response to a supported request, the server returns a
+ `CreateTaskResult` (identified by `resultType: "task"`) containing a `taskId`,
+ initial status, TTL, and suggested polling interval. The task is durably
+ created before the response is sent.
+
+3. **Polling.** The client calls `tasks/get` with the `taskId`. The response
+ carries the current status and, for terminal states, the final result or
+ error.
+
+4. **Mid-flight input.** If the task moves to `input_required`, the `tasks/get`
+ response includes an `inputRequests` map with elicitations or other server
+ requests. The client fulfills these via `tasks/update`.
+
+5. **Completion.** When the status reaches `completed`, the `result` field
+ contains what the original request would have returned synchronously. If the
+ status is `failed`, the `error` field contains the JSON-RPC error.
+
+6. **Cancellation.** The client can send `tasks/cancel` at any time.
+ Cancellation is cooperative — the server acknowledges the intent but is not
+ obligated to stop the work.
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Server
+
+ Client->>Server: tools/call (with tasks capability)
+ Server-->>Client: CreateTaskResult (taskId, status: working)
+
+ loop Poll until terminal
+ Client->>Server: tasks/get (taskId)
+ Server-->>Client: Task (status: working)
+ end
+
+ Note over Client,Server: Server needs user input
+ Client->>Server: tasks/get (taskId)
+ Server-->>Client: Task (status: input_required, inputRequests)
+ Client->>Server: tasks/update (taskId, inputResponses)
+ Server-->>Client: ack
+
+ loop Poll until terminal
+ Client->>Server: tasks/get (taskId)
+ Server-->>Client: Task (status: working)
+ end
+
+ Client->>Server: tasks/get (taskId)
+ Server-->>Client: Task (status: completed, result)
+```
+
+## When to use Tasks
+
+Tasks are a good fit when your use case involves:
+
+**Long-running operations.** CI pipelines, batch data processing, or model
+training jobs that take minutes or hours.
+
+**Human-in-the-loop workflows.** Approval gates, review steps, or any operation
+that pauses for user confirmation. The task moves to `input_required` and the
+client presents the request.
+
+**External job systems.** If your server wraps an API that already uses job IDs
+(cloud deployments, async APIs, queued work), return a task when you create the
+job and resolve it when the job completes.
+
+**Unreliable connections.** Mobile clients, intermittent networks, or
+environments where connections drop. Task IDs survive disconnects.
+
+**Batch processing.** Operations that process many items (bulk imports, mass
+updates) where partial progress is meaningful. Status messages report progress.
+
+## Task lifecycle
+
+| Status | Meaning |
+| ---------------- | -------------------------------------------------------------------------- |
+| `working` | The operation is in progress. |
+| `input_required` | The server needs client input before continuing. See `inputRequests`. |
+| `completed` | The operation finished. The `result` field contains the final output. |
+| `failed` | A JSON-RPC error occurred during execution. The `error` field has details. |
+| `cancelled` | The operation was cancelled (not always honored). |
+
+`completed`, `failed`, and `cancelled` are terminal — once reached, the task's
+state does not change.
+
+## Notifications
+
+Servers can push status updates via `notifications/tasks`. Clients opt
+into these through the `subscriptions/listen` mechanism. Each notification
+carries the full task state, eliminating the need for an extra `tasks/get`
+round-trip.
+
+Polling is the default. If a server supports notifications, clients can rely on
+them instead of polling.
+
+## Implementation guide
+
+### For MCP clients
+
+To consume task-augmented responses, your client must:
+
+
+
+
+Include the extension in its per-request capabilities:
+
+```jsonc
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "...",
+ "params": {
+ // Other fields...
+ "_meta": {
+ // Other fields...
+ "io.modelcontextprotocol/clientCapabilities": {
+ "extensions": {
+ "io.modelcontextprotocol/tasks": {},
+ },
+ },
+ },
+ },
+}
+```
+
+
+
+
+When issuing a supported request (e.g., `tools/call`), be prepared to receive
+either the standard result or a `CreateTaskResult` with `resultType: "task"`.
+
+
+
+
+Call `tasks/get` with the returned `taskId`, respecting the `pollIntervalMs`
+value. Continue polling until the task reaches a terminal status (`completed`,
+`failed`, or `cancelled`).
+
+
+
+
+If the task status is `input_required`, read the `inputRequests` map, present
+the requests to the user or model, and submit responses via `tasks/update`.
+
+
+
+
+Store task IDs durably so polling can resume after a client crash or restart.
+
+
+
+
+### For MCP servers
+
+To return tasks from your server:
+
+
+
+
+Include the extension in your `server/discover` capabilities:
+
+```jsonc
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ // Other fields...
+ "capabilities": {
+ "extensions": {
+ "io.modelcontextprotocol/tasks": {},
+ },
+ },
+ },
+}
+```
+
+
+
+
+Before returning a `CreateTaskResult`, verify that the client included the
+extension in its per-request capabilities. Never return a task to a client that
+did not declare support.
+
+
+
+
+When a request will be long-running, respond with `resultType: "task"` and a
+`Task` object containing a unique `taskId`, initial status, `ttlMs`, and
+`pollIntervalMs`. The task must be durably created before sending the response.
+
+
+
+
+Return the current task state on each poll. For terminal states, include the
+`result` (on `completed`) or `error` (on `failed`) field.
+
+
+
+
+Accept `inputResponses` keyed to outstanding `inputRequests`. Acknowledge with
+an empty result. Ignore responses for unknown or already-satisfied keys.
+
+
+
+
+Acknowledge cancellation requests with an empty result. Honor them when
+possible, but cancellation is cooperative — the task may still reach a
+non-`cancelled` terminal status.
+
+
+
+
+## Client support
+
+
+
+MCP Tasks is an extension to the [core MCP specification](/specification/latest). Host
+support varies by client.
+
+
+
+See the [client matrix](/extensions/client-matrix) for extension support across
+clients. Task support requires explicit opt-in from both client and server.
+
+## Specification
+
+The Tasks extension is specified in the [experimental-ext-tasks repository](https://github.com/modelcontextprotocol/experimental-ext-tasks). It uses the standard MCP [extension negotiation](/extensions/overview#negotiation) mechanism: clients declare support in the `extensions` field of the `io.modelcontextprotocol/clientCapabilities` they send in each request's `_meta`, and servers advertise theirs in the capabilities returned by [`server/discover`](/specification/draft/server/discover).
diff --git a/spec/tasks.md b/spec/tasks.md
deleted file mode 100644
index df2005b2..00000000
--- a/spec/tasks.md
+++ /dev/null
@@ -1,900 +0,0 @@
----
-title: Tasks
----
-
-
-
-
-
-Tasks were introduced in version 2025-11-25 of the MCP specification and are currently considered **experimental**.
-The design and behavior of tasks may evolve in future protocol versions.
-
-
-
-The Model Context Protocol (MCP) allows requestors — which can be either clients or servers, depending on the direction of communication — to augment their requests with **tasks**. Tasks are durable state machines that carry information about the underlying execution state of the request they wrap, and are intended for requestor polling and deferred result retrieval. Each task is uniquely identifiable by a receiver-generated **task ID**.
-
-Tasks are useful for representing expensive computations and batch processing requests, and integrate seamlessly with external job APIs.
-
-## Definitions
-
-Tasks represent parties as either "requestors" or "receivers," defined as follows:
-
-- **Requestor:** The sender of a task-augmented request. This can be the client or the server — either can create tasks.
-- **Receiver:** The receiver of a task-augmented request, and the entity executing the task. This can be the client or the server — either can receive and execute tasks.
-
-## User Interaction Model
-
-Tasks are designed to be **requestor-driven** - requestors are responsible for augmenting requests with tasks and for polling for the results of those tasks; meanwhile, receivers tightly control which requests (if any) support task-based execution and manages the lifecycles of those tasks.
-
-This requestor-driven approach ensures deterministic response handling and enables sophisticated patterns such as dispatching concurrent requests, which only the requestor has sufficient context to orchestrate.
-
-Implementations are free to expose tasks through any interface pattern that suits their needs — the protocol itself does not mandate any specific user interaction model.
-
-## Capabilities
-
-Servers and clients that support task-augmented requests **MUST** declare a `tasks` capability during initialization. The `tasks` capability is structured by request category, with boolean properties indicating which specific request types support task augmentation.
-
-### Server Capabilities
-
-Servers declare if they support tasks, and if so, which server-side requests can be augmented with tasks.
-
-| Capability | Description |
-| --------------------------- | ---------------------------------------------------- |
-| `tasks.list` | Server supports the `tasks/list` operation |
-| `tasks.cancel` | Server supports the `tasks/cancel` operation |
-| `tasks.requests.tools.call` | Server supports task-augmented `tools/call` requests |
-
-```json
-{
- "capabilities": {
- "tasks": {
- "list": {},
- "cancel": {},
- "requests": {
- "tools": {
- "call": {}
- }
- }
- }
- }
-}
-```
-
-### Client Capabilities
-
-Clients declare if they support tasks, and if so, which client-side requests can be augmented with tasks.
-
-| Capability | Description |
-| --------------------------------------- | ---------------------------------------------------------------- |
-| `tasks.list` | Client supports the `tasks/list` operation |
-| `tasks.cancel` | Client supports the `tasks/cancel` operation |
-| `tasks.requests.sampling.createMessage` | Client supports task-augmented `sampling/createMessage` requests |
-| `tasks.requests.elicitation.create` | Client supports task-augmented `elicitation/create` requests |
-
-```json
-{
- "capabilities": {
- "tasks": {
- "list": {},
- "cancel": {},
- "requests": {
- "sampling": {
- "createMessage": {}
- },
- "elicitation": {
- "create": {}
- }
- }
- }
- }
-}
-```
-
-### Capability Negotiation
-
-During the initialization phase, both parties exchange their `tasks` capabilities to establish which operations support task-based execution. Requestors **SHOULD** only augment requests with a task if the corresponding capability has been declared by the receiver.
-
-For example, if a server's capabilities include `tasks.requests.tools.call: {}`, then clients may augment `tools/call` requests with a task. If a client's capabilities include `tasks.requests.sampling.createMessage: {}`, then servers may augment `sampling/createMessage` requests with a task.
-
-If `capabilities.tasks` is not defined, the peer **SHOULD NOT** attempt to create tasks during requests.
-
-The set of capabilities in `capabilities.tasks.requests` is exhaustive. If a request type is not present, it does not support task-augmentation.
-
-`capabilities.tasks.list` controls if the `tasks/list` operation is supported by the party.
-
-`capabilities.tasks.cancel` controls if the `tasks/cancel` operation is supported by the party.
-
-### Tool-Level Negotiation
-
-Tool calls are given special consideration for the purpose of task augmentation. In the result of `tools/list`, tools declare support for tasks via `execution.taskSupport`, which if present can have a value of `"required"`, `"optional"`, or `"forbidden"`.
-
-This is to be interpreted as a fine-grained layer in addition to capabilities, following these rules:
-
-1. If a server's capabilities do not include `tasks.requests.tools.call`, then clients **MUST NOT** attempt to use task augmentation on that server's tools, regardless of the `execution.taskSupport` value.
-1. If a server's capabilities include `tasks.requests.tools.call`, then clients consider the value of `execution.taskSupport`, and handle it accordingly:
- 1. If `execution.taskSupport` is not present or `"forbidden"`, clients **MUST NOT** attempt to invoke the tool as a task. Servers **SHOULD** return a `-32601` (Method not found) error if a client attempts to do so. This is the default behavior.
- 1. If `execution.taskSupport` is `"optional"`, clients **MAY** invoke the tool as a task or as a normal request.
- 1. If `execution.taskSupport` is `"required"`, clients **MUST** invoke the tool as a task. Servers **MUST** return a `-32601` (Method not found) error if a client does not attempt to do so.
-
-## Protocol Messages
-
-### Creating Tasks
-
-Task-augmented requests follow a two-phase response pattern that differs from normal requests:
-
-- **Normal requests**: The server processes the request and returns the actual operation result directly.
-- **Task-augmented requests**: The server accepts the request and immediately returns a `CreateTaskResult` containing task data. The actual operation result becomes available later through `tasks/result` after the task completes.
-
-To create a task, requestors send a request with the `task` field included in the request params. Requestors **MAY** include a `ttl` value indicating the desired task lifetime duration (in milliseconds) since its creation.
-
-**Request:**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1,
- "method": "tools/call",
- "params": {
- "name": "get_weather",
- "arguments": {
- "city": "New York"
- },
- "task": {
- "ttl": 60000
- }
- }
-}
-```
-
-**Response:**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1,
- "result": {
- "task": {
- "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
- "status": "working",
- "statusMessage": "The operation is now in progress.",
- "createdAt": "2025-11-25T10:30:00Z",
- "lastUpdatedAt": "2025-11-25T10:40:00Z",
- "ttl": 60000,
- "pollInterval": 5000
- }
- }
-}
-```
-
-When a receiver accepts a task-augmented request, it returns a [`CreateTaskResult`](/specification/2025-11-25/schema#createtaskresult) containing task data. The response does not include the actual operation result. The actual result (e.g., tool result for `tools/call`) becomes available only through `tasks/result` after the task completes.
-
-
-
-When a task is created in response to a `tools/call` request, host applications may wish to return control to the model while the task is executing. This allows the model to continue processing other requests or perform additional work while waiting for the task to complete.
-
-To support this pattern, servers can provide an optional `io.modelcontextprotocol/model-immediate-response` key in the `_meta` field of the `CreateTaskResult`. The value of this key should be a string intended to be passed as an immediate tool result to the model.
-If a server does not provide this field, the host application can fall back to its own predefined message.
-
-This guidance is non-binding and is provisional logic intended to account for the specific use case. This behavior may be formalized or modified as part of `CreateTaskResult` in future protocol versions.
-
-
-
-### Getting Tasks
-
-
-
-In the Streamable HTTP (SSE) transport, clients **MAY** disconnect from an SSE stream opened by the server in response to a `tasks/get` request at any time.
-
-While this note is not prescriptive regarding the specific usage of SSE streams, all implementations **MUST** continue to comply with the existing [Streamable HTTP transport specification](../transports#sending-messages-to-the-server).
-
-
-
-Requestors poll for task completion by sending [`tasks/get`](/specification/2025-11-25/schema#tasks%2Fget) requests.
-Requestors **SHOULD** respect the `pollInterval` provided in responses when determining polling frequency.
-
-Requestors **SHOULD** continue polling until the task reaches a terminal status (`completed`, `failed`, or `cancelled`), or until encountering the [`input_required`](#input-required-status) status. Note that invoking `tasks/result` does not imply that the requestor needs to stop polling - requestors **SHOULD** continue polling the task status via `tasks/get` if they are not actively waiting for `tasks/result` to complete.
-
-**Request:**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 3,
- "method": "tasks/get",
- "params": {
- "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840"
- }
-}
-```
-
-**Response:**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 3,
- "result": {
- "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
- "status": "working",
- "statusMessage": "The operation is now in progress.",
- "createdAt": "2025-11-25T10:30:00Z",
- "lastUpdatedAt": "2025-11-25T10:40:00Z",
- "ttl": 30000,
- "pollInterval": 5000
- }
-}
-```
-
-### Retrieving Task Results
-
-
-
-In the Streamable HTTP (SSE) transport, clients **MAY** disconnect from an SSE stream opened by the server in response to a `tasks/result` request at any time.
-
-While this note is not prescriptive regarding the specific usage of SSE streams, all implementations **MUST** continue to comply with the existing [Streamable HTTP transport specification](../transports#sending-messages-to-the-server).
-
-
-
-After a task completes the operation result is retrieved via [`tasks/result`](/specification/2025-11-25/schema#tasks%2Fresult). This is distinct from the initial `CreateTaskResult` response, which contains only task data. The result structure matches the original request type (e.g., `CallToolResult` for `tools/call`).
-
-To retrieve the result of a completed task, requestors can send a `tasks/result` request:
-
-While `tasks/result` blocks until the task reaches a terminal status, requestors can continue polling via `tasks/get` in parallel if they are not actively blocked waiting for the result, such as if their previous `tasks/result` request failed or was cancelled. This allows requestors to monitor status changes or display progress updates while the task executes, even after invoking `tasks/result`.
-
-**Request:**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 4,
- "method": "tasks/result",
- "params": {
- "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840"
- }
-}
-```
-
-**Response:**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 4,
- "result": {
- "content": [
- {
- "type": "text",
- "text": "Current weather in New York:\nTemperature: 72°F\nConditions: Partly cloudy"
- }
- ],
- "isError": false,
- "_meta": {
- "io.modelcontextprotocol/related-task": {
- "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840"
- }
- }
- }
-}
-```
-
-### Task Status Notification
-
-When a task status changes, receivers **MAY** send a [`notifications/tasks/status`](/specification/2025-11-25/schema#notifications%2Ftasks%2Fstatus) notification to inform the requestor of the change. This notification includes the full task state.
-
-**Notification:**
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "notifications/tasks/status",
- "params": {
- "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
- "status": "completed",
- "createdAt": "2025-11-25T10:30:00Z",
- "lastUpdatedAt": "2025-11-25T10:50:00Z",
- "ttl": 60000,
- "pollInterval": 5000
- }
-}
-```
-
-The notification includes the full [`Task`](/specification/2025-11-25/schema#task) object, including the updated `status` and `statusMessage` (if present). This allows requestors to access the complete task state without making an additional `tasks/get` request.
-
-Requestors **MUST NOT** rely on receiving this notifications, as it is optional. Receivers are not required to send status notifications and may choose to only send them for certain status transitions. Requestors **SHOULD** continue to poll via `tasks/get` to ensure they receive status updates.
-
-### Listing Tasks
-
-To retrieve a list of tasks, requestors can send a [`tasks/list`](/specification/2025-11-25/schema#tasks%2Flist) request. This operation supports pagination.
-
-**Request:**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 5,
- "method": "tasks/list",
- "params": {
- "cursor": "optional-cursor-value"
- }
-}
-```
-
-**Response:**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 5,
- "result": {
- "tasks": [
- {
- "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
- "status": "working",
- "createdAt": "2025-11-25T10:30:00Z",
- "lastUpdatedAt": "2025-11-25T10:40:00Z",
- "ttl": 30000,
- "pollInterval": 5000
- },
- {
- "taskId": "abc123-def456-ghi789",
- "status": "completed",
- "createdAt": "2025-11-25T09:15:00Z",
- "lastUpdatedAt": "2025-11-25T10:40:00Z",
- "ttl": 60000
- }
- ],
- "nextCursor": "next-page-cursor"
- }
-}
-```
-
-### Cancelling Tasks
-
-To explicitly cancel a task, requestors can send a [`tasks/cancel`](/specification/2025-11-25/schema#tasks%2Fcancel) request.
-
-**Request:**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 6,
- "method": "tasks/cancel",
- "params": {
- "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840"
- }
-}
-```
-
-**Response:**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 6,
- "result": {
- "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
- "status": "cancelled",
- "statusMessage": "The task was cancelled by request.",
- "createdAt": "2025-11-25T10:30:00Z",
- "lastUpdatedAt": "2025-11-25T10:40:00Z",
- "ttl": 30000,
- "pollInterval": 5000
- }
-}
-```
-
-## Behavior Requirements
-
-These requirements apply to all parties that support receiving task-augmented requests.
-
-### Task Support and Handling
-
-1. Receivers that do not declare the task capability for a request type **MUST** process requests of that type normally, ignoring any task-augmentation metadata if present.
-1. Receivers that declare the task capability for a request type **MAY** return an error for non-task-augmented requests, requiring requestors to use task augmentation.
-
-### Task ID Requirements
-
-1. Task IDs **MUST** be a string value.
-1. Task IDs **MUST** be generated by the receiver when creating a task.
-1. Task IDs **MUST** be unique among all tasks controlled by the receiver.
-
-### Task Status Lifecycle
-
-1. Tasks **MUST** begin in the `working` status when created.
-1. Receivers **MUST** only transition tasks through the following valid paths:
- 1. From `working`: may move to `input_required`, `completed`, `failed`, or `cancelled`
- 1. From `input_required`: may move to `working`, `completed`, `failed`, or `cancelled`
- 1. Tasks with a `completed`, `failed`, or `cancelled` status are in a terminal state and **MUST NOT** transition to any other status
-
-**Task Status State Diagram:**
-
-```mermaid
-stateDiagram-v2
- [*] --> working
-
- working --> input_required
- working --> terminal
-
- input_required --> working
- input_required --> terminal
-
- terminal --> [*]
-
- note right of terminal
- Terminal states:
- • completed
- • failed
- • cancelled
- end note
-```
-
-### Input Required Status
-
-
-
-With the Streamable HTTP (SSE) transport, servers often close SSE streams after delivering a response message, which can lead to ambiguity regarding the stream used for subsequent task messages.
-
-Servers can handle this by enqueueing messages to the client to side-channel task-related messages alongside other responses.
-
-Servers have flexibility in how they manage SSE streams during task polling and result retrieval, and clients **SHOULD** expect messages to be delivered on any SSE stream, including the HTTP GET stream.
-One possible approach is maintaining an SSE stream on `tasks/result` (see notes on the `input_required` status).
-Where possible, servers **SHOULD NOT** upgrade to an SSE stream in response to a `tasks/get` request, as the client has indicated it wishes to poll for a result.
-
-While this note is not prescriptive regarding the specific usage of SSE streams, all implementations **MUST** continue to comply with the existing [Streamable HTTP transport specification](../transports#sending-messages-to-the-server).
-
-
-
-1. When the task receiver has messages for the requestor that are necessary to complete the task, the receiver **SHOULD** move the task to the `input_required` status.
-1. The receiver **MUST** include the `io.modelcontextprotocol/related-task` metadata in the request to associate it with the task.
-1. When the requestor encounters the `input_required` status, it **SHOULD** preemptively call `tasks/result`.
-1. When the receiver receives all required input, the task **SHOULD** transition out of `input_required` status (typically back to `working`).
-
-### TTL and Resource Management
-
-1. Receivers **MUST** include a `createdAt` [ISO 8601](https://datatracker.ietf.org/doc/html/rfc3339#section-5)-formatted timestamp in all task responses to indicate when the task was created.
-1. Receivers **MUST** include a `lastUpdatedAt` [ISO 8601](https://datatracker.ietf.org/doc/html/rfc3339#section-5)-formatted timestamp in all task responses to indicate when the task was last updated.
-1. Receivers **MAY** override the requested `ttl` duration.
-1. Receivers **MUST** include the actual `ttl` duration (or `null` for unlimited) in `tasks/get` responses.
-1. After a task's `ttl` lifetime has elapsed, receivers **MAY** delete the task and its results, regardless of the task status.
-1. Receivers **MAY** include a `pollInterval` value (in milliseconds) in `tasks/get` responses to suggest polling intervals. Requestors **SHOULD** respect this value when provided.
-
-### Result Retrieval
-
-1. Receivers that accept a task-augmented request **MUST** return a `CreateTaskResult` as the response. This result **SHOULD** be returned as soon as possible after accepting the task.
-1. When a receiver receives a `tasks/result` request for a task in a terminal status (`completed`, `failed`, or `cancelled`), it **MUST** return the final result of the underlying request, whether that is a successful result or a JSON-RPC error.
-1. When a receiver receives a `tasks/result` request for a task in any other non-terminal status (`working` or `input_required`), it **MUST** block the response until the task reaches a terminal status.
-1. For tasks in a terminal status, receivers **MUST** return from `tasks/result` exactly what the underlying request would have returned, whether that is a successful result or a JSON-RPC error.
-
-### Associating Task-Related Messages
-
-1. All requests, notifications, and responses related to a task **MUST** include the `io.modelcontextprotocol/related-task` key in their `_meta` field, with the value set to an object with a `taskId` matching the associated task ID.
- 1. For example, an elicitation that a task-augmented tool call depends on **MUST** share the same related task ID with that tool call's task.
-1. For the `tasks/get`, `tasks/result`, and `tasks/cancel` operations, the `taskId` parameter in the request **MUST** be used as the source of truth for identifying the target task. Requestors **SHOULD NOT** include `io.modelcontextprotocol/related-task` metadata in these requests, and receivers **MUST** ignore such metadata if present in favor of the RPC method parameter.
- Similarly, for the `tasks/get`, `tasks/list`, and `tasks/cancel` operations, receivers **SHOULD NOT** include `io.modelcontextprotocol/related-task` metadata in the result messages, as the `taskId` is already present in the response structure.
-
-### Task Notifications
-
-1. Receivers **MAY** send `notifications/tasks/status` notifications when a task's status changes.
-1. Requestors **MUST NOT** rely on receiving the `notifications/tasks/status` notification, as it is optional.
-1. When sent, the `notifications/tasks/status` notification **SHOULD NOT** include the `io.modelcontextprotocol/related-task` metadata, as the task ID is already present in the notification parameters.
-
-### Task Progress Notifications
-
-Task-augmented requests support progress notifications as defined in the [progress](./progress) specification. The `progressToken` provided in the initial request remains valid throughout the task lifetime.
-
-### Task Listing
-
-1. Receivers **SHOULD** use cursor-based pagination to limit the number of tasks returned in a single response.
-1. Receivers **MUST** include a `nextCursor` in the response if more tasks are available.
-1. Requestors **MUST** treat cursors as opaque tokens and not attempt to parse or modify them.
-1. If a task is retrievable via `tasks/get` for a requestor, it **MUST** be retrievable via `tasks/list` for that requestor.
-
-### Task Cancellation
-
-1. Receivers **MUST** reject cancellation requests for tasks already in a terminal status (`completed`, `failed`, or `cancelled`) with error code `-32602` (Invalid params).
-1. Upon receiving a valid cancellation request, receivers **SHOULD** attempt to stop the task execution and **MUST** transition the task to `cancelled` status before sending the response.
-1. Once a task is cancelled, it **MUST** remain in `cancelled` status even if execution continues to completion or fails.
-1. The `tasks/cancel` operation does not define deletion behavior. However, receivers **MAY** delete cancelled tasks at their discretion at any time, including immediately after cancellation or after the task `ttl` expires.
-1. Requestors **SHOULD NOT** rely on cancelled tasks being retained for any specific duration and should retrieve any needed information before cancelling.
-
-## Message Flow
-
-### Basic Task Lifecycle
-
-```mermaid
-sequenceDiagram
- participant C as Client (Requestor)
- participant S as Server (Receiver)
- Note over C,S: 1. Task Creation
- C->>S: Request with task field (ttl)
- activate S
- S->>C: CreateTaskResult (taskId, status: working, ttl, pollInterval)
- deactivate S
- Note over C,S: 2. Task Polling
- C->>S: tasks/get (taskId)
- activate S
- S->>C: working
- deactivate S
- Note over S: Task processing continues...
- C->>S: tasks/get (taskId)
- activate S
- S->>C: working
- deactivate S
- Note over S: Task completes
- C->>S: tasks/get (taskId)
- activate S
- S->>C: completed
- deactivate S
- Note over C,S: 3. Result Retrieval
- C->>S: tasks/result (taskId)
- activate S
- S->>C: Result content
- deactivate S
- Note over C,S: 4. Cleanup
- Note over S: After ttl period from creation, task is cleaned up
-```
-
-### Task-Augmented Tool Call With Elicitation
-
-```mermaid
-sequenceDiagram
- participant U as User
- participant LLM
- participant C as Client (Requestor)
- participant S as Server (Receiver)
-
- Note over LLM,C: LLM initiates request
- LLM->>C: Request operation
-
- Note over C,S: Client augments with task
- C->>S: tools/call (ttl: 3600000)
- activate S
- S->>C: CreateTaskResult (task-123, status: working)
- deactivate S
-
- Note over LLM,C: Client continues processing other requests while task executes in background
- LLM->>C: Request other operation
- C->>LLM: Other operation result
-
- Note over C,S: Client polls for status
- C->>S: tasks/get (task-123)
- activate S
- S->>C: working
- deactivate S
-
- Note over S: Server needs information from client Task moves to input_required
-
- Note over C,S: Client polls and discovers input_required
- C->>S: tasks/get (task-123)
- activate S
- S->>C: input_required
- deactivate S
-
- Note over C,S: Client opens result stream
- C->>S: tasks/result (task-123)
- activate S
- S->>C: elicitation/create (related-task: task-123)
- activate C
- C->>U: Prompt user for input
- U->>C: Provide information
- C->>S: elicitation response (related-task: task-123)
- deactivate C
- deactivate S
-
- Note over C,S: Client closes result stream and resumes polling
-
- Note over S: Task continues processing... Task moves back to working
-
- C->>S: tasks/get (task-123)
- activate S
- S->>C: working
- deactivate S
-
- Note over S: Task completes
-
- Note over C,S: Client polls and discovers completion
- C->>S: tasks/get (task-123)
- activate S
- S->>C: completed
- deactivate S
-
- Note over C,S: Client retrieves final results
- C->>S: tasks/result (task-123)
- activate S
- S->>C: Result content
- deactivate S
- C->>LLM: Process result
-
- Note over S: Results retained for ttl period from creation
-```
-
-### Task-Augmented Sampling Request
-
-```mermaid
-sequenceDiagram
- participant U as User
- participant LLM
- participant C as Client (Receiver)
- participant S as Server (Requestor)
-
- Note over S: Server decides to initiate request
-
- Note over S,C: Server requests client operation (task-augmented)
- S->>C: sampling/createMessage (ttl: 3600000)
- activate C
- C->>S: CreateTaskResult (request-789, status: working)
- deactivate C
-
- Note over S: Server continues processing while waiting for result
-
- Note over S,C: Server polls for result
- S->>C: tasks/get (request-789)
- activate C
- C->>S: working
- deactivate C
-
- Note over C,U: Client may present request to user
- C->>U: Review request
- U->>C: Approve request
-
- Note over C,LLM: Client may involve LLM
- C->>LLM: Request completion
- LLM->>C: Return completion
-
- Note over C,U: Client may present result to user
- C->>U: Review result
- U->>C: Approve result
-
- Note over S,C: Server polls and discovers completion
- S->>C: tasks/get (request-789)
- activate C
- C->>S: completed
- deactivate C
-
- Note over S,C: Server retrieves result
- S->>C: tasks/result (request-789)
- activate C
- C->>S: Result content
- deactivate C
-
- Note over S: Server continues processing
-
- Note over C: Results retained for ttl period from creation
-```
-
-### Task Cancellation Flow
-
-```mermaid
-sequenceDiagram
- participant C as Client (Requestor)
- participant S as Server (Receiver)
-
- Note over C,S: 1. Task Creation
- C->>S: tools/call (request ID: 42, ttl: 60000)
- activate S
- S->>C: CreateTaskResult (task-123, status: working)
- deactivate S
-
- Note over C,S: 2. Task Processing
- C->>S: tasks/get (task-123)
- activate S
- S->>C: working
- deactivate S
-
- Note over C,S: 3. Client Cancellation
- Note over C: User requests cancellation
- C->>S: tasks/cancel (taskId: task-123)
- activate S
-
- Note over S: Server stops execution (best effort)
- Note over S: Task moves to cancelled status
-
- S->>C: Task (status: cancelled)
- deactivate S
-
- Note over C: Client receives confirmation
-
- Note over S: Server may delete task at its discretion
-```
-
-## Data Types
-
-### Task
-
-A task represents the execution state of a request. The task state includes:
-
-- `taskId`: Unique identifier for the task
-- `status`: Current state of the task execution
-- `statusMessage`: Optional human-readable message describing the current state (can be present for any status, including error details for failed tasks)
-- `createdAt`: ISO 8601 timestamp when the task was created
-- `ttl`: Time in milliseconds from creation before task may be deleted
-- `pollInterval`: Suggested time in milliseconds between status checks
-- `lastUpdatedAt`: ISO 8601 timestamp when the task status was last updated
-
-### Task Status
-
-Tasks can be in one of the following states:
-
-- `working`: The request is currently being processed.
-- `input_required`: The receiver needs input from the requestor. The requestor should call `tasks/result` to receive input requests, even though the task has not reached a terminal state.
-- `completed`: The request completed successfully and results are available.
-- `failed`: The associated request did not complete successfully. For tool calls specifically, this includes cases where the tool call result has `isError` set to true.
-- `cancelled`: The request was cancelled before completion.
-
-### Task Parameters
-
-When augmenting a request with task execution, the `task` field is included in the request parameters:
-
-```json
-{
- "task": {
- "ttl": 60000
- }
-}
-```
-
-Fields:
-
-- `ttl` (number, optional): Requested duration in milliseconds to retain task from creation
-
-### Related Task Metadata
-
-All requests, responses, and notifications associated with a task **MUST** include the `io.modelcontextprotocol/related-task` key in `_meta`:
-
-```json
-{
- "io.modelcontextprotocol/related-task": {
- "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840"
- }
-}
-```
-
-This associates messages with their originating task across the entire request lifecycle.
-
-For the `tasks/get`, `tasks/list`, and `tasks/cancel` operations, requestors and receivers **SHOULD NOT** include this metadata in their messages, as the `taskId` is already present in the message structure.
-The `tasks/result` operation **MUST** include this metadata in its response, as the result structure itself does not contain the task ID.
-
-## Error Handling
-
-Tasks use two error reporting mechanisms:
-
-1. **Protocol Errors**: Standard JSON-RPC errors for protocol-level issues
-1. **Task Execution Errors**: Errors in the underlying request execution, reported through task status
-
-### Protocol Errors
-
-Receivers **MUST** return standard JSON-RPC errors for the following protocol error cases:
-
-- Invalid or nonexistent `taskId` in `tasks/get`, `tasks/result`, or `tasks/cancel`: `-32602` (Invalid params)
-- Invalid or nonexistent cursor in `tasks/list`: `-32602` (Invalid params)
-- Attempt to cancel a task already in a terminal status: `-32602` (Invalid params)
-- Internal errors: `-32603` (Internal error)
-
-Additionally, receivers **MAY** return the following errors:
-
-- Non-task-augmented request when receiver requires task augmentation for that request type: `-32600` (Invalid request)
-
-Receivers **SHOULD** provide informative error messages to describe the cause of errors.
-
-**Example: Task augmentation required**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1,
- "error": {
- "code": -32600,
- "message": "Task augmentation required for tools/call requests"
- }
-}
-```
-
-**Example: Task not found**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 70,
- "error": {
- "code": -32602,
- "message": "Failed to retrieve task: Task not found"
- }
-}
-```
-
-**Example: Task expired**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 71,
- "error": {
- "code": -32602,
- "message": "Failed to retrieve task: Task has expired"
- }
-}
-```
-
-
-
-Receivers are not required to retain tasks indefinitely. It is compliant behavior for a receiver to return an error stating the task cannot be found if it has purged an expired task.
-
-
-
-**Example: Task cancellation rejected (already terminal)**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 74,
- "error": {
- "code": -32602,
- "message": "Cannot cancel task: already in terminal status 'completed'"
- }
-}
-```
-
-### Task Execution Errors
-
-When the underlying request does not complete successfully, the task moves to the `failed` status. This includes JSON-RPC protocol errors during request execution, or for tool calls specifically, when the tool result has `isError` set to true. The `tasks/get` response **SHOULD** include a `statusMessage` field with diagnostic information about the failure.
-
-**Example: Task with execution error**
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 4,
- "result": {
- "taskId": "786512e2-9e0d-44bd-8f29-789f820fe840",
- "status": "failed",
- "createdAt": "2025-11-25T10:30:00Z",
- "lastUpdatedAt": "2025-11-25T10:40:00Z",
- "ttl": 30000,
- "statusMessage": "Tool execution failed: API rate limit exceeded"
- }
-}
-```
-
-For tasks that wrap tool call requests, when the tool result has `isError` set to `true`, the task should reach `failed` status.
-
-The `tasks/result` endpoint returns exactly what the underlying request would have returned:
-
-- If the underlying request resulted in a JSON-RPC error, `tasks/result` **MUST** return that same JSON-RPC error.
-- If the request completed with a JSON-RPC response, `tasks/result` **MUST** return a successful JSON-RPC response containing that result.
-
-## Security Considerations
-
-### Task Isolation and Access Control
-
-Task IDs are the primary mechanism for accessing task state and results. Without proper access controls, any party that can guess or obtain a task ID could potentially access sensitive information or manipulate tasks they did not create.
-
-When an authorization context is provided, receivers **MUST** bind tasks to said context.
-
-Context-binding is not practical for all applications. Some MCP servers operate in environments without authorization, such as single-user tools, or use transports that don't support authorization.
-In these scenarios, receivers **SHOULD** document this limitation clearly, as task results may be accessible to any requestor that can guess the task ID.
-If context-binding is unavailable, receivers **MUST** generate cryptographically secure task IDs with enough entropy to prevent guessing and should consider using shorter TTL durations to reduce the exposure window.
-Furthermore, receivers that cannot identify requestors **SHOULD NOT** declare the `tasks.list` capability, as listing tasks would expose task metadata to any requestor regardless of task ID entropy.
-
-If context-binding is available, receivers **MUST** reject `tasks/get`, `tasks/result`, and `tasks/cancel` requests for tasks that do not belong to the same authorization context as the requestor. For `tasks/list` requests, receivers **MUST** ensure the returned task list includes only tasks associated with the requestor's authorization context.
-
-Additionally, receivers **SHOULD** implement rate limiting on task operations to prevent denial-of-service and enumeration attacks.
-
-### Resource Management
-
-1. Receivers **SHOULD**:
- 1. Enforce limits on concurrent tasks per requestor
- 1. Enforce maximum `ttl` durations to prevent indefinite resource retention
- 1. Clean up expired tasks promptly to free resources
- 1. Document maximum supported `ttl` duration
- 1. Document maximum concurrent tasks per requestor
- 1. Implement monitoring and alerting for resource usage
-
-### Audit and Logging
-
-1. Receivers **SHOULD**:
- 1. Log task creation, completion, and retrieval events for audit purposes
- 1. Include auth context in logs when available
- 1. Monitor for suspicious patterns (e.g., many failed task lookups, excessive polling)
-1. Requestors **SHOULD**:
- 1. Log task lifecycle events for debugging and audit purposes
- 1. Track task IDs and their associated operations
diff --git a/spec/tools.md b/spec/tools.md
new file mode 100644
index 00000000..449020f5
--- /dev/null
+++ b/spec/tools.md
@@ -0,0 +1,803 @@
+---
+title: Tools
+---
+
+
+
+The Model Context Protocol (MCP) allows servers to expose tools that can be invoked by
+language models. Tools enable models to interact with external systems, such as querying
+databases, calling APIs, or performing computations. Each tool is uniquely identified by
+a name and includes metadata describing its schema.
+
+
+ For brevity, the request examples on this page omit the `_meta` request
+ metadata (`io.modelcontextprotocol/protocolVersion`,
+ `io.modelcontextprotocol/clientInfo`, and
+ `io.modelcontextprotocol/clientCapabilities`). Every request **MUST** include
+ the required `_meta` fields; see
+ [`_meta`](/specification/2026-07-28/basic/index#meta).
+
+
+## User Interaction Model
+
+Tools in MCP are designed to be **model-controlled**, meaning that the language model can
+discover and invoke tools automatically based on its contextual understanding and the
+user's prompts.
+
+However, implementations are free to expose tools through any interface pattern that
+suits their needs—the protocol itself does not mandate any specific user
+interaction model.
+
+
+
+For trust & safety and security, there **SHOULD** always
+be a human in the loop with the ability to deny tool invocations.
+
+Applications **SHOULD**:
+
+- Provide UI that makes clear which tools are being exposed to the AI model
+- Insert clear visual indicators when tools are invoked
+- Present confirmation prompts to the user for operations, to ensure a human is in the
+ loop
+
+
+
+## Capabilities
+
+Servers that support tools **MUST** declare the `tools` capability:
+
+```json
+{
+ "capabilities": {
+ "tools": {
+ "listChanged": true
+ }
+ }
+}
+```
+
+`listChanged` indicates whether the server will emit notifications when the list of
+available tools changes.
+
+Servers that declare the `tools` capability **MUST** respond to `tools/list` requests
+with the set of tools currently available to the requesting client. This set **MAY** be
+empty and **MAY** change over time (see
+[List Changed Notification](#list-changed-notification)), but **MUST NOT** vary
+per-connection or as a side effect of other requests on the connection. The set
+**MAY** vary by the authorization presented on the request — for example, returning
+only the tools the caller's granted scopes permit — since credentials are
+per-request input, not connection state.
+
+Servers **SHOULD** return tools in a deterministic order (i.e., the same ordering across
+requests when the underlying set of tools has not changed). Deterministic ordering enables
+clients to reliably cache the tool list and improves LLM prompt cache hit rates when tools
+are included in model context.
+
+## Protocol Messages
+
+### Listing Tools
+
+To discover available tools, clients send a `tools/list` request. This operation supports
+[pagination](/specification/2026-07-28/server/utilities/pagination) and [caching](/specification/2026-07-28/server/utilities/caching).
+
+**Request:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "tools/list",
+ "params": {
+ "cursor": "optional-cursor-value"
+ }
+}
+```
+
+**Response:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "resultType": "complete",
+ "tools": [
+ {
+ "name": "get_weather",
+ "title": "Weather Information Provider",
+ "description": "Get current weather information for a location",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string",
+ "description": "City name or zip code"
+ }
+ },
+ "required": ["location"]
+ },
+ "icons": [
+ {
+ "src": "https://example.com/weather-icon.png",
+ "mimeType": "image/png",
+ "sizes": ["48x48"]
+ }
+ ]
+ }
+ ],
+ "nextCursor": "next-page-cursor",
+ "ttlMs": 300000,
+ "cacheScope": "public"
+ }
+}
+```
+
+### Calling Tools
+
+To invoke a tool, clients send a `tools/call` request:
+
+**Request:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {
+ "name": "get_weather",
+ "arguments": {
+ "location": "New York"
+ }
+ }
+}
+```
+
+**Response:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "result": {
+ "resultType": "complete",
+ "content": [
+ {
+ "type": "text",
+ "text": "Current weather in New York:\nTemperature: 72°F\nConditions: Partly cloudy"
+ }
+ ],
+ "isError": false
+ }
+}
+```
+
+### Input Required Tool Results
+
+Servers **MAY** respond to `tools/call` with an [`InputRequiredResult`](/specification/2026-07-28/basic/patterns/mrtr#inputrequiredresult) to indicate that additional input is needed before the tool call can be completed. This follows the [multi round-trip requests](/specification/2026-07-28/basic/patterns/mrtr#multi-round-trip-requests) mechanism.
+
+When retrying the request with input responses, clients include `inputResponses` and, if provided by the server, `requestState` in the request parameters:
+
+**Input Required Response:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "result": {
+ "resultType": "input_required",
+ "inputRequests": {
+ "github_login": {
+ "method": "elicitation/create",
+ "params": {
+ "mode": "form",
+ "message": "Please provide your GitHub username",
+ "requestedSchema": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" }
+ },
+ "required": ["name"]
+ }
+ }
+ }
+ },
+ "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0..."
+ }
+}
+```
+
+**Retry with Input Responses:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 3,
+ "method": "tools/call",
+ "params": {
+ "name": "get_weather",
+ "arguments": {
+ "location": "New York"
+ },
+ "inputResponses": {
+ "github_login": {
+ "action": "accept",
+ "content": {
+ "name": "octocat"
+ }
+ }
+ },
+ "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0..."
+ }
+}
+```
+
+Note that the JSON-RPC `id` **MUST** be different between the initial request and the retry.
+
+### List Changed Notification
+
+When the list of available tools changes, servers that declared the `listChanged`
+capability **SHOULD** send a notification to clients that have opened a
+[`subscriptions/listen`](/specification/2026-07-28/basic/patterns/subscriptions) stream with
+`toolsListChanged: true`:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "notifications/tools/list_changed"
+}
+```
+
+## Message Flow
+
+```mermaid
+sequenceDiagram
+ participant LLM
+ participant Client
+ participant Server
+
+ Note over Client,Server: Discovery
+ Client->>Server: tools/list
+ Server-->>Client: List of tools
+
+ Note over Client,LLM: Tool Selection
+ LLM->>Client: Select tool to use
+
+ Note over Client,Server: Invocation
+ Client->>Server: tools/call
+ Server-->>Client: Tool result
+ Client->>LLM: Process result
+
+ opt listChanged
+ Client->>Server: subscriptions/listen (toolsListChanged: true)
+ Server--)Client: notifications/subscriptions/acknowledged
+ Note over Client,Server: Updates
+ Server--)Client: notifications/tools/list_changed
+ Client->>Server: tools/list
+ Server-->>Client: Updated tools
+ end
+```
+
+## Data Types
+
+### Tool
+
+A tool definition includes:
+
+- `name`: Unique identifier for the tool
+- `title`: Optional human-readable name of the tool for display purposes.
+- `description`: Human-readable description of functionality
+- `icons`: Optional array of icons for display in user interfaces
+- `inputSchema`: JSON Schema defining expected parameters
+ - Follows the [JSON Schema usage guidelines](/specification/2026-07-28/basic#json-schema-usage)
+ - Defaults to 2020-12 if no `$schema` field is present
+ - **MUST** be a valid JSON Schema object (not `null`)
+ - For tools with no parameters, use one of these valid approaches:
+ - `{ "type": "object", "additionalProperties": false }` - **Recommended**: explicitly accepts only empty objects
+ - `{ "type": "object" }` - accepts any object (including with properties)
+ - Properties **MAY** include an [`x-mcp-header`](#x-mcp-header) annotation to expose
+ parameter values as HTTP headers
+- `outputSchema`: Optional JSON Schema defining expected output structure
+ - Follows the [JSON Schema usage guidelines](/specification/2026-07-28/basic#json-schema-usage)
+ - Defaults to 2020-12 if no `$schema` field is present
+- `annotations`: Optional properties describing tool behavior
+
+
+ For trust & safety and security, clients **MUST** consider tool annotations to
+ be untrusted unless they come from trusted servers.
+
+
+#### Tool Names
+
+- Tool names **SHOULD** be between 1 and 128 characters in length (inclusive).
+- Tool names **SHOULD** be considered case-sensitive.
+- The following **SHOULD** be the only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits
+ (0-9), underscore (\_), hyphen (-), and dot (.)
+- Tool names **SHOULD NOT** contain spaces, commas, or other special characters.
+- Tool names **SHOULD** be unique within a server.
+- Example valid tool names:
+ - `getUser`
+ - `DATA_EXPORT_v2`
+ - `admin.tools.list`
+
+
+
+Tool name uniqueness is scoped to a single server. Clients or proxies that
+aggregate tools from multiple servers **MAY** encounter naming collisions (for
+example, two servers each exposing a `search` tool) and **SHOULD** implement a
+disambiguation strategy such as prefixing tool names with a server identifier.
+
+The server `name` (from `serverInfo`) is not guaranteed to be unique across
+servers and **SHOULD NOT** be relied upon for disambiguation.
+
+
+
+#### x-mcp-header
+
+The `x-mcp-header` extension property allows servers to designate specific tool
+parameters to be mirrored into HTTP headers when using the
+[Streamable HTTP transport](/specification/2026-07-28/basic/transports/streamable-http#custom-headers-from-tool-parameters).
+This enables network intermediaries (load balancers, proxies, WAFs) to route and process
+requests based on parameter values without parsing the request body.
+
+The `x-mcp-header` property is placed directly within the JSON Schema of the property to
+be mirrored. Its value specifies the name portion of the resulting `Mcp-Param-{name}`
+HTTP header.
+
+**Constraints on `x-mcp-header` values:**
+
+- **MUST NOT** be empty
+- **MUST** match HTTP field-name token syntax (`1*tchar`, [RFC 9110 Section 5.1](https://datatracker.ietf.org/doc/html/rfc9110#section-5.1))
+- **MUST NOT** contain control characters, including carriage return (CR, `\r`) or
+ line feed (LF, `\n`)
+- **MUST** be case-insensitively unique among all `x-mcp-header` values in the
+ `inputSchema`
+- **MUST** only be applied to parameters with primitive types (integer, string, boolean).
+ Parameters with type `number` are not permitted. Integer values **MUST** be within the
+ safe range for integers represented using IEEE754 double-precision floating point numbers (−253+1 to 253−1)
+- **MUST** only be applied to properties that are _statically reachable_ from the schema
+ root, as defined in
+ [Custom Headers from Tool Parameters](/specification/2026-07-28/basic/transports/streamable-http#custom-headers-from-tool-parameters),
+ which also defines how header values are extracted from call arguments
+
+Clients using the Streamable HTTP transport **MUST** reject tool definitions where any
+`x-mcp-header` value violates these constraints. Rejection means the client **MUST**
+exclude the invalid tool from the result of `tools/list`. Clients **SHOULD** log a
+warning when rejecting a tool definition, including the tool name and the reason for
+rejection. This ensures that a single malformed tool definition does not prevent other
+valid tools from being used. Clients using other transports (e.g., stdio) **MAY** ignore
+`x-mcp-header` annotations entirely.
+
+**Example tool definition with `x-mcp-header`:**
+
+```json
+{
+ "name": "execute_sql",
+ "description": "Execute SQL on Google Cloud Spanner",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "region": {
+ "type": "string",
+ "description": "The region to execute the query in",
+ "x-mcp-header": "Region"
+ },
+ "query": {
+ "type": "string",
+ "description": "The SQL query to execute"
+ }
+ },
+ "required": ["region", "query"]
+ }
+}
+```
+
+In this example, when the tool is called with `"region": "us-west1"`, the client adds
+the header `Mcp-Param-Region: us-west1` to the HTTP request.
+
+
+
+Server developers **SHOULD NOT** mark sensitive parameters (passwords, API keys, tokens,
+PII) with `x-mcp-header`, as header values are visible to network intermediaries.
+
+
+
+### Tool Result
+
+Tool results may contain [**structured**](#structured-content) or **unstructured** content.
+
+**Unstructured** content is returned in the `content` field of a result, and can contain multiple content items of different types:
+
+
+ All content types (text, image, audio, resource links, and embedded resources)
+ support optional
+ [annotations](/specification/2026-07-28/server/resources#annotations) that
+ provide metadata about audience, priority, and modification times. This is the
+ same annotation format used by resources and prompts.
+
+
+#### Text Content
+
+```json
+{
+ "type": "text",
+ "text": "Tool result text"
+}
+```
+
+#### Image Content
+
+```json
+{
+ "type": "image",
+ "data": "base64-encoded-data",
+ "mimeType": "image/png",
+ "annotations": {
+ "audience": ["user"],
+ "priority": 0.9
+ }
+}
+```
+
+#### Audio Content
+
+```json
+{
+ "type": "audio",
+ "data": "base64-encoded-audio-data",
+ "mimeType": "audio/wav"
+}
+```
+
+#### Resource Links
+
+A tool **MAY** return links to [Resources](/specification/2026-07-28/server/resources), to provide additional context
+or data. In this case, the tool will return a URI that can be subscribed to or fetched by the client:
+
+```json
+{
+ "type": "resource_link",
+ "uri": "file:///project/src/main.rs",
+ "name": "main.rs",
+ "description": "Primary application entry point",
+ "mimeType": "text/x-rust"
+}
+```
+
+Resource links support the same [Resource annotations](/specification/2026-07-28/server/resources#annotations) as regular resources to help clients understand how to use them.
+
+
+ Resource links returned by tools are not guaranteed to appear in the results
+ of a `resources/list` request.
+
+
+#### Embedded Resources
+
+[Resources](/specification/2026-07-28/server/resources) **MAY** be embedded to provide additional context
+or data using a suitable [URI scheme](./resources#common-uri-schemes). Servers that use embedded resources **SHOULD** implement the `resources` capability:
+
+```json
+{
+ "type": "resource",
+ "resource": {
+ "uri": "file:///project/src/main.rs",
+ "mimeType": "text/x-rust",
+ "text": "fn main() {\n println!(\"Hello world!\");\n}",
+ "annotations": {
+ "audience": ["user", "assistant"],
+ "priority": 0.7,
+ "lastModified": "2025-05-03T14:30:00Z"
+ }
+ }
+}
+```
+
+Embedded resources support the same [Resource annotations](/specification/2026-07-28/server/resources#annotations) as regular resources to help clients understand how to use them.
+
+#### Structured Content
+
+**Structured** content is returned as a JSON value in the `structuredContent` field of a result. This can be any JSON value (object, array, string, number, boolean, or null) that conforms to the tool's `outputSchema` if one is defined.
+
+For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.
+
+
+
+`structuredContent` is server-produced result data and is unrelated to LLM
+"structured outputs" (schema-constrained model generation).
+
+
+
+#### Output Schema
+
+Tools may also provide an output schema for validation of structured results.
+If an output schema is provided:
+
+- Servers **MUST** provide structured results that conform to this schema.
+- Clients **SHOULD** validate structured results against this schema.
+
+Example tool with output schema:
+
+```json
+{
+ "name": "get_weather_data",
+ "title": "Weather Data Retriever",
+ "description": "Get current weather data for a location",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string",
+ "description": "City name or zip code"
+ }
+ },
+ "required": ["location"]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "temperature": {
+ "type": "number",
+ "description": "Temperature in celsius"
+ },
+ "conditions": {
+ "type": "string",
+ "description": "Weather conditions description"
+ },
+ "humidity": {
+ "type": "number",
+ "description": "Humidity percentage"
+ }
+ },
+ "required": ["temperature", "conditions", "humidity"]
+ }
+}
+```
+
+Example valid response for this tool:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 5,
+ "result": {
+ "resultType": "complete",
+ "content": [
+ {
+ "type": "text",
+ "text": "{\"temperature\": 22.5, \"conditions\": \"Partly cloudy\", \"humidity\": 65}"
+ }
+ ],
+ "structuredContent": {
+ "temperature": 22.5,
+ "conditions": "Partly cloudy",
+ "humidity": 65
+ }
+ }
+}
+```
+
+Example tool with array output schema:
+
+```json
+{
+ "name": "list_users",
+ "title": "User List",
+ "description": "Returns a list of all users",
+ "inputSchema": {
+ "type": "object",
+ "properties": {}
+ },
+ "outputSchema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "name": { "type": "string" },
+ "email": { "type": "string" }
+ },
+ "required": ["id", "name", "email"]
+ }
+ }
+}
+```
+
+Example valid response for a tool with array output:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 6,
+ "result": {
+ "resultType": "complete",
+ "content": [
+ {
+ "type": "text",
+ "text": "Found 2 users: Alice (alice@example.com) and Bob (bob@example.com)."
+ }
+ ],
+ "structuredContent": [
+ { "id": "1", "name": "Alice", "email": "alice@example.com" },
+ { "id": "2", "name": "Bob", "email": "bob@example.com" }
+ ]
+ }
+}
+```
+
+Providing an output schema helps clients and LLMs understand and properly handle structured tool outputs by:
+
+- Enabling strict schema validation of responses
+- Providing type information for better integration with programming languages
+- Guiding clients and LLMs to properly parse and utilize the returned data
+- Supporting better documentation and developer experience
+
+### Schema Examples
+
+#### Tool with default 2020-12 schema:
+
+```json
+{
+ "name": "calculate_sum",
+ "description": "Add two numbers",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "a": { "type": "number" },
+ "b": { "type": "number" }
+ },
+ "required": ["a", "b"]
+ }
+}
+```
+
+#### Tool with explicit draft-07 schema:
+
+```json
+{
+ "name": "calculate_sum",
+ "description": "Add two numbers",
+ "inputSchema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {
+ "a": { "type": "number" },
+ "b": { "type": "number" }
+ },
+ "required": ["a", "b"]
+ }
+}
+```
+
+#### Tool with no parameters:
+
+```json
+{
+ "name": "get_current_time",
+ "description": "Returns the current server time",
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": false
+ }
+}
+```
+
+## Stateful Tools
+
+
+
+This section is non-normative guidance for tool design. The protocol has no
+concept of a state handle; from the wire's perspective a handle is an ordinary
+string in a tool result and an ordinary argument to subsequent tool calls.
+
+
+
+MCP has no protocol-level session, so a server cannot rely on implicit
+per-connection state to relate one tool call to the next. Servers that need to
+maintain state across calls — a shopping cart, an open browser context, a
+database transaction — should do so by returning an explicit handle from a
+creation tool and accepting that handle as an argument on subsequent calls.
+
+For example, a server that manages a shopping cart might expose:
+
+```jsonc
+// → tools/call
+{ "name": "create_basket", "arguments": {} }
+
+// ← result
+{
+ "content": [{ "type": "text", "text": "Created basket bsk_a1b2c3" }],
+ "structuredContent": { "basket_id": "bsk_a1b2c3" }
+}
+
+// → tools/call
+{
+ "name": "add_item",
+ "arguments": { "basket_id": "bsk_a1b2c3", "sku": "..." }
+}
+```
+
+The model is responsible for carrying `basket_id` forward; the server stores
+the cart contents under that key and looks them up on each call.
+
+When designing handles, servers should consider:
+
+- **Authorization.** For authenticated servers, a handle is a name, not a
+ capability. The server should validate the caller's authorization against the
+ handle on every call. For unauthenticated servers, where the handle is
+ necessarily a bearer token, it should be generated with sufficient entropy
+ (e.g., a UUIDv4) and given a bounded lifetime.
+- **Opacity.** Handles that encode internal structure invite parsing or
+ guessing; opaque identifiers do not.
+- **Lifetime.** Because handles outlive any single connection, the server's
+ retention policy should be stated in the creation tool's description (e.g.,
+ "baskets expire after 24 hours of inactivity") so the model can see it when
+ deciding to create state.
+- **Expiry errors.** A call against an expired or unknown handle should return
+ a tool execution error that says so, so the model can recover by creating a
+ new one.
+
+## Error Handling
+
+Tools use two error reporting mechanisms:
+
+1. **Protocol Errors** indicate issues with the request structure itself that models are less likely to be able to fix:
+ - Unknown tool
+ - Malformed requests (requests that fail to satisfy [CallToolRequest schema](/specification/2026-07-28/schema#calltoolrequest))
+ - Server errors
+
+ They are returned as standard JSON-RPC errors:
+
+ ```json
+ {
+ "jsonrpc": "2.0",
+ "id": 3,
+ "error": {
+ "code": -32602,
+ "message": "Unknown tool: invalid_tool_name"
+ }
+ }
+ ```
+
+2. **Tool Execution Errors** contain actionable feedback that language models can use to self-correct and retry with adjusted parameters:
+ - API failures
+ - Input validation errors (e.g., date in wrong format, value out of range)
+ - Business logic errors
+
+ They are reported in tool results with `isError: true`:
+
+ ```json
+ {
+ "jsonrpc": "2.0",
+ "id": 4,
+ "result": {
+ "resultType": "complete",
+ "content": [
+ {
+ "type": "text",
+ "text": "Invalid departure date: must be in the future. Current date is 08/08/2025."
+ }
+ ],
+ "isError": true
+ }
+ }
+ ```
+
+Clients **MAY** provide protocol errors to language models, though these are less likely to result in successful recovery.
+Clients **SHOULD** provide tool execution errors to language models to enable self-correction.
+
+## Security Considerations
+
+1. Servers **MUST**:
+ - Validate all tool inputs
+ - Implement proper access controls
+ - Rate limit tool invocations
+ - Sanitize tool outputs
+
+2. Clients **SHOULD**:
+ - Prompt for user confirmation on sensitive operations
+ - Show tool inputs to the user before calling the server, to avoid malicious or
+ accidental data exfiltration
+ - Validate tool results before passing to LLM
+ - Follow the [`$ref` resolution requirements](/specification/2026-07-28/basic/index#ref-resolution)
+ when validating tool inputs and outputs against `inputSchema` and `outputSchema`
+ - Implement timeouts for tool calls
+ - Log tool usage for audit purposes
diff --git a/spec/transports-stdio.md b/spec/transports-stdio.md
new file mode 100644
index 00000000..b03ac100
--- /dev/null
+++ b/spec/transports-stdio.md
@@ -0,0 +1,154 @@
+---
+title: stdio
+---
+
+
+
+In the **stdio** transport, the client launches the MCP server as a subprocess.
+The two ends communicate over the subprocess's standard streams:
+
+- The server reads JSON-RPC messages from `stdin` and writes JSON-RPC messages to
+ `stdout`.
+- Each message is a single JSON-RPC request, notification, or response.
+- Messages are delimited by newlines, and **MUST NOT** contain embedded newlines.
+- The server **MAY** write UTF-8 strings to `stderr` for any logging purposes
+ including informational, debug, and error messages.
+- The client **MAY** capture, forward, or ignore the server's `stderr` output and
+ **SHOULD NOT** assume `stderr` output indicates error conditions.
+- The server **MUST NOT** write anything to its `stdout` that is not a valid MCP
+ message.
+- The client **MUST NOT** write anything to the server's `stdin` that is not a
+ valid MCP message.
+
+Standard streams are the canonical channel, but nothing in this binding
+depends on them except the process lifecycle. The wire format (one
+newline-delimited JSON-RPC message per line over a reliable bidirectional
+byte stream) works unchanged over Unix domain sockets, TCP connections, or
+any similar channel.
+[Custom transports](/specification/2026-07-28/basic/transports#custom-transports)
+built on such streams **SHOULD** reuse this framing and the message rules on
+this page; only the subprocess-specific aspects (launch, `stderr`, shutdown
+by closing the stream, process restart) need channel-specific equivalents.
+
+## Sending Messages
+
+The client sends messages by writing JSON-RPC _requests_ and _notifications_
+to the server's `stdin`, one message per line. The client **MUST NOT** write
+JSON-RPC _responses_.
+
+## Receiving Messages
+
+The client reads server messages from `stdout`, one message per line. All
+messages share this single channel; there are no per-request streams.
+
+The server writes three kinds of messages:
+
+1. _Responses_ to client requests, correlated by JSON-RPC `id`.
+2. _Notifications_ that relate to an in-flight request, such as
+ `notifications/progress` and `notifications/message`.
+3. _Notifications_ delivered for an active
+ [`subscriptions/listen`][subscriptions-listen] request. Clients **MUST**
+ correlate these using the `io.modelcontextprotocol/subscriptionId` field
+ in `_meta`; see
+ [`SubscriptionsListenRequest`][subscriptions-listen-request].
+
+The server **MUST NOT** write JSON-RPC _requests_ to `stdout`.
+Server-to-client interactions are carried in
+[`InputRequiredResult`][mrtr-input-required] replies; see
+[Multi Round-Trip Requests][mrtr].
+
+[mrtr]: /specification/2026-07-28/basic/patterns/mrtr
+[mrtr-input-required]: /specification/2026-07-28/basic/patterns/mrtr#inputrequiredresult
+[subscriptions-listen]: /specification/2026-07-28/basic/patterns/subscriptions
+[subscriptions-listen-request]: /specification/2026-07-28/schema#subscriptionslistenrequest
+
+## Request Metadata
+
+All request metadata for the stdio transport is carried inline in the
+JSON-RPC message body. The protocol version, per-request capabilities, and
+optional client identity live in
+[`_meta.io.modelcontextprotocol/*`][meta-fields];
+the method name and arguments live where JSON-RPC puts them. There is no
+header layer.
+
+[meta-fields]: /specification/2026-07-28/basic/index#meta
+
+## Cancellation
+
+To cancel an in-flight request, the client **MUST** send a
+`notifications/cancelled` notification referencing the request's ID. Because
+stdio is a single shared bidirectional channel, there is no per-request stream
+to close. Servers **SHOULD** stop work on a cancelled request as soon as
+practical and **MUST NOT** send any further messages for it. See
+[Cancellation][cancellation] for the full rules.
+
+[cancellation]: /specification/2026-07-28/basic/patterns/cancellation
+
+## Shutdown
+
+The client **SHOULD** initiate shutdown by:
+
+1. Closing the input stream to the child process (the server).
+2. Waiting for the server to exit.
+3. If the server does not exit within a reasonable time, forcibly terminating
+ the process using the mechanism appropriate for the operating system.
+
+On POSIX systems, forced termination typically escalates from
+[`SIGTERM`][sigterm]
+to `SIGKILL`. On Windows, where POSIX signals are not available, clients can
+use [`TerminateProcess`][terminateprocess]
+or [Job Objects][job-objects].
+
+Servers **SHOULD** exit promptly when their standard input is closed or reads
+return end-of-file. This is the primary graceful-shutdown signal and the only
+portable one, so honoring it reduces the need for forced termination.
+
+The server **MAY** initiate shutdown by closing its output stream to the
+client and exiting.
+
+## Unexpected Termination
+
+If the server process exits unexpectedly, the client **SHOULD** restart it.
+Because the protocol is stateless, any in-flight requests are simply lost and
+the client can retry them against the fresh process. Active
+[`subscriptions/listen`][subscriptions-listen] streams must also be
+re-established after restart.
+
+[sigterm]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/signal.h.html
+[terminateprocess]: https://learn.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-terminateprocess
+[job-objects]: https://learn.microsoft.com/windows/win32/procthread/job-objects
+
+## Backward Compatibility
+
+A client that supports both modern (per-request-metadata) MCP versions and a
+legacy version that requires an `initialize` handshake **SHOULD** probe with
+[`server/discover`][server-discover] before sending any other request,
+setting its preferred modern version in `_meta`. The probe has three
+possible outcomes:
+
+- The server returns a `DiscoverResult`: the server is modern. Select a
+ mutually supported version from `supportedVersions` and continue.
+- The server returns a recognized modern JSON-RPC error such as
+ [`UnsupportedProtocolVersionError`][unsupported-version]: the server is
+ modern but does not support the requested version. Use one of the versions
+ in its advertised `supported` list. Do **not** fall back to `initialize`.
+- The server returns any other error, or does not respond within a
+ reasonable timeout: the server is legacy. Fall back to the `initialize`
+ handshake.
+
+The fallback **MUST NOT** be keyed to one specific error code: legacy servers
+respond to unknown pre-`initialize` requests with implementation-defined
+errors (commonly `-32601` or `-32602`) or not at all.
+
+A client that only supports modern versions does not need to probe, but
+probing is still **RECOMMENDED**: some legacy servers do not validate that a
+request arrives after `initialize` and would process an era-ambiguous method
+(such as `tools/call`) under legacy semantics. Probing yields a
+deterministic failure instead.
+
+See [Versioning: Backward Compatibility][lifecycle-compat] for the era model
+and a compatibility matrix for implementors.
+
+[server-discover]: /specification/2026-07-28/schema#discoverrequest
+[unsupported-version]: /specification/2026-07-28/schema#unsupportedprotocolversionerror
+[lifecycle-compat]: /specification/2026-07-28/basic/versioning#backward-compatibility-with-initialization-based-versions
diff --git a/spec/transports-streamable-http.md b/spec/transports-streamable-http.md
new file mode 100644
index 00000000..7b9813d6
--- /dev/null
+++ b/spec/transports-streamable-http.md
@@ -0,0 +1,739 @@
+---
+title: Streamable HTTP
+---
+
+
+
+
+
+Streamable HTTP was introduced in protocol version 2025-03-26 as a replacement
+for the [HTTP+SSE transport][http-sse] from protocol version 2024-11-05.
+
+
+
+
+
+Revision 2026-07-28 changed the behavior of Streamable HTTP. Clients must
+ensure they handle backwards compatibility correctly. Changes included:
+
+- Removal of the GET stream endpoint.
+- Removal of protocol-level sessions.
+
+See the [changelog](/specification/2026-07-28/changelog) and
+[Backward Compatibility](#backward-compatibility) below.
+
+
+
+In the **Streamable HTTP** transport, the server operates as an independent
+process that can handle multiple client connections. At a glance:
+
+- The server exposes a single HTTP endpoint (the **MCP endpoint**) that
+ accepts POST.
+- The client sends every JSON-RPC request or notification as its own HTTP
+ POST.
+- The server answers each request with either a single JSON object or a
+ [Server-Sent Events][sse] (SSE) stream scoped to that request, carrying
+ request-related notifications followed by the final response.
+- Server-to-client interactions (sampling, elicitation, roots) are embedded
+ in results as input requests per
+ [Multi Round-Trip Requests (MRTR)][mrtr] ([SEP-2322][sep-2322]).
+- Long-lived change notifications (such as list changes and resource updates)
+ are delivered on the response stream of a
+ [`subscriptions/listen`][subscriptions-listen] request.
+
+See [Message Flow](#message-flow) for sequence diagrams of these
+interactions.
+
+The server **MUST** provide a single HTTP endpoint path (hereafter referred to
+as the **MCP endpoint**) that supports POST. For example, this could be a URL
+like `https://example.com/mcp`.
+
+[http-sse]: /specification/2024-11-05/basic/transports#http-with-sse
+[sse]: https://en.wikipedia.org/wiki/Server-sent_events
+
+## Security & Endpoint
+
+When implementing Streamable HTTP transport:
+
+1. Servers **MUST** validate the `Origin` header on all incoming connections
+ to prevent DNS rebinding attacks.
+ - If the `Origin` header is present and invalid, servers **MUST** respond
+ with HTTP 403 Forbidden. The HTTP response body **MAY** comprise a
+ JSON-RPC _error response_ that has no `id`.
+2. When running locally, servers **SHOULD** bind only to localhost
+ (127.0.0.1) rather than all network interfaces (0.0.0.0).
+3. Servers **SHOULD** implement proper authentication for all connections.
+
+Without these protections, attackers could use DNS rebinding to interact with
+local MCP servers from remote websites.
+
+## Sending Messages
+
+Every JSON-RPC message sent from the client **MUST** be a new HTTP POST
+request to the MCP endpoint.
+
+1. The client **MUST** use HTTP POST to send JSON-RPC messages.
+2. The client **MUST** include an `Accept` header listing both
+ `application/json` and `text/event-stream` as supported content types.
+3. The client **MUST** include the [request metadata headers](#request-metadata)
+ on each POST request.
+4. The body of the HTTP POST **MUST** be a single JSON-RPC _request_ or
+ _notification_. The client **MUST NOT** send JSON-RPC _responses_.
+5. If the body is a JSON-RPC _notification_:
+ - If the server accepts it, the server **MUST** return HTTP status code
+ `202 Accepted` with no body.
+ - If the server cannot accept it, it **MUST** return an HTTP error status
+ code (e.g., `400 Bad Request`). The HTTP response body **MAY** comprise
+ a JSON-RPC _error response_ that has no `id`.
+6. If the body is a JSON-RPC _request_, the server **MUST** return either
+ `Content-Type: application/json` (a single JSON object) or
+ `Content-Type: text/event-stream` (an SSE response stream). The client
+ **MUST** support both.
+
+
+
+This revision of the core protocol defines no client-to-server
+_notifications_ over Streamable HTTP. The only client-sent notification in
+the core protocol, `notifications/cancelled`, is used only on the
+[stdio](/specification/2026-07-28/basic/transports/stdio) transport; on
+Streamable HTTP, closing the SSE response stream is itself the cancellation
+signal and no `notifications/cancelled` message is expected (see
+[Cancellation][cancellation]). The notification rules above describe the
+transport mechanics for a notification POST; header requirements for
+notification POSTs are not defined by this revision.
+
+
+
+## Receiving Messages
+
+When the server returns an SSE response stream
+(`Content-Type: text/event-stream`):
+
+- The server **MAY** send JSON-RPC _notifications_ — for example,
+ [`notifications/progress`][notifications-progress]
+ or [`notifications/message`][notifications-message] —
+ before the final response. These notifications **MUST** relate to the
+ originating client request.
+- The server **MUST NOT** send independent JSON-RPC _requests_ on this stream.
+ Server-to-client interactions (sampling, elicitation, list-roots) are
+ embedded as input requests inside an
+ [`InputRequiredResult`][input-required-result] per
+ [MRTR][mrtr] ([SEP-2322][sep-2322]), not delivered as separate requests on
+ this or any other stream. This is a change from Streamable HTTP in protocol
+ versions `2025-03-26` through `2025-11-25`, where servers could send such
+ requests on SSE streams.
+- The final JSON-RPC _response_ **SHOULD** terminate the stream.
+
+Long-lived notification streams are obtained by sending a
+[`subscriptions/listen`][subscriptions-listen]
+request. The server's response is itself an SSE stream that stays open and
+delivers the change notifications the client opted in to (such as
+`notifications/tools/list_changed` or `notifications/resources/updated`).
+Request-scoped notifications like `notifications/progress` and
+`notifications/message` are **not** delivered on the listen stream — they
+flow only on the response stream of the request they relate to.
+
+When initiating an SSE stream, servers **SHOULD** include the
+`X-Accel-Buffering: no` header in the HTTP response. This instructs reverse
+proxies (such as nginx) to disable response buffering, ensuring that SSE
+events are delivered to clients immediately rather than being held in a
+buffer. Without this header, proxies may accumulate messages before sending
+them to the client, introducing unwanted latency and potentially breaking the
+real-time nature of SSE communication.
+
+
+
+For long-lived streams — in particular the
+[`subscriptions/listen`][subscriptions-listen] response stream — servers are
+encouraged to periodically emit an SSE comment line (a line beginning with a
+colon, e.g. `:\r\n`) as a keep-alive. This keeps the connection from being
+closed by intermediaries or client idle timeouts during quiet periods when no
+notifications are flowing. Per the [SSE specification][sse], any line beginning
+with a colon is a comment that carries no event data; clients must ignore such
+lines and must not treat them as malformed input.
+
+
+
+Resumable SSE streams via `Last-Event-ID` are not supported.
+
+[notifications-progress]: /specification/2026-07-28/basic/patterns/progress
+[notifications-message]: /specification/2026-07-28/server/utilities/logging
+[input-required-result]: /specification/2026-07-28/schema#inputrequiredresult
+[mrtr]: /specification/2026-07-28/basic/patterns/mrtr
+[sep-2322]: /seps/2322-MRTR
+[subscriptions-listen]: /specification/2026-07-28/basic/patterns/subscriptions
+
+## Message Flow
+
+The following diagrams illustrate the message flows on a single MCP endpoint.
+
+**Requests and responses.** Each request is its own POST; the server chooses
+per request whether to respond with a single JSON object or an SSE stream:
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Server
+
+ note over Client,Server: Simple response
+ Client->>Server: POST tools/call (JSON-RPC request)
+ Server-->>Client: 200 OK, application/json JSON-RPC response
+
+ note over Client,Server: Streaming response
+ Client->>Server: POST tools/call (JSON-RPC request)
+ note over Server: Opens SSE stream scoped to this request
+ Server-->>Client: SSE: notifications/progress
+ Server-->>Client: SSE: notifications/progress
+ Server-->>Client: SSE: JSON-RPC response
+ note over Client,Server: Stream closes
+
+ note over Client,Server: Notification
+ Client->>Server: POST (JSON-RPC notification)
+ Server-->>Client: 202 Accepted
+```
+
+**Server-to-client interactions (MRTR).** When the server needs input from
+the client — sampling, elicitation, or roots — it does not send its own
+JSON-RPC request. It returns an
+[`InputRequiredResult`][input-required-result] containing `inputRequests`,
+and the client retries the original request with the matching
+`inputResponses` (see [Multi Round-Trip Requests][mrtr]):
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Server
+
+ Client->>Server: POST tools/call (id: 1)
+ note over Server: Needs user input or an LLM completion
+ Server-->>Client: InputRequiredResult (inputRequests: elicitation/create)
+ note over Client: Gathers the requested input
+ Client->>Server: POST tools/call (id: 2) (original params + inputResponses)
+ Server-->>Client: Final result
+```
+
+**Change notifications.** Clients that want server-initiated change
+notifications open a long-lived stream with
+[`subscriptions/listen`][subscriptions-listen]; the response stream stays
+open and carries only the notification types the client opted in to:
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Server
+
+ Client->>Server: POST subscriptions/listen (notification filter)
+ Server-->>Client: SSE: notifications/subscriptions/acknowledged
+ note over Client,Server: Stream stays open
+ Server-->>Client: SSE: notifications/tools/list_changed
+ Server-->>Client: SSE: notifications/resources/updated
+ note over Client,Server: Until the client or server closes the stream
+```
+
+## Cancellation
+
+Closing the SSE response stream **MUST** be treated by the server as
+cancellation of that request. Because each request has its own response
+stream, the transport-level disconnect is unambiguous. The server **SHOULD**
+stop work on the cancelled request as soon as practical and **MUST NOT** send
+any further messages for it. See
+[Cancellation][cancellation] for the full rules.
+
+[cancellation]: /specification/2026-07-28/basic/patterns/cancellation
+
+## Request Metadata
+
+The Streamable HTTP transport mirrors selected JSON-RPC body fields into HTTP
+headers so that intermediaries (load balancers, gateways, observability
+tooling) can route and inspect requests without parsing the body.
+
+### Protocol Version Header
+
+Every POST request to the MCP endpoint **MUST** include an
+`MCP-Protocol-Version` header.
+
+For example: `MCP-Protocol-Version: 2026-07-28`
+
+The header value **MUST** match the
+`io.modelcontextprotocol/protocolVersion` field carried in the request body's
+`_meta`. If the values do not match, the server **MUST** reject the request
+with `400 Bad Request` and a `HeaderMismatch` JSON-RPC error
+(see [Server Validation](#server-validation)).
+
+If the server does not implement the requested protocol version (whether the
+version is unknown to the server, or is a known version the server has chosen
+not to support), it **MUST** respond with `400 Bad Request` and an
+[`UnsupportedProtocolVersionError`][unsupported-version]
+listing its supported versions. See
+[Versioning: Protocol Version Negotiation][lifecycle-version]
+for the negotiation flow.
+
+If the server does not implement the requested RPC method, it **MUST** respond
+with `404 Not Found` and a JSON-RPC error with code `-32601`
+(`Method not found`). The JSON-RPC error body distinguishes this case from a
+`404` returned by a legacy [HTTP+SSE][http-sse] server that does not host the
+modern MCP endpoint (see [Backward Compatibility](#backward-compatibility)).
+
+A server that supports clients implementing protocol versions earlier than
+`2025-06-18` (which did not define the `MCP-Protocol-Version` header) **MAY**
+treat a request that omits the header as protocol version `2025-03-26`. A
+server that does not support such clients **MUST** reject a request without
+the header per [Server Validation](#server-validation).
+
+[unsupported-version]: /specification/2026-07-28/schema#unsupportedprotocolversionerror
+[lifecycle-version]: /specification/2026-07-28/basic/versioning#protocol-version-negotiation
+
+### Standard Request Headers
+
+| Header Name | Source Field | Required For |
+| ------------ | ----------------------------- | ------------------------------------------------------ |
+| `Mcp-Method` | `method` | All requests |
+| `Mcp-Name` | `params.name` or `params.uri` | `tools/call`, `resources/read`, `prompts/get` requests |
+
+These headers are **REQUIRED** for compliance.
+
+If the `Mcp-Name` source value cannot be safely represented as a plain ASCII
+header value, clients **MUST** encode it using the Base64 sentinel format
+described in [Value Encoding](#value-encoding).
+
+**`tools/call` request:**
+
+```http
+POST /mcp HTTP/1.1
+Content-Type: application/json
+MCP-Protocol-Version: 2026-07-28
+Mcp-Method: tools/call
+Mcp-Name: get_weather
+
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "tools/call",
+ "params": {
+ "name": "get_weather",
+ "arguments": {
+ "location": "Seattle, WA"
+ },
+ "_meta": {
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
+ "io.modelcontextprotocol/clientInfo": {
+ "name": "ExampleClient",
+ "version": "1.0.0"
+ },
+ "io.modelcontextprotocol/clientCapabilities": {}
+ }
+ }
+}
+```
+
+**`resources/read` request:**
+
+```http
+POST /mcp HTTP/1.1
+Content-Type: application/json
+MCP-Protocol-Version: 2026-07-28
+Mcp-Method: resources/read
+Mcp-Name: file:///projects/myapp/config.json
+
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "resources/read",
+ "params": {
+ "uri": "file:///projects/myapp/config.json",
+ "_meta": {
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
+ "io.modelcontextprotocol/clientInfo": {
+ "name": "ExampleClient",
+ "version": "1.0.0"
+ },
+ "io.modelcontextprotocol/clientCapabilities": {}
+ }
+ }
+}
+```
+
+### Custom Headers from Tool Parameters
+
+MCP servers **MAY** designate specific tool parameters to be mirrored into
+HTTP headers using an `x-mcp-header` extension property in the parameter's
+schema within the tool's `inputSchema`. See
+[Tool Definitions][tool-definitions] for
+details on how to annotate tool parameters.
+
+While the use of `x-mcp-header` is optional for servers, clients **MUST**
+support this feature. When a server's tool definition includes
+`x-mcp-header` annotations, conforming clients **MUST** mirror the
+designated parameter values into HTTP headers.
+
+[tool-definitions]: /specification/2026-07-28/server/tools#x-mcp-header
+
+#### Schema Extension
+
+The `x-mcp-header` property specifies the name portion used to construct
+the header name `Mcp-Param-{name}`.
+
+**Constraints on `x-mcp-header` values**:
+
+- **MUST NOT** be empty
+- **MUST** match HTTP field-name token syntax (`1*tchar`, [RFC 9110 Section 5.1](https://datatracker.ietf.org/doc/html/rfc9110#section-5.1))
+- **MUST NOT** contain control characters, including carriage return (CR, `\r`)
+ or line feed (LF, `\n`)
+- **MUST** be case-insensitively unique among all `x-mcp-header` values in
+ the `inputSchema`
+- **MUST** only be applied to parameters with primitive types (integer,
+ string, boolean). Parameters with type `number` are not permitted.
+ Integer values **MUST** be within the safe range for JavaScript
+ (−253+1 to 253−1)
+- **MUST** only be applied to properties that are _statically reachable_
+ from the schema root: reachable via a chain consisting solely of
+ `properties` keys. The chain **MUST NOT** pass through `items` (or any
+ other array keyword), composition keywords (`oneOf`, `anyOf`, `allOf`,
+ `not`), conditional keywords (`if`/`then`/`else`), or `$ref`. Nested
+ object properties are permitted as long as every step in the chain is a
+ `properties` key. An `x-mcp-header` annotation anywhere else makes the
+ annotation — and thus the tool definition — invalid.
+
+Header extraction is defined as reading the instance value at the exact
+property path of the annotated property (the chain of `properties` keys
+leading to it). If no value is present at that path in the call arguments,
+the header is omitted.
+
+Clients using the Streamable HTTP transport **MUST** reject tool definitions
+where any `x-mcp-header` value violates these constraints. Rejection means
+the client **MUST** exclude the invalid tool from the result of `tools/list`.
+Clients **SHOULD** log a warning when rejecting a tool definition, including
+the tool name and the reason for rejection. This ensures that a single
+malformed tool definition does not prevent other valid tools from being used.
+Clients using other transports (e.g., stdio) **MAY** ignore `x-mcp-header`
+annotations entirely.
+
+**Example tool definition:**
+
+```json
+{
+ "name": "execute_sql",
+ "description": "Execute SQL on Google Cloud Spanner",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "region": {
+ "type": "string",
+ "description": "The region to execute the query in",
+ "x-mcp-header": "Region"
+ },
+ "query": {
+ "type": "string",
+ "description": "The SQL query to execute"
+ }
+ },
+ "required": ["region", "query"]
+ }
+}
+```
+
+**Resulting HTTP request:**
+
+```http
+POST /mcp HTTP/1.1
+Content-Type: application/json
+MCP-Protocol-Version: 2026-07-28
+Mcp-Method: tools/call
+Mcp-Name: execute_sql
+Mcp-Param-Region: us-west1
+
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "tools/call",
+ "params": {
+ "_meta": {
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
+ "io.modelcontextprotocol/clientInfo": {
+ "name": "ExampleClient",
+ "version": "1.0.0"
+ },
+ "io.modelcontextprotocol/clientCapabilities": {}
+ },
+ "name": "execute_sql",
+ "arguments": {
+ "region": "us-west1",
+ "query": "SELECT * FROM users"
+ }
+ }
+}
+```
+
+#### Value Encoding
+
+Clients **MUST** encode parameter values before including them in HTTP
+headers to ensure safe transmission and prevent injection attacks.
+
+**Type conversion**: Convert the parameter value to its string representation:
+
+- `string`: Use the value as-is
+- `integer`: Convert to decimal string representation (e.g., `42`, `-7`)
+- `boolean`: Convert to lowercase `"true"` or `"false"`
+
+Per [RFC 9110][rfc9110-values],
+HTTP header field values must consist of visible ASCII characters
+(0x21-0x7E), space (0x20), and horizontal tab (0x09). When a value cannot
+be safely represented as a plain ASCII header value (e.g., it contains
+non-ASCII characters, control characters, or has leading/trailing
+whitespace), clients **MUST** use Base64 encoding of the UTF-8
+representation with the following format:
+
+```text
+Mcp-Param-{Name}: =?base64?{Base64EncodedValue}?=
+```
+
+The same encoding rule applies to the `Mcp-Name` header value. Tool and
+prompt names are only **SHOULD**-constrained to header-safe characters, so a
+name (or resource URI) outside the safe set is carried as:
+
+```text
+Mcp-Name: =?base64?{Base64EncodedValue}?=
+```
+
+The prefix `=?base64?` and suffix `?=` indicate that the value is
+Base64-encoded. These markers are case-sensitive and **MUST** appear exactly
+as shown (lowercase). Servers and intermediaries that need to inspect these
+values **MUST** decode them accordingly. In particular, servers **MUST**
+decode an encoded `Mcp-Name` or `Mcp-Param-{Name}` value before comparing it
+to the corresponding request body value during
+[Server Validation](#server-validation).
+
+To avoid ambiguity, clients **MUST** also Base64-encode any plain-ASCII
+value that matches the sentinel pattern (i.e., starts with `=?base64?`
+and ends with `?=`).
+
+**Encoding examples:**
+
+| Original Value | Reason | Encoded Header Value |
+| ---------------------- | ------------------------ | ----------------------------------------------------- |
+| `"us-west1"` | Plain ASCII | `Mcp-Param-Region: us-west1` |
+| `"Hello, 世界"` | Contains non-ASCII | `Mcp-Param-Greeting: =?base64?SGVsbG8sIOS4lueVjA==?=` |
+| `" padded "` | Leading/trailing spaces | `Mcp-Param-Text: =?base64?IHBhZGRlZCA=?=` |
+| `"line1\nline2"` | Contains newline | `Mcp-Param-Text: =?base64?bGluZTEKbGluZTI=?=` |
+| `"=?base64?literal?="` | Matches sentinel pattern | `Mcp-Param-Val: =?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=` |
+
+[rfc9110-values]: https://datatracker.ietf.org/doc/html/rfc9110#name-field-values
+
+#### Client Behavior
+
+When constructing a `tools/call` request via HTTP transport, the client
+**MUST**:
+
+1. Extract the values for any standard headers from the request body (e.g.,
+ `method`, `params.name`, `params.uri`).
+2. Append the `Mcp-Method` header and, if applicable, `Mcp-Name` header to
+ the request.
+3. Inspect the tool's `inputSchema` for properties marked with
+ `x-mcp-header` and extract the value at each annotated property's exact
+ property path, omitting the header when no value is present (see
+ [Schema Extension](#schema-extension)).
+4. Encode the values according to the [Value Encoding](#value-encoding)
+ rules.
+5. Append a `Mcp-Param-{Name}: {Value}` header to the request.
+
+If the server rejects a request with a
+[`HeaderMismatch`](#server-validation) error because required
+`Mcp-Param-*` headers are missing or do not match the body, the client
+**SHOULD** call `tools/list` to check for changes to the tool's
+`inputSchema`, then retry the original request with the appropriate
+headers.
+
+#### Server Behavior for Custom Headers
+
+Intermediate servers that do not recognize an `Mcp-Param-{Name}` header
+**MUST** forward it and otherwise ignore it, as required by the
+[HTTP Semantics RFC][http-semantics].
+
+Servers **MUST** reject requests with a recognized `Mcp-Param-{Name}` header
+that contains invalid characters (see [Value Encoding](#value-encoding)).
+
+Any server that processes the message body **MUST** validate that encoded
+header values, after decoding if Base64-encoded, match the corresponding
+values in the request body. Servers **MUST** reject requests with a
+`400 Bad Request` HTTP status and JSON-RPC error code `-32020`
+(`HeaderMismatch`) if any validation fails.
+
+| Scenario | Client Behavior | Server Behavior |
+| ---------------------------------------- | ------------------------------ | ---------------------------------------- |
+| Parameter value provided | Client MUST include the header | Server MUST validate header matches body |
+| Parameter value is `null` | Client MUST omit the header | Server MUST NOT expect the header |
+| Parameter not in arguments | Client MUST omit the header | Server MUST NOT expect the header |
+| Client omits header but value is in body | Non-conforming client | Server MUST reject the request |
+
+[http-semantics]: https://www.rfc-editor.org/rfc/rfc9110.html#name-field-names
+
+### Case Sensitivity
+
+Header names (called "field names" in
+[RFC 9110][rfc9110-names])
+are case-insensitive. Clients and servers **MUST** use case-insensitive
+comparisons for header names. Header _values_ (such as method names) are
+case-sensitive.
+
+[rfc9110-names]: https://datatracker.ietf.org/doc/html/rfc9110#name-field-names
+
+### Server Validation
+
+Servers that process the request body **MUST** reject requests where the
+values specified in the headers do not match the corresponding values in the
+request body. This prevents potential security vulnerabilities when
+different components in the network rely on different sources of truth
+(e.g., a load balancer routing on the header value while the MCP server
+executes based on the body value).
+
+
+
+When validating integer parameter values, servers **SHOULD** compare the
+header value and the body value numerically rather than as strings (e.g.,
+`42.0` and `42` are considered equal).
+
+
+
+When rejecting a request due to header validation failure, servers **MUST**
+return HTTP status `400 Bad Request` and **MUST** include a JSON-RPC error
+response using the following error code:
+
+| Code | Name | Description |
+| -------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
+| `-32020` | [`HeaderMismatch`](/specification/2026-07-28/schema#headermismatcherror) | The HTTP headers do not match the corresponding values in the request body, or required headers are missing/malformed. |
+
+This error code is allocated from the sub-range the MCP specification
+reserves for protocol-defined errors. See
+[Error Codes](/specification/2026-07-28/basic/index#error-codes).
+
+**Example error response:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "error": {
+ "code": -32020,
+ "message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'"
+ }
+}
+```
+
+Validation failure conditions include:
+
+- A required standard header (`MCP-Protocol-Version`, `Mcp-Method`,
+ `Mcp-Name`) is missing.
+- A header value does not match the corresponding request body value.
+ For headers that permit the Base64 sentinel encoding (`Mcp-Name` and
+ `Mcp-Param-{Name}`), servers **MUST** decode encoded values (see
+ [Value Encoding](#value-encoding)) before comparing them to the body value.
+- A header value contains invalid characters.
+
+
+
+Intermediaries **MUST** return an appropriate HTTP error status (e.g.,
+`400 Bad Request`) for validation failures but are not required to return
+a JSON-RPC error response.
+
+
+
+
+
+Intermediaries that enforce policy based on mirrored headers (e.g., routing
+or rate-limiting by tenant) **SHOULD** verify that the `MCP-Protocol-Version`
+header indicates a version that requires header–body validation. If the
+version is older or the header is absent, the intermediary **SHOULD** reject
+the request rather than trusting unvalidated header values.
+
+
+
+## Backward Compatibility
+
+A client that supports both modern (per-request-metadata) MCP versions and a
+legacy version that requires an `initialize` handshake **MAY** detect which
+era the server implements by attempting a modern request first. On
+`400 Bad Request`, the client **SHOULD** inspect the response body before
+falling back: modern servers also use `400` for
+[`UnsupportedProtocolVersionError`][unsupported-version],
+`MissingRequiredClientCapabilityError`, and header-validation failures.
+
+- If the body contains a recognized modern JSON-RPC error, the server speaks
+ a modern version of MCP — retry using the advertised `supported` versions
+ or correct the request, rather than falling back.
+- If the body is empty or is not a recognized modern JSON-RPC error, fall
+ back to `initialize` and continue with the legacy version for subsequent
+ requests.
+
+See [Versioning: Backward Compatibility][lifecycle-compat] for the era model
+and a compatibility matrix for implementors.
+
+### Earlier Streamable HTTP Revisions
+
+Protocol versions `2025-03-26` through [`2025-11-25`](/specification/2025-11-25/basic/transports)
+also used the Streamable HTTP transport, but in a different shape: servers could assign a session via
+the `Mcp-Session-Id` header (terminated with HTTP DELETE), clients could open
+a standalone SSE stream with HTTP GET to receive server-initiated messages,
+servers could send JSON-RPC _requests_ on SSE streams, and streams were
+resumable via `Last-Event-ID`. None of these mechanisms are part of this
+revision.
+
+A server that supports only this revision and receives such traffic from an
+older client **SHOULD** respond as follows:
+
+- HTTP GET or DELETE to the MCP endpoint: respond with
+ `405 Method Not Allowed`.
+- An `Mcp-Session-Id` header on a request: ignore it, and do not mint or echo
+ session IDs.
+- A `Last-Event-ID` header: ignore it; streams are not resumable.
+
+Servers and clients that need to interoperate with counterparts speaking
+those protocol versions implement the behavior described in the corresponding
+revision (for example,
+[2025-11-25: Streamable HTTP](/specification/2025-11-25/basic/transports#streamable-http)),
+in addition to the version-negotiation fallback described above.
+
+### HTTP+SSE Transport (2024-11-05)
+
+
+ **Deprecated**: The [HTTP+SSE transport][http-sse] from protocol version
+ 2024-11-05 has been deprecated since protocol version `2025-03-26` and is
+ classified as Deprecated under the [feature lifecycle
+ policy](/community/feature-lifecycle#deprecating-a-feature)
+ ([SEP-2596](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2596)).
+ New implementations **SHOULD NOT** adopt it; existing implementations
+ **SHOULD** migrate to [Streamable
+ HTTP](/specification/2026-07-28/basic/transports/streamable-http). It is
+ eligible for removal in a future revision; see the [deprecated features
+ registry](/specification/2026-07-28/deprecated).
+
+
+Clients and servers can maintain backward compatibility with the
+deprecated [HTTP+SSE transport][http-sse] (from
+protocol version 2024-11-05) as follows:
+
+**Servers** wanting to support older clients should:
+
+- Continue to host both the SSE and POST endpoints of the old transport,
+ alongside the new "MCP endpoint" defined for the Streamable HTTP transport.
+ - It is also possible to combine the old POST endpoint and the new MCP
+ endpoint, but this may introduce unneeded complexity.
+
+**Clients** wanting to support older servers should:
+
+1. Accept an MCP server URL from the user, which may point to either a server
+ using the old transport or the new transport.
+2. Attempt to POST a request to the server URL, with an `Accept` header as
+ defined above:
+ - If it succeeds, the client can assume this is a server supporting the
+ new Streamable HTTP transport.
+ - If it fails with HTTP status code `400 Bad Request`, `404 Not Found`,
+ or `405 Method Not Allowed` **and** the response body is not a
+ recognized modern JSON-RPC error (a modern server returns one for
+ unsupported version, unknown method, or header-validation failure):
+ - Issue a GET request to the server URL, expecting that this will open
+ an SSE stream and return an `endpoint` event as the first event.
+ - When the `endpoint` event arrives, the client can assume this is a
+ server running the old HTTP+SSE transport, and should use that
+ transport for all subsequent communication.
+
+[lifecycle-compat]: /specification/2026-07-28/basic/versioning#backward-compatibility-with-initialization-based-versions
diff --git a/spec/transports.md b/spec/transports.md
deleted file mode 100644
index 97b9de42..00000000
--- a/spec/transports.md
+++ /dev/null
@@ -1,320 +0,0 @@
----
-title: Transports
----
-
-
-
-MCP uses JSON-RPC to encode messages. JSON-RPC messages **MUST** be UTF-8 encoded.
-
-The protocol currently defines two standard transport mechanisms for client-server
-communication:
-
-1. [stdio](#stdio), communication over standard in and standard out
-2. [Streamable HTTP](#streamable-http)
-
-Clients **SHOULD** support stdio whenever possible.
-
-It is also possible for clients and servers to implement
-[custom transports](#custom-transports) in a pluggable fashion.
-
-## stdio
-
-In the **stdio** transport:
-
-- The client launches the MCP server as a subprocess.
-- The server reads JSON-RPC messages from its standard input (`stdin`) and sends messages
- to its standard output (`stdout`).
-- Messages are individual JSON-RPC requests, notifications, or responses.
-- Messages are delimited by newlines, and **MUST NOT** contain embedded newlines.
-- The server **MAY** write UTF-8 strings to its standard error (`stderr`) for any
- logging purposes including informational, debug, and error messages.
-- The client **MAY** capture, forward, or ignore the server's `stderr` output
- and **SHOULD NOT** assume `stderr` output indicates error conditions.
-- The server **MUST NOT** write anything to its `stdout` that is not a valid MCP message.
-- The client **MUST NOT** write anything to the server's `stdin` that is not a valid MCP
- message.
-
-```mermaid
-sequenceDiagram
- participant Client
- participant Server Process
-
- Client->>+Server Process: Launch subprocess
- loop Message Exchange
- Client->>Server Process: Write to stdin
- Server Process->>Client: Write to stdout
- Server Process--)Client: Optional logs on stderr
- end
- Client->>Server Process: Close stdin, terminate subprocess
- deactivate Server Process
-```
-
-## Streamable HTTP
-
-
-
-This replaces the [HTTP+SSE
-transport](/specification/2024-11-05/basic/transports#http-with-sse) from
-protocol version 2024-11-05. See the [backwards compatibility](#backwards-compatibility)
-guide below.
-
-
-
-In the **Streamable HTTP** transport, the server operates as an independent process that
-can handle multiple client connections. This transport uses HTTP POST and GET requests.
-Server can optionally make use of
-[Server-Sent Events](https://en.wikipedia.org/wiki/Server-sent_events) (SSE) to stream
-multiple server messages. This permits basic MCP servers, as well as more feature-rich
-servers supporting streaming and server-to-client notifications and requests.
-
-The server **MUST** provide a single HTTP endpoint path (hereafter referred to as the
-**MCP endpoint**) that supports both POST and GET methods. For example, this could be a
-URL like `https://example.com/mcp`.
-
-#### Security Warning
-
-When implementing Streamable HTTP transport:
-
-1. Servers **MUST** validate the `Origin` header on all incoming connections to prevent DNS rebinding attacks
- - If the `Origin` header is present and invalid, servers **MUST** respond with HTTP 403 Forbidden. The HTTP response
- body **MAY** comprise a JSON-RPC _error response_ that has no `id`
-2. When running locally, servers **SHOULD** bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)
-3. Servers **SHOULD** implement proper authentication for all connections
-
-Without these protections, attackers could use DNS rebinding to interact with local MCP servers from remote websites.
-
-### Sending Messages to the Server
-
-Every JSON-RPC message sent from the client **MUST** be a new HTTP POST request to the
-MCP endpoint.
-
-1. The client **MUST** use HTTP POST to send JSON-RPC messages to the MCP endpoint.
-2. The client **MUST** include an `Accept` header, listing both `application/json` and
- `text/event-stream` as supported content types.
-3. The body of the POST request **MUST** be a single JSON-RPC _request_, _notification_, or _response_.
-4. If the input is a JSON-RPC _response_ or _notification_:
- - If the server accepts the input, the server **MUST** return HTTP status code 202
- Accepted with no body.
- - If the server cannot accept the input, it **MUST** return an HTTP error status code
- (e.g., 400 Bad Request). The HTTP response body **MAY** comprise a JSON-RPC _error
- response_ that has no `id`.
-5. If the input is a JSON-RPC _request_, the server **MUST** either
- return `Content-Type: text/event-stream`, to initiate an SSE stream, or
- `Content-Type: application/json`, to return one JSON object. The client **MUST**
- support both these cases.
-6. If the server initiates an SSE stream:
- - The server **SHOULD** immediately send an SSE event consisting of an event
- ID and an empty `data` field in order to prime the client to reconnect
- (using that event ID as `Last-Event-ID`).
- - After the server has sent an SSE event with an event ID to the client, the
- server **MAY** close the _connection_ (without terminating the _SSE stream_)
- at any time in order to avoid holding a long-lived connection. The client
- **SHOULD** then "poll" the SSE stream by attempting to reconnect.
- - If the server does close the _connection_ prior to terminating the _SSE stream_,
- it **SHOULD** send an SSE event with a standard [`retry`](https://html.spec.whatwg.org/multipage/server-sent-events.html#:~:text=field%20name%20is%20%22retry%22) field before
- closing the connection. The client **MUST** respect the `retry` field,
- waiting the given number of milliseconds before attempting to reconnect.
- - The SSE stream **SHOULD** eventually include a JSON-RPC _response_ for the
- JSON-RPC _request_ sent in the POST body.
- - The server **MAY** send JSON-RPC _requests_ and _notifications_ before sending the
- JSON-RPC _response_. These messages **SHOULD** relate to the originating client
- _request_.
- - The server **MAY** terminate the SSE stream if the [session](#session-management)
- expires.
- - After the JSON-RPC _response_ has been sent, the server **SHOULD** terminate the
- SSE stream.
- - Disconnection **MAY** occur at any time (e.g., due to network conditions).
- Therefore:
- - Disconnection **SHOULD NOT** be interpreted as the client cancelling its request.
- - To cancel, the client **SHOULD** explicitly send an MCP `CancelledNotification`.
- - To avoid message loss due to disconnection, the server **MAY** make the stream
- [resumable](#resumability-and-redelivery).
-
-### Listening for Messages from the Server
-
-1. The client **MAY** issue an HTTP GET to the MCP endpoint. This can be used to open an
- SSE stream, allowing the server to communicate to the client, without the client first
- sending data via HTTP POST.
-2. The client **MUST** include an `Accept` header, listing `text/event-stream` as a
- supported content type.
-3. The server **MUST** either return `Content-Type: text/event-stream` in response to
- this HTTP GET, or else return HTTP 405 Method Not Allowed, indicating that the server
- does not offer an SSE stream at this endpoint.
-4. If the server initiates an SSE stream:
- - The server **MAY** send JSON-RPC _requests_ and _notifications_ on the stream.
- - These messages **SHOULD** be unrelated to any concurrently-running JSON-RPC
- _request_ from the client.
- - The server **MUST NOT** send a JSON-RPC _response_ on the stream **unless**
- [resuming](#resumability-and-redelivery) a stream associated with a previous client
- request.
- - The server **MAY** close the SSE stream at any time.
- - If the server closes the _connection_ without terminating the _stream_, it
- **SHOULD** follow the same polling behavior as described for POST requests:
- sending a `retry` field and allowing the client to reconnect.
- - The client **MAY** close the SSE stream at any time.
-
-### Multiple Connections
-
-1. The client **MAY** remain connected to multiple SSE streams simultaneously.
-2. The server **MUST** send each of its JSON-RPC messages on only one of the connected
- streams; that is, it **MUST NOT** broadcast the same message across multiple streams.
- - The risk of message loss **MAY** be mitigated by making the stream
- [resumable](#resumability-and-redelivery).
-
-### Resumability and Redelivery
-
-To support resuming broken connections, and redelivering messages that might otherwise be
-lost:
-
-1. Servers **MAY** attach an `id` field to their SSE events, as described in the
- [SSE standard](https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation).
- - If present, the ID **MUST** be globally unique across all streams within that
- [session](#session-management)—or all streams with that specific client, if session
- management is not in use.
- - Event IDs **SHOULD** encode sufficient information to identify the originating
- stream, enabling the server to correlate a `Last-Event-ID` to the correct stream.
-2. If the client wishes to resume after a disconnection (whether due to network failure
- or server-initiated closure), it **SHOULD** issue an HTTP GET to the MCP endpoint,
- and include the
- [`Last-Event-ID`](https://html.spec.whatwg.org/multipage/server-sent-events.html#the-last-event-id-header)
- header to indicate the last event ID it received.
- - The server **MAY** use this header to replay messages that would have been sent
- after the last event ID, _on the stream that was disconnected_, and to resume the
- stream from that point.
- - The server **MUST NOT** replay messages that would have been delivered on a
- different stream.
- - This mechanism applies regardless of how the original stream was initiated (via
- POST or GET). Resumption is always via HTTP GET with `Last-Event-ID`.
-
-In other words, these event IDs should be assigned by servers on a _per-stream_ basis, to
-act as a cursor within that particular stream.
-
-### Session Management
-
-An MCP "session" consists of logically related interactions between a client and a
-server, beginning with the [initialization phase](/specification/2025-11-25/basic/lifecycle). To support
-servers which want to establish stateful sessions:
-
-1. A server using the Streamable HTTP transport **MAY** assign a session ID at
- initialization time, by including it in an `MCP-Session-Id` header on the HTTP
- response containing the `InitializeResult`.
- - The session ID **SHOULD** be globally unique and cryptographically secure (e.g., a
- securely generated UUID, a JWT, or a cryptographic hash).
- - The session ID **MUST** only contain visible ASCII characters (ranging from 0x21 to
- 0x7E).
- - The client **MUST** handle the session ID in a secure manner, see [Session Hijacking mitigations](/specification/2025-11-25/basic/security_best_practices#session-hijacking) for more details.
-2. If an `MCP-Session-Id` is returned by the server during initialization, clients using
- the Streamable HTTP transport **MUST** include it in the `MCP-Session-Id` header on
- all of their subsequent HTTP requests.
- - Servers that require a session ID **SHOULD** respond to requests without an
- `MCP-Session-Id` header (other than initialization) with HTTP 400 Bad Request.
-3. The server **MAY** terminate the session at any time, after which it **MUST** respond
- to requests containing that session ID with HTTP 404 Not Found.
-4. When a client receives HTTP 404 in response to a request containing an
- `MCP-Session-Id`, it **MUST** start a new session by sending a new `InitializeRequest`
- without a session ID attached.
-5. Clients that no longer need a particular session (e.g., because the user is leaving
- the client application) **SHOULD** send an HTTP DELETE to the MCP endpoint with the
- `MCP-Session-Id` header, to explicitly terminate the session.
- - The server **MAY** respond to this request with HTTP 405 Method Not Allowed,
- indicating that the server does not allow clients to terminate sessions.
-
-### Sequence Diagram
-
-```mermaid
-sequenceDiagram
- participant Client
- participant Server
-
- note over Client, Server: initialization
-
- Client->>+Server: POST InitializeRequest
- Server->>-Client: InitializeResponse MCP-Session-Id: 1868a90c...
-
- Client->>+Server: POST InitializedNotification MCP-Session-Id: 1868a90c...
- Server->>-Client: 202 Accepted
-
- note over Client, Server: client requests
- Client->>+Server: POST ... request ... MCP-Session-Id: 1868a90c...
-
- alt single HTTP response
- Server->>Client: ... response ...
- else server opens SSE stream
- loop while connection remains open
- Server-)Client: ... SSE messages from server ...
- end
- Server-)Client: SSE event: ... response ...
- end
- deactivate Server
-
- note over Client, Server: client notifications/responses
- Client->>+Server: POST ... notification/response ... MCP-Session-Id: 1868a90c...
- Server->>-Client: 202 Accepted
-
- note over Client, Server: server requests
- Client->>+Server: GET MCP-Session-Id: 1868a90c...
- loop while connection remains open
- Server-)Client: ... SSE messages from server ...
- end
- deactivate Server
-
-```
-
-### Protocol Version Header
-
-If using HTTP, the client **MUST** include the `MCP-Protocol-Version:
-` HTTP header on all subsequent requests to the MCP
-server, allowing the MCP server to respond based on the MCP protocol version.
-
-For example: `MCP-Protocol-Version: 2025-11-25`
-
-The protocol version sent by the client **SHOULD** be the one [negotiated during
-initialization](/specification/2025-11-25/basic/lifecycle#version-negotiation).
-
-For backwards compatibility, if the server does _not_ receive an `MCP-Protocol-Version`
-header, and has no other way to identify the version - for example, by relying on the
-protocol version negotiated during initialization - the server **SHOULD** assume protocol
-version `2025-03-26`.
-
-If the server receives a request with an invalid or unsupported
-`MCP-Protocol-Version`, it **MUST** respond with `400 Bad Request`.
-
-### Backwards Compatibility
-
-Clients and servers can maintain backwards compatibility with the deprecated [HTTP+SSE
-transport](/specification/2024-11-05/basic/transports#http-with-sse) (from
-protocol version 2024-11-05) as follows:
-
-**Servers** wanting to support older clients should:
-
-- Continue to host both the SSE and POST endpoints of the old transport, alongside the
- new "MCP endpoint" defined for the Streamable HTTP transport.
- - It is also possible to combine the old POST endpoint and the new MCP endpoint, but
- this may introduce unneeded complexity.
-
-**Clients** wanting to support older servers should:
-
-1. Accept an MCP server URL from the user, which may point to either a server using the
- old transport or the new transport.
-2. Attempt to POST an `InitializeRequest` to the server URL, with an `Accept` header as
- defined above:
- - If it succeeds, the client can assume this is a server supporting the new Streamable
- HTTP transport.
- - If it fails with the following HTTP status codes "400 Bad Request", "404 Not
- Found" or "405 Method Not Allowed":
- - Issue a GET request to the server URL, expecting that this will open an SSE stream
- and return an `endpoint` event as the first event.
- - When the `endpoint` event arrives, the client can assume this is a server running
- the old HTTP+SSE transport, and should use that transport for all subsequent
- communication.
-
-## Custom Transports
-
-Clients and servers **MAY** implement additional custom transport mechanisms to suit
-their specific needs. The protocol is transport-agnostic and can be implemented over any
-communication channel that supports bidirectional message exchange.
-
-Implementers who choose to support custom transports **MUST** ensure they preserve the
-JSON-RPC message format and lifecycle requirements defined by MCP. Custom transports
-**SHOULD** document their specific connection establishment and message exchange patterns
-to aid interoperability.
diff --git a/spec/versioning.md b/spec/versioning.md
new file mode 100644
index 00000000..fcfea9f0
--- /dev/null
+++ b/spec/versioning.md
@@ -0,0 +1,183 @@
+---
+title: Versioning and Compatibility
+---
+
+
+
+This page defines how a client and server agree on what they are speaking:
+the protocol version, declared on every request; optional extensions,
+negotiated through capabilities; and interoperability with earlier,
+handshake-based protocol revisions.
+
+There is no negotiation handshake. Every request carries its protocol
+version, and the server accepts or rejects each request independently:
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Server
+
+ Client->>Server: request (with `_meta`)
+ alt server supports requested version
+ Server-->>Client: result
+ else version unsupported
+ Server-->>Client: UnsupportedProtocolVersionError
+ Note over Client,Server: Client retries with a mutually supported version
+ end
+```
+
+## Terminology
+
+This page uses the following terms for interoperability across protocol
+revisions:
+
+- **Modern**: protocol versions that convey version, identity, and
+ capabilities as per-request metadata (revision `2026-07-28` and later).
+- **Legacy**: protocol versions that establish a session with an
+ `initialize` handshake (`2025-11-25` and earlier).
+- **Dual-era**: an implementation that supports both modern and legacy
+ versions.
+
+## Protocol Version Negotiation
+
+Every request declares the protocol version it is using in its
+[`_meta`](/specification/2026-07-28/basic/index#meta) field. On HTTP, this is
+also carried in the
+[`MCP-Protocol-Version` header](/specification/2026-07-28/basic/transports/streamable-http#protocol-version-header).
+
+If the server does not implement the requested version (whether the version
+is unknown to the server, or is a known version the server has chosen not to
+support), it **MUST** respond with an
+[`UnsupportedProtocolVersionError`](/specification/2026-07-28/schema#unsupportedprotocolversionerror)
+listing the versions it does support:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "error": {
+ "code": -32022,
+ "message": "Unsupported protocol version",
+ "data": {
+ "supported": ["2026-07-28", "2025-11-25"],
+ "requested": "1900-01-01"
+ }
+ }
+}
+```
+
+The client **SHOULD** select a mutually supported version from the `supported`
+list and retry the request, or surface an error to the user if no compatible
+version exists.
+
+Servers **MUST** implement
+[`server/discover`](/specification/2026-07-28/server/discover). Clients
+**MAY** call it before sending any other requests to learn the server's
+supported versions up front, but are not required to: a client is free to
+invoke any RPC inline and handle `UnsupportedProtocolVersionError` if its
+preferred version is not supported.
+
+## Extension Negotiation
+
+Clients and servers can negotiate support for optional
+[extensions](/docs/extensions/overview) beyond the core protocol. Extensions
+are advertised in the `extensions` field of capabilities, which is a map of
+extension identifiers to per-extension settings objects. Extension identifiers
+**MUST** follow the [`_meta` key naming rules](/specification/2026-07-28/basic/index#meta),
+with a mandatory prefix.
+
+The following is an example of a client that advertises the
+[MCP Apps extension](/extensions/apps/overview) identified as `io.modelcontextprotocol/ui`:
+
+```json
+{
+ "capabilities": {
+ "roots": {},
+ "extensions": {
+ "io.modelcontextprotocol/ui": {
+ "mimeTypes": ["text/html;profile=mcp-app"]
+ }
+ }
+ }
+}
+```
+
+An example of [Tasks extension](/extensions/tasks/overview) identified as `io.modelcontextprotocol/tasks`:
+
+```json
+{
+ "capabilities": {
+ "tools": {},
+ "extensions": {
+ "io.modelcontextprotocol/tasks": {}
+ }
+ }
+}
+```
+
+Each extension specifies the schema of its settings object; an empty object
+indicates support with no additional settings.
+
+If one party supports an extension but the other does not, the supporting
+party **MUST** either revert to core protocol behavior or reject the request
+with an appropriate error. Extensions **SHOULD** document their expected
+fallback behavior.
+
+## Backward Compatibility with Initialization-Based Versions
+
+A server that wishes to support both [legacy](#terminology) clients (which
+expect an `initialize` handshake) and [modern](#terminology) clients (which
+use per-request metadata) **MAY** implement both behaviors.
+
+A client that needs to interoperate with both kinds of servers detects the
+server's era with transport-specific mechanics, specified in the binding
+pages:
+
+- [stdio](/specification/2026-07-28/basic/transports/stdio#backward-compatibility):
+ probe with `server/discover` and fall back on any error that is not a
+ recognized modern error.
+- [Streamable HTTP](/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility):
+ attempt a modern request and inspect the body of a `400 Bad Request`
+ before falling back.
+
+In both cases, a recognized modern JSON-RPC error (such as
+[`UnsupportedProtocolVersionError`](/specification/2026-07-28/schema#unsupportedprotocolversionerror))
+identifies a modern server: the client retries with a supported version
+rather than falling back. Anything else identifies a legacy server.
+
+The era determination is a property of the server, not of an individual
+request. Clients **SHOULD** cache the result for the lifetime of the server
+process (stdio) or origin (HTTP), and **MAY** persist it across restarts of
+the same server configuration, re-probing if the cached assumption later
+fails.
+
+A server that supports only [modern](#terminology) versions **SHOULD** name
+the protocol versions it supports in any error it returns to an `initialize`
+request, on any transport: legacy clients have no fall-forward mechanism, and
+this message may be the only diagnostic they can surface to users.
+
+### Compatibility Matrix
+
+The following matrix summarizes the expected outcome of every combination of
+client and server era:
+
+| Client | Server | Outcome |
+| -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Modern | Modern | Works. `server/discover` is optional; version mismatches surface as `UnsupportedProtocolVersionError` and the client retries with a mutually supported version. |
+| Modern | Legacy | Fails. The server may reject the request with an implementation-defined error, stay silent, or even process an era-ambiguous method under legacy semantics. On stdio, clients **SHOULD** send `server/discover` first to fail deterministically; the client then surfaces an actionable error to the user. |
+| Dual-era | Modern | Works. The stdio probe returns a `DiscoverResult` (or `UnsupportedProtocolVersionError`); on HTTP, the first modern request succeeds or returns a modern error. The client stays modern. |
+| Dual-era | Legacy | Works. stdio: the probe returns a non-modern error or times out, and the client falls back to `initialize`. HTTP: the modern request returns a `4xx` without a recognized modern error body, and the client falls back to `initialize` (and possibly further to the deprecated HTTP+SSE transport). |
+| Legacy | Modern | Fails. stdio: the server rejects `initialize` with a JSON-RPC error; the exact code is implementation-defined (`initialize` is an unknown method and the request also lacks the required `_meta` fields). HTTP: the request is missing the required headers and is rejected per [server validation](/specification/2026-07-28/basic/transports/streamable-http#server-validation) with `400 Bad Request` (a client on the deprecated HTTP+SSE transport fails at its opening `GET` instead). Legacy clients have no fall-forward mechanism. |
+| Legacy | Dual-era | Works. The server answers `initialize` and serves the client according to the negotiated legacy revision. |
+| Legacy | Legacy | Works according to the legacy revision; out of scope for this document. |
+
+A dual-era **server** selects its behavior from how the client opens:
+
+- A request carrying modern per-request `_meta` is served statelessly
+ according to this revision.
+- An `initialize` request selects legacy semantics, scoped to the stdio
+ process (stdio) or the session (HTTP), as specified by the negotiated
+ legacy protocol version.
+
+A dual-era server **MAY** serve both eras concurrently on the same endpoint
+or process.
diff --git a/src/auth/prehandler.ts b/src/auth/prehandler.ts
index 7759bce2..634aaf85 100644
--- a/src/auth/prehandler.ts
+++ b/src/auth/prehandler.ts
@@ -1,6 +1,7 @@
import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from 'fastify'
import type { AuthorizationConfig } from '../types/auth-types.ts'
import { TokenValidator } from './token-validator.ts'
+import { isStdioRequest } from '../stdio-trust.ts'
export function createAuthPreHandler (
config: AuthorizationConfig,
@@ -12,6 +13,13 @@ export function createAuthPreHandler (
return
}
+ // stdio is a local transport: the spec has it take credentials from the
+ // environment, not from HTTP bearer tokens, and only the in-process stdio
+ // transport can present this token.
+ if (isStdioRequest(request)) {
+ return
+ }
+
// Skip authorization for well-known endpoints
if (request.url.startsWith('/.well-known/') || request.url.startsWith('/mcp/.well-known')) {
return
diff --git a/src/brokers/message-broker.ts b/src/brokers/message-broker.ts
index 2b9820dd..829e2202 100644
--- a/src/brokers/message-broker.ts
+++ b/src/brokers/message-broker.ts
@@ -1,6 +1,7 @@
import type { JSONRPCMessage } from '../schema.ts'
export interface MessageBroker {
+ /** Resolves only when the broker has accepted delivery to the intended consumers. */
publish(topic: string, message: JSONRPCMessage): Promise
subscribe(topic: string, handler: (message: JSONRPCMessage) => void): Promise
unsubscribe(topic: string): Promise
diff --git a/src/brokers/redis-message-broker.ts b/src/brokers/redis-message-broker.ts
index 460b90ac..eda99f37 100644
--- a/src/brokers/redis-message-broker.ts
+++ b/src/brokers/redis-message-broker.ts
@@ -4,6 +4,7 @@ import type { JSONRPCMessage } from '../schema.ts'
import type { MessageBroker } from './message-broker.ts'
const DEFAULT_CLOSE_TIMEOUT_MS = 2000
+const SUBSCRIBE_READY_TIMEOUT_MS = 500
interface RedisConnLike {
disconnect (): unknown
@@ -75,11 +76,23 @@ export class RedisMessageBroker implements MessageBroker {
async subscribe (topic: string, handler: (message: JSONRPCMessage) => void): Promise {
return new Promise((resolve) => {
+ let settled = false
+ const finish = () => {
+ if (settled) return
+ settled = true
+ clearTimeout(timer)
+ resolve()
+ }
+ // A real Redis subscription normally confirms immediately. Bound the
+ // readiness wait so a partially responsive Redis endpoint cannot hang
+ // plugin registration forever.
+ const timer = setTimeout(finish, SUBSCRIBE_READY_TIMEOUT_MS)
+ timer.unref()
+
this.emitter.on(topic, (packet, cb) => {
handler(packet.message)
cb()
- })
- resolve()
+ }, finish)
})
}
diff --git a/src/client.ts b/src/client.ts
index fe06cb52..b6e72c00 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -3,13 +3,23 @@ import createFastifyError from 'fastify-error'
import type {
Implementation,
JSONRPCNotification,
+ Tool,
JSONRPCRequest,
JSONRPCResponse
} from './schema.ts'
import {
JSONRPC_VERSION,
- LATEST_PROTOCOL_VERSION
+ LATEST_LEGACY_PROTOCOL_VERSION,
+ MODERN_PROTOCOL_VERSIONS
} from './schema.ts'
+import {
+ META_CLIENT_CAPABILITIES,
+ META_CLIENT_INFO,
+ META_PROTOCOL_VERSION,
+ TASKS_EXTENSION
+} from './schema-2026.ts'
+import { collectHeaderParams, encodeHeaderValue, expectedNameFor } from './modern/headers.ts'
+import type { InputResponses } from './schema-2026.ts'
const DEFAULT_ENDPOINT = '/mcp'
const DEFAULT_STARTING_REQUEST_ID = 1
@@ -55,12 +65,30 @@ const InvalidMcpErrorCodeError = createFastifyError(
'MCP_ERR_INVALID_ERROR_CODE',
'Expected error.code to be a number'
)
+const UnrecognizedResultTypeError = createFastifyError(
+ 'MCP_ERR_UNRECOGNIZED_RESULT_TYPE',
+ "Unrecognized resultType '%s'; a result whose resultType is not understood is invalid"
+)
+const MissingStreamResponseError = createFastifyError(
+ 'MCP_ERR_MISSING_STREAM_RESPONSE',
+ 'The response stream ended without a JSON-RPC response (status %s)'
+)
+/** The `resultType` values this client understands: core plus the tasks extension. */
+const KNOWN_RESULT_TYPES = new Set(['complete', 'input_required', 'task'])
+const ModernInitializeError = createFastifyError(
+ 'MCP_ERR_MODERN_INITIALIZE',
+ "Protocol version '%s' is stateless and does not support initialize; call methods directly or use discover()"
+)
export interface McpClientOptions {
endpoint?: string
headers?: Record
protocolVersion?: string | null
startingRequestId?: number
+ /** Identity sent in modern per-request metadata. */
+ clientInfo?: Implementation
+ /** Capabilities sent in modern per-request metadata. */
+ clientCapabilities?: Record
}
export interface McpClientRequestOptions {
@@ -69,11 +97,29 @@ export interface McpClientRequestOptions {
id?: string | number
}
+export interface McpClientCallToolOptions extends McpClientRequestOptions {
+ /** Opaque state returned by a modern input_required result. */
+ requestState?: string
+ /** Client answers supplied when retrying a modern multi round-trip call. */
+ inputResponses?: InputResponses
+ /**
+ * Extra `_meta` for the request, such as a `progressToken` or
+ * `io.modelcontextprotocol/logLevel`. The protocol fields are always set by
+ * the client and cannot be overridden here.
+ */
+ meta?: Record
+}
+
export interface McpClientResponse {
statusCode: number
headers: Record
body: TBody
payload: string
+ /**
+ * Notifications the server sent on the response stream before the final
+ * response (2026-07-28 progress and log messages). Empty for a JSON response.
+ */
+ notifications: JSONRPCNotification[]
}
export interface McpClientInitializeOptions {
@@ -92,6 +138,8 @@ export interface McpClient {
initialize(options?: McpClientInitializeOptions): Promise
+ discover(options?: McpClientRequestOptions): Promise
+
listTools(options?: McpClientRequestOptions & {
cursor?: string
}): Promise
@@ -99,7 +147,7 @@ export interface McpClient {
callTool(
name: string,
args?: Record,
- options?: McpClientRequestOptions
+ options?: McpClientCallToolOptions
): Promise
}
@@ -116,6 +164,37 @@ function truncateForError (payload: string): string {
return `${payload.slice(0, JSON_PARSE_ERROR_PAYLOAD_LIMIT)}... [truncated ${payload.length - JSON_PARSE_ERROR_PAYLOAD_LIMIT} chars]`
}
+/**
+ * Split an SSE response into the notifications sent before the response and
+ * the final JSON-RPC response itself.
+ */
+function parseEventStream (
+ payload: string,
+ statusCode: number,
+ requestId: unknown
+): { body: unknown, notifications: JSONRPCNotification[] } {
+ const notifications: JSONRPCNotification[] = []
+ let body: unknown
+ // SSE lines may end in CRLF, LF or CR; events end at a blank line.
+ for (const frame of payload.replace(/\r\n?/g, '\n').split('\n\n')) {
+ const data = frame.split('\n')
+ .filter(line => line.startsWith('data:'))
+ // The field value starts after the colon and at most one space.
+ .map(line => line.slice(line.startsWith('data: ') ? 6 : 5))
+ .join('\n')
+ if (!data) continue
+ const message = parseJsonBody(data, statusCode)
+ if (isRecord(message) && hasOwn(message, 'id') && (hasOwn(message, 'result') || hasOwn(message, 'error'))) {
+ // Only the response to this request ends it.
+ if (message.id === requestId) body = message
+ } else {
+ notifications.push(message as JSONRPCNotification)
+ }
+ }
+ if (body === undefined) throw new MissingStreamResponseError(statusCode)
+ return { body, notifications }
+}
+
function parseJsonBody (payload: string, statusCode: number): unknown {
try {
return JSON.parse(payload)
@@ -269,16 +348,67 @@ function getPayloadProtocolVersion (
requestProtocolVersion: string | null | undefined
): string {
if (requestProtocolVersion === undefined) {
- return configuredProtocolVersion ?? LATEST_PROTOCOL_VERSION
+ return configuredProtocolVersion ?? LATEST_LEGACY_PROTOCOL_VERSION
}
if (requestProtocolVersion === null) {
- return LATEST_PROTOCOL_VERSION
+ return LATEST_LEGACY_PROTOCOL_VERSION
}
return requestProtocolVersion
}
+function isModernProtocolVersion (protocolVersion: string | null): protocolVersion is string {
+ return protocolVersion !== null &&
+ (MODERN_PROTOCOL_VERSIONS as readonly string[]).includes(protocolVersion)
+}
+
+function valueAtPath (root: unknown, path: string[]): unknown {
+ let current = root
+ for (const segment of path) {
+ if (!isRecord(current) || Array.isArray(current)) return undefined
+ current = current[segment]
+ }
+ return current
+}
+
+function toolParamHeaders (inputSchema: unknown, args: Record): Record {
+ const collected = collectHeaderParams(inputSchema)
+ if (!collected.ok) return {}
+
+ const headers: Record = {}
+ for (const [name, path] of collected.params) {
+ const value = valueAtPath(args, path)
+ if (value !== undefined && value !== null) {
+ headers[`mcp-param-${name}`] = encodeHeaderValue(String(value))
+ }
+ }
+ return headers
+}
+
+function withModernMetadata (
+ request: JSONRPCRequest | JSONRPCNotification,
+ protocolVersion: string,
+ clientInfo: Implementation,
+ clientCapabilities: Record
+): JSONRPCRequest | JSONRPCNotification {
+ const params = isRecord(request.params) ? request.params : {}
+ const currentMeta = isRecord(params._meta) ? params._meta : {}
+
+ return {
+ ...request,
+ params: {
+ ...params,
+ _meta: {
+ ...currentMeta,
+ [META_PROTOCOL_VERSION]: protocolVersion,
+ [META_CLIENT_INFO]: clientInfo,
+ [META_CLIENT_CAPABILITIES]: clientCapabilities
+ }
+ }
+ }
+}
+
export function createMcpClient (
app: FastifyInstance,
options: McpClientOptions = {}
@@ -287,8 +417,11 @@ export function createMcpClient (
const clientHeaders = options.headers ?? {}
const configuredProtocolVersion =
options.protocolVersion === undefined
- ? LATEST_PROTOCOL_VERSION
+ ? LATEST_LEGACY_PROTOCOL_VERSION
: options.protocolVersion
+ const clientInfo = options.clientInfo ?? DEFAULT_CLIENT_INFO
+ const clientCapabilities = options.clientCapabilities ?? {}
+ const toolSchemas = new Map()
let nextRequestId = options.startingRequestId ?? DEFAULT_STARTING_REQUEST_ID
let storedSessionId: string | undefined
let negotiatedProtocolVersion = configuredProtocolVersion
@@ -303,6 +436,63 @@ export function createMcpClient (
return generatedId
}
+ function effectiveProtocolVersion (requestOptions?: McpClientRequestOptions): string | null {
+ return requestOptions?.protocolVersion === undefined
+ ? negotiatedProtocolVersion
+ : requestOptions.protocolVersion
+ }
+
+ function filterAndRememberToolSchemas (
+ response: McpClientResponse,
+ rejectInvalidHeaderAnnotations: boolean
+ ): McpClientResponse {
+ if (!('result' in response.body) || !isRecord(response.body.result)) return response
+ const tools = response.body.result.tools
+ if (!Array.isArray(tools)) return response
+
+ const accepted: Tool[] = []
+ for (const entry of tools as Tool[]) {
+ if (typeof entry?.name !== 'string' || !('inputSchema' in entry)) {
+ accepted.push(entry)
+ continue
+ }
+
+ const annotations = collectHeaderParams(entry.inputSchema)
+ if (rejectInvalidHeaderAnnotations && !annotations.ok) {
+ // Streamable HTTP clients MUST exclude malformed x-mcp-header tools.
+ // Also forget an earlier valid schema with the same name so a stale
+ // cache cannot keep generating headers for a now-invalid definition.
+ toolSchemas.delete(entry.name)
+ continue
+ }
+
+ toolSchemas.set(entry.name, entry.inputSchema)
+ accepted.push(entry)
+ }
+
+ if (accepted.length === tools.length) return response
+
+ const body = {
+ ...response.body,
+ result: {
+ ...response.body.result,
+ tools: accepted
+ }
+ } as JSONRPCResponse
+
+ const payload = JSON.stringify(body)
+ const headers = { ...response.headers }
+ // The client changed the representation, so origin validators and digests
+ // no longer describe what callers receive. Length can be recomputed; the
+ // others must be dropped rather than left stale.
+ headers['content-length'] = String(Buffer.byteLength(payload))
+ for (const name of ['etag', 'content-md5', 'digest', 'content-digest', 'content-encoding']) {
+ delete headers[name]
+ }
+
+ return { ...response, headers, body, payload }
+ }
+
function send (
request: JSONRPCRequest | JSONRPCNotification,
requestOptions?: SendOptions & { expectJsonResponse?: true }
@@ -316,20 +506,28 @@ export function createMcpClient (
requestOptions?: SendOptions
): Promise> {
const expectJsonResponse = requestOptions?.expectJsonResponse ?? true
- const effectiveProtocolVersion =
- requestOptions?.protocolVersion === undefined
- ? negotiatedProtocolVersion
- : requestOptions.protocolVersion
+ const requestProtocolVersion = effectiveProtocolVersion(requestOptions)
const effectiveSessionId =
requestOptions?.sessionId === undefined
? storedSessionId
: requestOptions.sessionId
+ const modern = isModernProtocolVersion(requestProtocolVersion)
+ const payloadRequest = modern
+ ? withModernMetadata(request, requestProtocolVersion, clientInfo, clientCapabilities)
+ : request
+
const generatedHeaders: Record = {}
- if (effectiveProtocolVersion !== null) {
- generatedHeaders['mcp-protocol-version'] = effectiveProtocolVersion
+ if (requestProtocolVersion !== null) {
+ generatedHeaders['mcp-protocol-version'] = requestProtocolVersion
}
- if (effectiveSessionId !== null && effectiveSessionId !== undefined) {
+ if (modern) {
+ generatedHeaders['mcp-method'] = request.method
+ const name = expectedNameFor(request.method, request.params)
+ if (name !== undefined) {
+ generatedHeaders['mcp-name'] = encodeHeaderValue(name)
+ }
+ } else if (effectiveSessionId !== null && effectiveSessionId !== undefined) {
generatedHeaders['mcp-session-id'] = effectiveSessionId
}
@@ -345,22 +543,40 @@ export function createMcpClient (
method: 'POST',
url: endpoint,
headers,
- payload: request
+ payload: payloadRequest
})
const payload = response.body
let body: JSONRPCResponse | undefined
+ let notifications: JSONRPCNotification[] = []
if (expectJsonResponse) {
- const parsedBody = parseJsonBody(payload, response.statusCode)
- assertMcpResponse(parsedBody)
- body = parsedBody
+ // A request may be answered with either JSON or an SSE stream, and a
+ // client must accept both.
+ const streamed = String(response.headers['content-type'] ?? '').startsWith('text/event-stream')
+ const parsed = streamed
+ ? parseEventStream(payload, response.statusCode, (request as { id?: unknown }).id)
+ : { body: parseJsonBody(payload, response.statusCode), notifications: [] }
+ assertMcpResponse(parsed.body)
+ body = parsed.body
+ notifications = parsed.notifications
+
+ // An absent resultType means "complete". `task` is only meaningful to a
+ // client that declared the tasks extension; anything else is invalid.
+ const resultType = (body as { result?: { resultType?: unknown } }).result?.resultType
+ if (modern && 'result' in body && resultType !== undefined) {
+ const tasksDeclared = isRecord(clientCapabilities.extensions) &&
+ hasOwn(clientCapabilities.extensions, TASKS_EXTENSION)
+ const known = KNOWN_RESULT_TYPES.has(resultType as string) && (resultType !== 'task' || tasksDeclared)
+ if (!known) throw new UnrecognizedResultTypeError(String(resultType))
+ }
}
return {
statusCode: response.statusCode,
headers: normalizeResponseHeaders(response.headers as Record),
body,
- payload
+ payload,
+ notifications
}
}
@@ -370,12 +586,15 @@ export function createMcpClient (
},
async initialize (initOptions?: McpClientInitializeOptions): Promise {
- const id = getRequestId(initOptions?.id)
const payloadProtocolVersion = getPayloadProtocolVersion(
configuredProtocolVersion,
initOptions?.protocolVersion
)
+ if (isModernProtocolVersion(payloadProtocolVersion)) {
+ throw new ModernInitializeError(payloadProtocolVersion)
+ }
+ const id = getRequestId(initOptions?.id)
const request: JSONRPCRequest = {
jsonrpc: JSONRPC_VERSION,
id,
@@ -404,7 +623,7 @@ export function createMcpClient (
const candidateProtocolVersion =
typeof responseProtocolVersion === 'string'
? responseProtocolVersion
- : configuredProtocolVersion
+ : payloadProtocolVersion
const initializedResponse = await send(
{
@@ -430,6 +649,15 @@ export function createMcpClient (
return response
},
+ async discover (requestOptions?: McpClientRequestOptions): Promise {
+ const id = getRequestId(requestOptions?.id)
+ return await send({
+ jsonrpc: JSONRPC_VERSION,
+ id,
+ method: 'server/discover'
+ }, requestOptions)
+ },
+
async listTools (requestOptions?: McpClientRequestOptions & { cursor?: string }): Promise {
const id = getRequestId(requestOptions?.id)
@@ -440,15 +668,25 @@ export function createMcpClient (
...(requestOptions?.cursor === undefined ? {} : { params: { cursor: requestOptions.cursor } })
}
- return await send(request, requestOptions)
+ const response = await send(request, requestOptions)
+ return filterAndRememberToolSchemas(
+ response,
+ isModernProtocolVersion(effectiveProtocolVersion(requestOptions))
+ )
},
async callTool (
name: string,
args: Record = {},
- requestOptions?: McpClientRequestOptions
+ requestOptions?: McpClientCallToolOptions
): Promise {
const id = getRequestId(requestOptions?.id)
+ const {
+ requestState,
+ inputResponses,
+ meta,
+ ...baseRequestOptions
+ } = requestOptions ?? {}
const request: JSONRPCRequest = {
jsonrpc: JSONRPC_VERSION,
@@ -456,11 +694,25 @@ export function createMcpClient (
method: 'tools/call',
params: {
name,
- arguments: args
+ arguments: args,
+ ...(requestState === undefined ? {} : { requestState }),
+ ...(inputResponses === undefined ? {} : { inputResponses }),
+ ...(meta === undefined ? {} : { _meta: meta })
}
}
- return await send(request, requestOptions)
+ const schema = toolSchemas.get(name)
+ const generatedHeaders = isModernProtocolVersion(effectiveProtocolVersion(requestOptions)) && schema !== undefined
+ ? toolParamHeaders(schema, args)
+ : {}
+
+ return await send(request, {
+ ...baseRequestOptions,
+ headers: {
+ ...generatedHeaders,
+ ...(requestOptions?.headers ?? {})
+ }
+ })
}
}
}
diff --git a/src/decorators/meta.ts b/src/decorators/meta.ts
index 9c40468e..66e72cb6 100644
--- a/src/decorators/meta.ts
+++ b/src/decorators/meta.ts
@@ -13,6 +13,8 @@ import type {
import { callRegisteredTool } from '../handlers.ts'
import { schemaToArguments, validateToolSchema, isTypeBoxSchema } from '../validation/index.ts'
import type { JsonSchemaValidator } from '../validation/json-schema-validator.ts'
+import type { ServerCapabilities } from '../schema.ts'
+import { JSONRPC_VERSION } from '../schema.ts'
interface MCPDecoratorsOptions {
tools: Map
@@ -21,10 +23,32 @@ interface MCPDecoratorsOptions {
resourceHandlers: ResourceHandlers
opts: MCPPluginOptions
jsonSchemaValidator?: JsonSchemaValidator
+ capabilities: ServerCapabilities
}
+const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/
+
+const SUPPORTED_OUTPUT_DIALECTS = new Set([
+ 'https://json-schema.org/draft/2020-12/schema',
+ 'https://json-schema.org/draft/2020-12/schema#'
+])
+
const mcpDecoratorsPlugin: FastifyPluginAsync = async (app, options) => {
- const { tools, resources, prompts, resourceHandlers, opts, jsonSchemaValidator } = options
+ const { tools, resources, prompts, resourceHandlers, opts, jsonSchemaValidator, capabilities } = options
+
+ // Registrations before the server is ready are its initial lists; only later
+ // ones change a list a client may already hold.
+ let ready = false
+ app.addHook('onReady', async () => { ready = true })
+
+ /** Tell clients a list changed, when the server declares it does so. */
+ function announceListChange (list: 'tools' | 'resources' | 'prompts'): void {
+ if (!ready || !(capabilities[list] as { listChanged?: boolean } | undefined)?.listChanged) return
+ app.mcpBroadcastNotification({
+ jsonrpc: JSONRPC_VERSION,
+ method: `notifications/${list}/list_changed`
+ }).catch((error: unknown) => app.log.warn({ err: error, list }, 'Could not announce list change'))
+ }
// Enhanced tool decorator with TypeBox schema support
app.decorate('mcpAddTool', (
@@ -35,6 +59,11 @@ const mcpDecoratorsPlugin: FastifyPluginAsync = async (app
if (!name) {
throw new Error('Tool definition must have a name')
}
+ // The spec recommends 1-128 characters from [A-Za-z0-9_.-]; other names
+ // may not work with every client, so flag them without refusing.
+ if (!TOOL_NAME.test(name)) {
+ app.log.warn({ tool: name }, 'Tool name should be 1-128 characters of A-Z, a-z, 0-9, _, - and .')
+ }
// Validate schema if provided
if (definition.inputSchema) {
@@ -54,6 +83,14 @@ const mcpDecoratorsPlugin: FastifyPluginAsync = async (app
}
}
+ // Structured results are validated with JSON Schema 2020-12, the dialect
+ // MCP specifies. A schema declaring another dialect could never validate,
+ // so say so now rather than fail every call with a misleading mismatch.
+ const outputDialect = (definition.outputSchema as { $schema?: unknown } | undefined)?.$schema
+ if (outputDialect !== undefined && !SUPPORTED_OUTPUT_DIALECTS.has(String(outputDialect))) {
+ throw new Error(`Invalid output schema for '${name}': dialect '${outputDialect}' is not supported; use JSON Schema 2020-12`)
+ }
+
// TypeBox schemas are already JSON Schema compatible
const toolDefinition = definition
@@ -65,6 +102,7 @@ const mcpDecoratorsPlugin: FastifyPluginAsync = async (app
},
handler
})
+ announceListChange('tools')
})
app.decorate('mcpCallTool', (name: string, args: Record, context: McpCallToolContext) => {
@@ -104,6 +142,7 @@ const mcpDecoratorsPlugin: FastifyPluginAsync = async (app
}
resources.set(uriPattern, { definition: resourceDefinition, handler })
+ announceListChange('resources')
})
// Enhanced prompt decorator with argument schema support
@@ -132,11 +171,16 @@ const mcpDecoratorsPlugin: FastifyPluginAsync = async (app
},
handler
})
+ announceListChange('prompts')
})
// Resource subscription handler setters
app.decorate('mcpSetResourceSubscribeHandler', (handler: ResourceSubscribeHandler) => {
resourceHandlers.subscribeHandler = handler
+ // Default capabilities only: an explicit configuration is used as given.
+ if (opts.capabilities === undefined && capabilities.resources) {
+ capabilities.resources.subscribe = true
+ }
})
app.decorate('mcpSetResourceUnsubscribeHandler', (handler: ResourceUnsubscribeHandler) => {
diff --git a/src/decorators/pubsub.ts b/src/decorators/pubsub.ts
index 5f426f45..23f0783e 100644
--- a/src/decorators/pubsub.ts
+++ b/src/decorators/pubsub.ts
@@ -24,12 +24,10 @@ interface MCPPubSubDecoratorsOptions {
const mcpPubSubDecoratorsPlugin: FastifyPluginAsync = async (app, options) => {
const { enableSSE, messageBroker, sessionStore } = options
+ // Broadcasts are published regardless of `enableSSE`: that flag governs the
+ // legacy standing-stream transport, whereas 2026-07-28 delivers the same
+ // notifications on `subscriptions/listen` streams, which are always available.
app.decorate('mcpBroadcastNotification', async (notification: JSONRPCNotification) => {
- if (!enableSSE) {
- app.log.warn('Cannot broadcast notification: SSE is disabled')
- return
- }
-
try {
await messageBroker.publish('mcp/broadcast/notification', notification)
} catch (error) {
diff --git a/src/handlers.ts b/src/handlers.ts
index ed6add15..ff0773d6 100644
--- a/src/handlers.ts
+++ b/src/handlers.ts
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import createFastifyError from 'fastify-error'
+import type { TSchema } from '@sinclair/typebox'
import type {
JSONRPCMessage,
JSONRPCRequest,
@@ -23,6 +24,7 @@ import type {
import {
JSONRPC_VERSION,
LATEST_PROTOCOL_VERSION,
+ LATEST_LEGACY_PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS,
METHOD_NOT_FOUND,
INTERNAL_ERROR,
@@ -31,12 +33,30 @@ import {
} from './schema.ts'
import type { RequestId } from './schema.ts'
-import type { MCPTool, MCPResource, MCPPrompt, MCPPluginOptions, ResourceHandlers, McpCallToolOutcome, ToolAccessOperation, MCPToolCallCompleteEvent, TracerLike } from './types.ts'
+import type {
+ MCPTool,
+ MCPResource,
+ MCPPrompt,
+ MCPPluginOptions,
+ ResourceHandlers,
+ HandlerContext,
+ McpCallToolOutcome,
+ ToolAccessOperation,
+ MCPToolCallCompleteEvent,
+ TracerLike
+} from './types.ts'
import type { SessionStore } from './stores/session-store.ts'
import type { TaskStore, TaskRecord, TaskWaiters } from './stores/task-store.ts'
import { isTerminal, toWireTask } from './stores/task-store.ts'
import type { AuthorizationContext } from './types/auth-types.ts'
+import { principalOf } from './principal.ts'
+import { MAX_TIMER_MS, TaskStopped, quotaKeyOf, reserveTask } from './task-registry.ts'
+import type { TaskInputChannel } from './modern/task-inputs.ts'
+import { InputRequired } from './modern/input-required.ts'
+import type { RequestNotifiers } from './modern/request-stream.ts'
+import { isStdioRequest } from './stdio-trust.ts'
import {
+ atLeast,
supportsTasks,
supportsSchemaDialect,
trimDefinitionToRevision,
@@ -44,6 +64,7 @@ import {
} from './protocol-version.ts'
import { validate, CallToolRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, isTypeBoxSchema } from './validation/index.ts'
import type { JsonSchemaValidator } from './validation/json-schema-validator.ts'
+import { createJsonSchemaValidator } from './validation/json-schema-validator.ts'
import { sanitizeToolParams, assessToolSecurity } from './security.ts'
import { MCP_ATTR, type SpanAttributeValue } from './telemetry-constants.ts'
@@ -74,9 +95,31 @@ export type HandlerDependencies = {
sessionId?: string
/** The revision this client negotiated; responses are shaped to match it */
protocolVersion?: string
+ /**
+ * Multi round-trip state for the 2026-07-28 path: what the client sent back
+ * in answer to a previous `InputRequiredResult`. Absent on a first attempt
+ * and on the legacy path.
+ */
+ mrtr?: {
+ inputResponses?: Record
+ /** The payload the handler sealed into `requestState`, already verified. */
+ requestState?: unknown
+ }
+ /** Wakes task executions when `tasks/update` delivers their input. */
+ taskInputs?: TaskInputChannel
+ /**
+ * Report failures as JSON-RPC errors rather than as successful content.
+ * Set on the 2026-07-28 path, where a successful `resources/read` carries
+ * caching hints and an error must never be cached as the resource.
+ */
+ strictErrors?: boolean
+ /** Aborts when the request is cancelled; see `HandlerContext.signal`. */
+ signal?: AbortSignal
+ /** Request-scoped notifications; see `HandlerContext.sendProgress` and `log`. */
+ notifiers?: RequestNotifiers
}
-type ToolCallDependencies = Pick
export function createResponse (id: string | number, result: any): JSONRPCResponse {
@@ -106,17 +153,55 @@ export function createError (id: string | number | null, code: number, message:
}
/**
- * Pick the protocol revision to use for this session.
+ * The context every user handler receives.
*
- * The spec requires that we echo back the client's requested version when we
- * support it, and otherwise respond with the newest version we do support so
- * the client can decide whether to continue or disconnect.
+ * Built in one place so the multi round-trip fields cannot be plumbed into some
+ * handler kinds and forgotten in others.
+ */
+/** For requests nothing can cancel. */
+const NEVER_ABORTED = new AbortController().signal
+const NO_NOTIFIERS: RequestNotifiers = { sendProgress: () => {}, log: () => {} }
+
+function handlerContext (
+ dependencies: Pick,
+ sessionId: string | undefined
+): HandlerContext {
+ return {
+ sessionId,
+ request: dependencies.request,
+ reply: dependencies.reply,
+ authContext: dependencies.authContext,
+ inputResponses: dependencies.mrtr?.inputResponses,
+ requestState: dependencies.mrtr?.requestState,
+ signal: dependencies.signal ?? NEVER_ABORTED,
+ sendProgress: (dependencies.notifiers ?? NO_NOTIFIERS).sendProgress,
+ log: (dependencies.notifiers ?? NO_NOTIFIERS).log
+ }
+}
+
+/**
+ * A handler asking for client input is a protocol outcome, not a failure, so it
+ * must escape the catch-alls that turn thrown errors into `isError` results.
+ */
+function rethrowIfInputRequired (error: unknown): void {
+ if (error instanceof InputRequired) throw error
+}
+
+/**
+ * Pick the protocol revision for a client that arrived via `initialize`.
+ *
+ * We echo back what was asked for when we implement it, and otherwise answer
+ * with the newest revision we support that still has a handshake. Answering
+ * with 2026-07-28 would be nonsense here: a client capable of it would not be
+ * sending `initialize` in the first place.
*/
export function negotiateProtocolVersion (requested: unknown): string {
- if (typeof requested === 'string' && (SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(requested)) {
+ if (typeof requested === 'string' &&
+ requested !== LATEST_PROTOCOL_VERSION &&
+ (SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(requested)) {
return requested
}
- return LATEST_PROTOCOL_VERSION
+ return LATEST_LEGACY_PROTOCOL_VERSION
}
async function handleInitialize (
@@ -149,8 +234,9 @@ async function handleInitialize (
const result: InitializeResult = {
protocolVersion,
- // Never advertise a capability the agreed revision cannot express
- capabilities: capabilitiesForRevision(capabilities, protocolVersion),
+ // Never advertise a capability the agreed revision cannot express, nor a
+ // list-change notification a legacy client has no SSE channel to receive.
+ capabilities: withoutUndeliverableListChanges(capabilitiesForRevision(capabilities, protocolVersion), opts.enableSSE === true),
serverInfo,
instructions: opts.instructions
}
@@ -176,6 +262,18 @@ function withSchemaDialect (schema: T, protocolVersion: string | undefined):
return { $schema: JSON_SCHEMA_DIALECT, ...(schema as Record) } as T
}
+function withoutUndeliverableListChanges> (capabilities: T, deliverable: boolean): T {
+ if (deliverable) return capabilities
+ const trimmed: Record = { ...capabilities }
+ for (const list of ['tools', 'resources', 'prompts']) {
+ if (trimmed[list]?.listChanged) {
+ const { listChanged, ...rest } = trimmed[list]
+ trimmed[list] = rest
+ }
+ }
+ return trimmed as T
+}
+
/**
* Evaluate the `canAccessTool` hook for one tool. No hook means every tool is
* accessible. A hook that throws denies access (fail closed) rather than
@@ -226,7 +324,7 @@ async function mapWithConcurrency (items: T[], limit: number, fn: (item: T
return results
}
-async function handleToolsList (request: JSONRPCRequest, dependencies: HandlerDependencies): Promise {
+export async function handleToolsList (request: JSONRPCRequest, dependencies: HandlerDependencies): Promise {
const { tools, protocolVersion } = dependencies
// Per-tool checks run concurrently (bounded); order stays registration order
const registeredTools = Array.from(tools.values())
@@ -242,7 +340,9 @@ async function handleToolsList (request: JSONRPCRequest, dependencies: HandlerDe
// TypeBox schemas are already JSON Schema compatible
const serialized: typeof tool = {
...tool,
- inputSchema: withSchemaDialect(tool.inputSchema, protocolVersion)
+ // `inputSchema` is required, and must be an object schema; a tool
+ // registered without one takes an object of anything.
+ inputSchema: withSchemaDialect(tool.inputSchema ?? { type: 'object' }, protocolVersion)
}
if (serialized.outputSchema) {
serialized.outputSchema = withSchemaDialect(serialized.outputSchema, protocolVersion)
@@ -260,7 +360,7 @@ function isTemplateUri (uri: string): boolean {
return URI_TEMPLATE_REGEX.test(uri)
}
-function handleResourcesList (request: JSONRPCRequest, dependencies: HandlerDependencies): JSONRPCResponse {
+export function handleResourcesList (request: JSONRPCRequest, dependencies: HandlerDependencies): JSONRPCResponse {
const { resources, protocolVersion } = dependencies
const result: ListResourcesResult = {
resources: Array.from(resources.values())
@@ -271,7 +371,7 @@ function handleResourcesList (request: JSONRPCRequest, dependencies: HandlerDepe
return createResponse(request.id, result)
}
-function handleResourceTemplatesList (request: JSONRPCRequest, dependencies: HandlerDependencies): JSONRPCResponse {
+export function handleResourceTemplatesList (request: JSONRPCRequest, dependencies: HandlerDependencies): JSONRPCResponse {
const { resources, protocolVersion } = dependencies
const result: ListResourceTemplatesResult = {
resourceTemplates: Array.from(resources.values())
@@ -285,7 +385,7 @@ function handleResourceTemplatesList (request: JSONRPCRequest, dependencies: Han
return createResponse(request.id, result)
}
-function handlePromptsList (request: JSONRPCRequest, dependencies: HandlerDependencies): JSONRPCResponse {
+export function handlePromptsList (request: JSONRPCRequest, dependencies: HandlerDependencies): JSONRPCResponse {
const { prompts, protocolVersion } = dependencies
const result: ListPromptsResult = {
prompts: Array.from(prompts.values()).map(p => trimDefinitionToRevision(p.definition, protocolVersion)),
@@ -347,7 +447,7 @@ async function handleToolsCall (
taskParams?.ttl,
// Timed from when the task actually starts executing, not from when it
// was queued, so `durationMs` reflects work done rather than wait time.
- () => executeToolCall(request, resolved.tool, params, sessionId, dependencies, { source: 'task', startedAt: performance.now() }),
+ (signal) => executeToolCall(request, resolved.tool, params, sessionId, { ...dependencies, signal }, { source: 'task', startedAt: performance.now() }),
dependencies
)
}
@@ -356,7 +456,7 @@ async function handleToolsCall (
}
/** An observability failure must never change the tool response. */
-async function emitToolCallComplete (
+export async function emitToolCallComplete (
source: MCPToolCallCompleteEvent['source'],
toolName: string,
args: Record,
@@ -403,7 +503,7 @@ type RegisteredToolResolution =
| { ok: false, reason: 'not-found' }
| { ok: false, reason: 'access-denied' }
-async function resolveRegisteredTool (
+export async function resolveRegisteredTool (
toolName: string,
dependencies: ToolCallDependencies
): Promise {
@@ -486,7 +586,7 @@ interface ToolCallObservationContext {
startedAt: number
}
-async function executeToolCall (
+export async function executeToolCall (
request: JSONRPCRequest,
tool: MCPTool,
params: { name: string, arguments?: Record },
@@ -503,11 +603,88 @@ async function executeToolCall (
return toolCallOutcomeToJsonRpc(request.id, toolName, outcome)
}
+/** The revision that introduced `outputSchema` and `structuredContent`. */
+const OUTPUT_SCHEMA_REVISION = '2025-06-18'
+
+/**
+ * Validates tool output. Unlike the input validator it must never change what
+ * it checks: the result goes to the client exactly as the tool produced it.
+ */
+let outputValidator: JsonSchemaValidator | undefined
+function getOutputValidator (): JsonSchemaValidator {
+ outputValidator ??= createJsonSchemaValidator({
+ coerceTypes: false,
+ useDefaults: false,
+ removeAdditional: false
+ })
+ return outputValidator
+}
+
+/**
+ * Hold a successful result to the tool's `outputSchema`: when one is declared,
+ * the server MUST provide structured content that conforms to it. A result
+ * that does not is the tool's bug, reported as a tool error rather than handed
+ * to a client that may trust the schema. Error results are exempt.
+ */
+function conformToOutputSchema (
+ tool: MCPTool,
+ toolName: string,
+ outcome: McpCallToolOutcome,
+ dependencies: ToolCallDependencies
+): McpCallToolOutcome {
+ const schema = (tool.definition as { outputSchema?: unknown }).outputSchema
+ if (!outcome.ok || schema === undefined || outcome.result.isError) return outcome
+ // Revisions before 2025-06-18 have no `structuredContent`, so a text-only
+ // result is correct there. An in-process call has no revision and is held
+ // to the current rules.
+ const version = dependencies.protocolVersion
+ if (version !== undefined && !atLeast(version, OUTPUT_SCHEMA_REVISION)) return outcome
+
+ const structured = outcome.result.structuredContent
+ let problem: string | null = null
+ if (structured === undefined) {
+ problem = 'missing structuredContent'
+ } else {
+ try {
+ // Validate what the client will receive: a Date or anything with
+ // `toJSON` conforms by its serialized form, not its in-memory one.
+ const wire = JSON.parse(JSON.stringify(structured))
+ if (isTypeBoxSchema(schema)) {
+ const checked = validate(schema as TSchema, wire)
+ if (!checked.success) problem = checked.error.message
+ } else {
+ problem = getOutputValidator().validate(schema as Record, wire)
+ }
+ } catch (error) {
+ problem = `structured content cannot be validated: ${error instanceof Error ? error.message : String(error)}`
+ }
+ }
+ if (problem === null) return outcome
+
+ dependencies.app.log.error({ tool: toolName, problem }, 'Tool result does not conform to its outputSchema')
+ return {
+ ok: true,
+ result: {
+ content: [{ type: 'text', text: `Tool '${toolName}' returned a result that does not match its output schema` }],
+ isError: true
+ }
+ }
+}
+
async function executeRegisteredTool (
tool: MCPTool,
toolName: string,
args: Record,
dependencies: ToolCallDependencies
+): Promise {
+ return conformToOutputSchema(tool, toolName, await runRegisteredTool(tool, toolName, args, dependencies), dependencies)
+}
+
+async function runRegisteredTool (
+ tool: MCPTool,
+ toolName: string,
+ args: Record,
+ dependencies: ToolCallDependencies
): Promise {
const sessionId = dependencies.sessionId
@@ -567,9 +744,10 @@ async function executeRegisteredTool (
// Use validated arguments
try {
- const result = await tool.handler(argumentsValidation.data, { sessionId, request: dependencies.request, reply: dependencies.reply, authContext: dependencies.authContext })
+ const result = await tool.handler(argumentsValidation.data, handlerContext(dependencies, sessionId))
return { ok: true, result }
} catch (error: any) {
+ rethrowIfInputRequired(error)
const result: CallToolResult = {
content: [{
type: 'text',
@@ -590,9 +768,10 @@ async function executeRegisteredTool (
}
}
try {
- const result = await tool.handler(toolArguments, { sessionId, request: dependencies.request, reply: dependencies.reply, authContext: dependencies.authContext })
+ const result = await tool.handler(toolArguments, handlerContext(dependencies, sessionId))
return { ok: true, result }
} catch (error: any) {
+ rethrowIfInputRequired(error)
const result: CallToolResult = {
content: [{
type: 'text',
@@ -606,14 +785,10 @@ async function executeRegisteredTool (
} else {
// Unsafe tool without schema - pass arguments as-is
try {
- const result = await tool.handler(toolArguments, {
- sessionId,
- request: dependencies.request,
- reply: dependencies.reply,
- authContext: dependencies.authContext
- })
+ const result = await tool.handler(toolArguments, handlerContext(dependencies, sessionId))
return { ok: true, result }
} catch (error: any) {
+ rethrowIfInputRequired(error)
const result: CallToolResult = {
content: [{
type: 'text',
@@ -626,7 +801,7 @@ async function executeRegisteredTool (
}
}
-async function handleResourcesRead (
+export async function handleResourcesRead (
request: JSONRPCRequest,
sessionId: string | undefined,
dependencies: HandlerDependencies
@@ -662,6 +837,9 @@ async function handleResourcesRead (
}
if (!resource.handler) {
+ if (dependencies.strictErrors) {
+ return createError(request.id, INTERNAL_ERROR, `Resource '${uri}' has no handler implementation`)
+ }
const result: ReadResourceResult = {
contents: [{
uri,
@@ -679,6 +857,9 @@ async function handleResourcesRead (
// TypeBox schema - use our validation
const uriValidation = validate(schema, uri)
if (!uriValidation.success) {
+ if (dependencies.strictErrors) {
+ return createError(request.id, INVALID_PARAMS, `Invalid resource URI: ${uriValidation.error.message}`)
+ }
const result: ReadResourceResult = {
contents: [{
uri,
@@ -692,14 +873,20 @@ async function handleResourcesRead (
}
try {
- const result = await resource.handler(uri, {
- sessionId,
- request: dependencies.request,
- reply: dependencies.reply,
- authContext: dependencies.authContext
- })
+ const result = await resource.handler(uri, handlerContext(dependencies, sessionId))
+ // A resource with no contents does not exist: answering an empty array
+ // for it is not allowed, so report it the way a missing resource is.
+ if (dependencies.strictErrors && Array.isArray(result?.contents) && result.contents.length === 0) {
+ return createError(request.id, INVALID_PARAMS, `Resource '${uri}' not found`)
+ }
return createResponse(request.id, result)
} catch (error: any) {
+ rethrowIfInputRequired(error)
+ if (dependencies.strictErrors) {
+ // The handler's error may carry internals; it goes to the log only.
+ dependencies.app.log.error({ err: error, uri }, 'Resource read failed')
+ return createError(request.id, INTERNAL_ERROR, 'Resource read failed')
+ }
const result: ReadResourceResult = {
contents: [{
uri,
@@ -711,7 +898,7 @@ async function handleResourcesRead (
}
}
-async function handlePromptsGet (
+export async function handlePromptsGet (
request: JSONRPCRequest,
sessionId: string | undefined,
dependencies: HandlerDependencies
@@ -770,14 +957,10 @@ async function handlePromptsGet (
// Use validated arguments
try {
- const result = await prompt.handler(promptName, argumentsValidation.data, {
- sessionId,
- request: dependencies.request,
- reply: dependencies.reply,
- authContext: dependencies.authContext
- })
+ const result = await prompt.handler(promptName, argumentsValidation.data, handlerContext(dependencies, sessionId))
return createResponse(request.id, result)
} catch (error: any) {
+ rethrowIfInputRequired(error)
const result: GetPromptResult = {
messages: [{
role: 'user',
@@ -792,14 +975,10 @@ async function handlePromptsGet (
} else {
// Regular JSON Schema - basic validation or pass through
try {
- const result = await prompt.handler(promptName, promptArguments, {
- sessionId,
- request: dependencies.request,
- reply: dependencies.reply,
- authContext: dependencies.authContext
- })
+ const result = await prompt.handler(promptName, promptArguments, handlerContext(dependencies, sessionId))
return createResponse(request.id, result)
} catch (error: any) {
+ rethrowIfInputRequired(error)
const result: GetPromptResult = {
messages: [{
role: 'user',
@@ -815,14 +994,10 @@ async function handlePromptsGet (
} else {
// Unsafe prompt without schema - pass arguments as-is
try {
- const result = await prompt.handler(promptName, promptArguments, {
- sessionId,
- request: dependencies.request,
- reply: dependencies.reply,
- authContext: dependencies.authContext
- })
+ const result = await prompt.handler(promptName, promptArguments, handlerContext(dependencies, sessionId))
return createResponse(request.id, result)
} catch (error: any) {
+ rethrowIfInputRequired(error)
const result: GetPromptResult = {
messages: [{
role: 'user',
@@ -865,26 +1040,28 @@ function taskTtlBounds (dependencies: HandlerDependencies): { defaultTtl: number
}
/**
- * The authorization subject a task belongs to.
+ * The principal a task belongs to: user, OAuth client and issuer, so another
+ * app acting for the same user cannot read or cancel the task.
*
* When the deployment cannot identify requestors this is undefined, and tasks
* are reachable by anyone holding the (cryptographically random) task id. That
* limitation is why `tasks/list` is only advertised when auth is in play.
*/
function taskSubject (dependencies: HandlerDependencies): string | undefined {
- return dependencies.authContext?.userId
+ return principalOf(dependencies.authContext)
}
/**
* Whether this deployment can tie a task to a requestor.
*
* Mirrors the gate in `index.ts` that decides whether to advertise
- * `tasks.list`. Without authorization every task shares the undefined subject,
+ * `tasks.list`. Without an identity resolver every task shares the undefined
+ * subject,
* so listing would hand every task's id to any caller — defeating the "random
* task id is the capability" model that protects `tasks/get|result|cancel`.
*/
function canIdentifyRequestors (dependencies: HandlerDependencies): boolean {
- return dependencies.opts.authorization?.enabled === true
+ return dependencies.opts.authorization?.enabled === true || dependencies.opts.resolveAuthorizationContext !== undefined
}
/**
@@ -894,19 +1071,22 @@ function canIdentifyRequestors (dependencies: HandlerDependencies): boolean {
*/
function assertTaskAccess (task: TaskRecord | null, dependencies: HandlerDependencies): TaskRecord | null {
if (!task) return null
+ // 2026-07-28 tasks have a different shape and lifecycle (input rounds
+ // answered through `tasks/update`), which a 2025-11-25 client cannot use.
+ if (task.era === 'modern') return null
const subject = taskSubject(dependencies)
if (canIdentifyRequestors(dependencies)) {
- // Auth on: a task is reachable only by the exact subject that owns it. A
- // token without a `sub` claim identifies no one, so an undefined subject
+ // Identity resolution on: a task is reachable only by its exact owner. A
+ // context without a `userId` identifies no one, so an undefined subject
// must never match another subject-less task via `undefined === undefined`.
if (subject === undefined || task.authSubject !== subject) return null
return task
}
- // Auth off: no requestor can be identified, so the random task id is the
- // capability and every (subject-less) task is reachable by whoever holds it.
+ // No identity resolver: the random task id is the capability and every
+ // subject-less task is reachable by whoever holds it.
return task
}
@@ -952,7 +1132,8 @@ function newTaskRecord (
ttl: Math.min(requested, bounds.maxTtl),
pollInterval: DEFAULT_POLL_INTERVAL,
method,
- authSubject: subject
+ authSubject: subject,
+ era: 'legacy'
}
}
@@ -1134,7 +1315,7 @@ async function handleTasksList (
return createResponse(request.id, { tasks: [], nextCursor: undefined } as ListTasksResult)
}
- const tasks = await taskStore.list(subject)
+ const tasks = (await taskStore.list(subject)).filter(task => task.era !== 'modern')
const result: ListTasksResult = {
tasks: tasks.map(toWireTask),
nextCursor: undefined
@@ -1169,7 +1350,9 @@ async function handleTasksCancel (
try {
cancelled = await taskStore.updateStatus(taskId, 'cancelled', {
statusMessage: 'The task was cancelled by request.',
- outcome: createError(request.id, INTERNAL_ERROR, 'Task was cancelled')
+ outcome: createError(request.id, INTERNAL_ERROR, 'Task was cancelled'),
+ inputRequests: null,
+ clearPendingInputResponses: true
})
} catch {
// The task reached a terminal status between our check above and the write
@@ -1183,6 +1366,13 @@ async function handleTasksCancel (
}
dependencies.taskWaiters?.notify(cancelled)
+ // A 2026-07-28 task parked for input lives in the same store; wake its
+ // worker wherever it runs instead of leaving it blocked until its ttl.
+ try {
+ await dependencies.taskInputs?.cancel(taskId)
+ } catch (error) {
+ dependencies.app.log.debug({ err: error, taskId }, 'Could not publish task input cancellation')
+ }
await notifyTaskStatus(cancelled, dependencies)
return createResponse(request.id, toWireTask(cancelled))
@@ -1215,7 +1405,7 @@ async function notifyTaskStatus (task: TaskRecord, dependencies: HandlerDependen
async function runToolCallAsTask (
request: JSONRPCRequest,
ttl: number | undefined,
- execute: () => Promise,
+ execute: (signal: AbortSignal) => Promise,
dependencies: HandlerDependencies
): Promise {
const { taskStore, taskWaiters, app } = dependencies
@@ -1224,7 +1414,32 @@ async function runToolCallAsTask (
}
const task = newTaskRecord('tools/call', ttl, taskSubject(dependencies), taskTtlBounds(dependencies))
- await taskStore.create(task)
+
+ // Legacy tasks share the limits, ttl abort and shutdown drain of the
+ // 2026-07-28 ones, so neither era can starve the other.
+ const reservation = reserveTask(taskStore, task.taskId, quotaKeyOf(dependencies.authContext), dependencies.opts)
+ if (!reservation.ok) {
+ return createError(request.id, INTERNAL_ERROR, `Cannot create a task: ${reservation.reason}`)
+ }
+ try {
+ await taskStore.create(task)
+ } catch (error) {
+ reservation.release()
+ throw error
+ }
+
+ const stopController = new AbortController()
+ const stopped = stopController.signal
+ reservation.live.stop = (reason: TaskStopped) => {
+ if (!stopped.aborted) stopController.abort(reason)
+ }
+ const taskTtl = task.ttl ?? undefined
+ const ttlTimer = taskTtl !== undefined && taskTtl <= MAX_TIMER_MS
+ ? setTimeout(() => {
+ reservation.live.stop(new TaskStopped('Task expired before it finished'))
+ reservation.release()
+ }, taskTtl).unref()
+ : undefined
// Deliberately not awaited: the point of a task is to return control now.
const execution = (async () => {
@@ -1233,7 +1448,7 @@ async function runToolCallAsTask (
let statusMessage: string | undefined
try {
- const result = await execute()
+ const result = await execute(stopped)
outcome = result
// A tool result carrying isError counts as a failed task
if ('result' in result && (result.result as CallToolResult)?.isError === true) {
@@ -1248,6 +1463,12 @@ async function runToolCallAsTask (
statusMessage = `Tool execution failed: ${error?.message || error}`
outcome = createError(request.id, INTERNAL_ERROR, statusMessage)
}
+ // A stopped task ends for the reason it was stopped.
+ if (stopped.aborted && stopped.reason instanceof TaskStopped) {
+ status = 'failed'
+ statusMessage = stopped.reason.message
+ outcome = createError(request.id, INTERNAL_ERROR, statusMessage)
+ }
try {
const updated = await taskStore.updateStatus(task.taskId, status, { statusMessage, outcome })
@@ -1263,9 +1484,14 @@ async function runToolCallAsTask (
// Nothing awaits `execution`; keep an explicit rejection guard so an
// unexpected throw can never become an unhandled rejection.
- execution.catch((error) => {
- app.log.error({ err: error, taskId: task.taskId }, 'Task execution failed unexpectedly')
- })
+ reservation.live.done = execution
+ .catch((error) => {
+ app.log.error({ err: error, taskId: task.taskId }, 'Task execution failed unexpectedly')
+ })
+ .finally(() => {
+ if (ttlTimer) clearTimeout(ttlTimer)
+ reservation.release()
+ })
const result: CreateTaskResult = { task: toWireTask(task) }
return createResponse(request.id, result)
@@ -1288,12 +1514,7 @@ async function handleResourcesSubscribe (
}
try {
- const result = await resourceHandlers.subscribeHandler(params, {
- sessionId,
- request: dependencies.request,
- reply: dependencies.reply,
- authContext: dependencies.authContext
- })
+ const result = await resourceHandlers.subscribeHandler(params, handlerContext(dependencies, sessionId))
return createResponse(request.id, result)
} catch (error: any) {
return createError(request.id, INTERNAL_ERROR, `Subscribe failed: ${error.message || error}`)
@@ -1317,20 +1538,13 @@ async function handleResourcesUnsubscribe (
}
try {
- const result = await resourceHandlers.unsubscribeHandler(params, {
- sessionId,
- request: dependencies.request,
- reply: dependencies.reply,
- authContext: dependencies.authContext
- })
+ const result = await resourceHandlers.unsubscribeHandler(params, handlerContext(dependencies, sessionId))
return createResponse(request.id, result)
} catch (error: any) {
return createError(request.id, INTERNAL_ERROR, `Unsubscribe failed: ${error.message || error}`)
}
}
-const STDIO_TRANSPORT_HEADER = 'x-platformatic-mcp-transport'
-
function mcpContextCarrier (params: unknown): Record | undefined {
if (typeof params !== 'object' || params === null || !('_meta' in params)) return undefined
const meta = params._meta
@@ -1349,7 +1563,7 @@ function normalizedNetworkProtocolVersion (version: string): string {
return version.endsWith('.0') ? version.slice(0, -2) : version
}
-async function withMcpServerSpan (
+export async function withMcpServerSpan (
message: JSONRPCRequest | JSONRPCNotification,
sessionId: string | undefined,
dependencies: HandlerDependencies,
@@ -1384,7 +1598,9 @@ async function withMcpServerSpan (
: dependencies.protocolVersion
if (protocolVersion) extraAttrs[MCP_ATTR.PROTOCOL_VERSION] = protocolVersion
- const isStdio = request.headers[STDIO_TRANSPORT_HEADER] === 'stdio'
+ // The plain transport header is only a hint any HTTP client could send;
+ // trusting it would let a caller drop its address from the span.
+ const isStdio = isStdioRequest(request)
if (isStdio) {
extraAttrs[MCP_ATTR.NETWORK_TRANSPORT] = 'pipe'
} else {
@@ -1467,7 +1683,17 @@ export async function handleRequest (
}
})
} catch (error) {
- return createError(request.id, INTERNAL_ERROR, 'Internal server error', error)
+ // Never serialize the thrown value: an `InputRequired` carries the
+ // handler's private state, and any other error may carry internals.
+ if (error instanceof InputRequired) {
+ return createError(
+ request.id,
+ INTERNAL_ERROR,
+ 'This request needs additional client input, which requires protocol version 2026-07-28'
+ )
+ }
+ app.log.error({ err: error, method: request.method }, 'Unhandled error in MCP request')
+ return createError(request.id, INTERNAL_ERROR, 'Internal server error')
}
}
diff --git a/src/index.ts b/src/index.ts
index 3182e056..2d86879e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,6 +12,16 @@ import { TaskWaiters } from './stores/task-store.ts'
import { MemoryTaskStore } from './stores/memory-task-store.ts'
import { RedisTaskStore } from './stores/redis-task-store.ts'
import type { MCPPluginOptions, MCPTool, MCPResource, MCPPrompt, ResourceHandlers } from './types.ts'
+import type { CacheHint, CachingConfig } from './modern/handlers.ts'
+import { drainTasks } from './task-registry.ts'
+import { claimStdioRequest } from './stdio-trust.ts'
+import { RequestStateSealer } from './modern/request-state.ts'
+import { SubscriptionRegistry } from './modern/subscriptions.ts'
+import {
+ TaskInputChannel,
+ TASK_INPUT_CANCEL_TOPIC,
+ TASK_INPUT_TOPIC
+} from './modern/task-inputs.ts'
import pubsubDecorators from './decorators/pubsub.ts'
import metaDecorators from './decorators/meta.ts'
import routes from './routes/mcp.ts'
@@ -29,6 +39,7 @@ import {
} from './client.ts'
// Import and export MCP protocol types
+import { JSONRPC_VERSION } from './schema.ts'
import type {
JSONRPCMessage,
JSONRPCRequest,
@@ -55,6 +66,7 @@ import type {
} from './schema.ts'
const REDIS_QUIT_TIMEOUT_MS = 2000
+const TASK_CLEANUP_INTERVAL_MS = 10 * 60 * 1000
declare module 'fastify' {
interface FastifyInstance {
@@ -68,12 +80,28 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption
version: '1.0.0'
}
+ // The plugin can always broadcast list changes (mcpBroadcastNotification),
+ // so by default it says so; without it, 2026-07-28 subscriptions/listen
+ // could acknowledge nothing. `resources.subscribe` is declared once a
+ // subscribe handler is registered. Explicit capabilities are used as given.
const capabilities: ServerCapabilities = opts.capabilities ?? {
- tools: {},
- resources: {},
- prompts: {}
+ tools: { listChanged: true },
+ resources: { listChanged: true },
+ prompts: { listChanged: true }
+ }
+
+ // Several instances serve each other's MRTR retries, so they must share the
+ // key that seals request state; without one each would refuse the others'.
+ if (opts.redis && opts.requestStateSecret === undefined) {
+ throw new Error('requestStateSecret is required when redis is configured: every instance must verify the request state the others seal')
}
+ // Recognise stdio-injected requests before any other hook (authorization
+ // included) runs, and take their tokens out of the headers handlers see.
+ app.addHook('onRequest', async (request) => {
+ claimStdioRequest(request)
+ })
+
app.decorate('mcpClient', (clientOptions?: McpClientOptions) => {
return createMcpClient(app, clientOptions)
})
@@ -109,19 +137,24 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption
sessionStore = new MemorySessionStore(100)
messageBroker = new MemoryMessageBroker()
if (enableTasks) {
- taskStore = new MemoryTaskStore()
+ taskStore = new MemoryTaskStore(opts.taskStoreMaxTasks)
}
}
// Waiters are process-local by design: only the instance serving a given
// tasks/result request needs to be woken when that task finishes.
const taskWaiters = new TaskWaiters()
+ const taskInputs = new TaskInputChannel(opts.taskMaxTtlMs ?? 3_600_000)
if (enableTasks) {
// Advertise which task operations we support. `tasks/list` is only offered
- // when authorization is on, because without an identifiable requestor it
- // would expose every task's metadata to anyone who can reach the server.
- const canIdentifyRequestors = opts.authorization?.enabled === true
+ // when identity resolution is configured; otherwise enumeration would
+ // expose every task's metadata to anyone who can reach the server.
+ //
+ // This is the 2025-11-25 core shape, used only on the legacy path. Modern
+ // clients see the `io.modelcontextprotocol/tasks` extension instead, which
+ // `buildServerCapabilities` adds to the `server/discover` result.
+ const canIdentifyRequestors = opts.authorization?.enabled === true || opts.resolveAuthorizationContext !== undefined
capabilities.tasks = {
...(canIdentifyRequestors ? { list: {} } : {}),
cancel: {},
@@ -131,6 +164,106 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption
}
}
+ // Cacheable results must always carry hints, so anything unconfigured
+ // defaults to "immediately stale, never shared" — correct for every server,
+ // and something deployments opt out of knowingly.
+ const noCache: CacheHint = { ttlMs: 0, cacheScope: 'private' }
+ const hintFor = (which: keyof CachingConfig): CacheHint => {
+ const hint = opts.caching?.[which]
+ if (hint === undefined) return noCache
+ // `ttlMs` goes on the wire as a non-negative integer; NaN would serialize
+ // as null and a fraction is not a valid value.
+ if (!Number.isSafeInteger(hint.ttlMs) || hint.ttlMs < 0) {
+ throw new Error(`caching.${which}.ttlMs must be a non-negative integer`)
+ }
+ if (hint.cacheScope !== 'public' && hint.cacheScope !== 'private') {
+ throw new Error(`caching.${which}.cacheScope must be "public" or "private"`)
+ }
+ return hint
+ }
+ const caching: CachingConfig = {
+ discover: hintFor('discover'),
+ toolsList: hintFor('toolsList'),
+ promptsList: hintFor('promptsList'),
+ resourcesList: hintFor('resourcesList'),
+ resourceTemplatesList: hintFor('resourceTemplatesList'),
+ resourcesRead: hintFor('resourcesRead')
+ }
+
+ // A `public` hint lets shared caches serve one caller's result to another,
+ // which is wrong when results depend on who asks.
+ const perCaller = opts.authorization?.enabled === true || opts.resolveAuthorizationContext !== undefined || opts.canAccessTool !== undefined
+ if (perCaller) {
+ const shared = (Object.keys(caching) as Array).filter(which => caching[which].cacheScope === 'public')
+ if (shared.length > 0) {
+ app.log.warn({ operations: shared }, 'MCP: cacheScope "public" is configured while results may differ per caller (authorization, resolveAuthorizationContext or canAccessTool); shared caches could serve one caller\'s result to another')
+ }
+ }
+
+ const sealer = new RequestStateSealer({
+ secret: opts.requestStateSecret,
+ audience: serverInfo.name,
+ ttlMs: opts.requestStateTtlMs,
+ // With identity resolution on, a request carrying no `userId` identifies
+ // nobody, and two such callers would share the undefined principal — so state
+ // sealed for one would verify for the other. Refuse rather than bind to
+ // nobody.
+ requirePrincipal: opts.authorization?.enabled === true || opts.resolveAuthorizationContext !== undefined
+ })
+
+ if (opts.requestStateSecret === undefined) {
+ app.log.debug('MCP: no requestStateSecret configured; multi round-trip retries will only verify on the instance that issued them')
+ } else if (opts.serverInfo === undefined) {
+ // Sealed state names the server it was issued by; without serverInfo every
+ // deployment shares the default name, so two sharing a secret would accept
+ // each other's state.
+ app.log.warn('MCP: requestStateSecret is set without serverInfo; set serverInfo.name so sealed state is bound to this server')
+ }
+
+ const subscriptions = new SubscriptionRegistry(app.log, undefined, undefined, serverInfo, {
+ maxStreams: opts.subscriptionMaxStreams,
+ maxStreamsPerPrincipal: opts.subscriptionMaxStreamsPerPrincipal
+ })
+
+ // A `tasks/update` can land on any instance, but the execution waiting for
+ // those answers lives on exactly one. Route the wake-up through the broker so
+ // it reaches that instance. A resolved publication is the broker contract's
+ // delivery confirmation; only then may the durable outbox be acknowledged.
+ taskInputs.setPublisher(async (taskId, inputResponses, deliveryId) => {
+ await messageBroker.publish(TASK_INPUT_TOPIC, {
+ jsonrpc: JSONRPC_VERSION,
+ method: 'notifications/tasks/input',
+ params: { taskId, inputResponses, deliveryId }
+ })
+ })
+ taskInputs.setCancellationPublisher(async (taskId) => {
+ await messageBroker.publish(TASK_INPUT_CANCEL_TOPIC, {
+ jsonrpc: JSONRPC_VERSION,
+ method: 'notifications/tasks/input_cancelled',
+ params: { taskId }
+ })
+ })
+
+ if (enableTasks) {
+ await messageBroker.subscribe(TASK_INPUT_TOPIC, (message) => {
+ const params = (message as {
+ params?: { taskId?: unknown, inputResponses?: unknown, deliveryId?: unknown }
+ }).params
+ if (typeof params?.taskId !== 'string' || !params.inputResponses) return
+ taskInputs.deliver(
+ params.taskId,
+ params.inputResponses as Record,
+ typeof params.deliveryId === 'string' ? params.deliveryId : undefined
+ )
+ })
+ await messageBroker.subscribe(TASK_INPUT_CANCEL_TOPIC, (message) => {
+ const params = (message as { params?: { taskId?: unknown } }).params
+ if (typeof params?.taskId === 'string') {
+ taskInputs.abort(params.taskId, 'task cancelled')
+ }
+ })
+ }
+
// Local stream management per server instance
const localStreams = new Map>()
@@ -173,7 +306,8 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption
prompts,
resourceHandlers,
opts,
- jsonSchemaValidator
+ jsonSchemaValidator,
+ capabilities
})
app.register(pubsubDecorators, {
enableSSE,
@@ -197,11 +331,53 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption
localStreams,
taskStore,
taskWaiters,
- jsonSchemaValidator
+ jsonSchemaValidator,
+ taskInputs,
+ sealer,
+ caching,
+ subscriptions,
+ enableTasks
})
+ // Streams must be closed in `preClose`: Fastify shuts the HTTP server down
+ // before `onClose` runs, and an open SSE response is an in-flight request, so
+ // waiting until `onClose` would deadlock the close on the very streams it is
+ // trying to end.
+ app.addHook('preClose', async () => {
+ // End modern subscription streams with the graceful-closure response, so
+ // clients can tell a shutdown from a dropped connection.
+ subscriptions.closeAll()
+ // Let running tasks finish (or record them as failed) while the task
+ // channel and Redis are still up to carry their outcome.
+ if (taskStore) await drainTasks(taskStore, opts.taskShutdownTimeoutMs ?? 5_000)
+ taskInputs.close()
+
+ for (const streams of localStreams.values()) {
+ for (const stream of streams) {
+ try {
+ if (stream.raw && !stream.raw.destroyed) {
+ stream.raw.end()
+ }
+ } catch (error) {
+ app.log.debug({ error }, 'Error ending SSE stream during shutdown')
+ }
+ }
+ }
+ })
+
+ // Expired task keys vanish on their own in Redis, but their ids linger in
+ // the index until something prunes it, so prune it on a schedule.
+ // Each instance runs it at a jittered interval, so instances deployed
+ // together do not all sweep the shared index at the same moment.
+ const taskCleanup = taskStore
+ ? setInterval(() => {
+ taskStore.cleanup().catch((error) => app.log.debug({ err: error }, 'Task store cleanup failed'))
+ }, Math.round(TASK_CLEANUP_INTERVAL_MS * (0.5 + Math.random()))).unref()
+ : undefined
+
// Add close hook to clean up Redis connections and authorization components
app.addHook('onClose', async () => {
+ if (taskCleanup) clearInterval(taskCleanup)
// Clean up all SSE streams and sessions
const unsubscribePromises: Promise[] = []
for (const [sessionId, streams] of localStreams.entries()) {
@@ -276,6 +452,7 @@ export type {
MCPRouteId,
MCPRouteSchemaContext,
MCPRouteSchemaTransformer,
+ AuthorizationContextResolver,
ToolAccessContext,
ToolAccessOperation,
McpCallToolContext,
@@ -308,6 +485,7 @@ export type { HandlerDependencies } from './handlers.ts'
// Export authorization types
export type {
AuthorizationConfig,
+ AuthorizationContext,
TokenValidationResult,
ProtectedResourceMetadata,
TokenIntrospectionResponse,
@@ -345,14 +523,69 @@ export type {
// Protocol constants, so consumers can negotiate and branch on the revision
export {
LATEST_PROTOCOL_VERSION,
+ LATEST_LEGACY_PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS,
+ MODERN_PROTOCOL_VERSIONS,
+ LEGACY_PROTOCOL_VERSIONS,
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
JSONRPC_VERSION,
- URL_ELICITATION_REQUIRED
+ URL_ELICITATION_REQUIRED,
+ HEADER_MISMATCH,
+ MISSING_REQUIRED_CLIENT_CAPABILITY,
+ UNSUPPORTED_PROTOCOL_VERSION
} from './schema.ts'
+/* ---------------------------------------------------------------- */
+/* 2026-07-28 */
+/* ---------------------------------------------------------------- */
+
+// Multi round-trip requests: how a handler asks the client for something.
+export {
+ InputRequired,
+ elicitForm,
+ elicitUrl,
+ requestSampling,
+ requestRoots
+} from './modern/input-required.ts'
+
+// Reserved `_meta` keys and the tasks extension identifier.
+export {
+ META_PROTOCOL_VERSION,
+ META_CLIENT_INFO,
+ META_CLIENT_CAPABILITIES,
+ META_LOG_LEVEL,
+ META_SUBSCRIPTION_ID,
+ META_SERVER_INFO,
+ TASKS_EXTENSION
+} from './schema-2026.ts'
+
+// Header mirroring, for clients and for tests.
+export { encodeHeaderValue, decodeHeaderValue } from './modern/headers.ts'
+
+export { RequestStateSealer } from './modern/request-state.ts'
+export { SubscriptionRegistry } from './modern/subscriptions.ts'
+
+export type {
+ ClientCapabilities as ModernClientCapabilities,
+ ServerCapabilities as ModernServerCapabilities,
+ DiscoverResult,
+ CacheableResult,
+ InputRequests,
+ InputResponses,
+ InputRequiredResult,
+ RequestMetaObject,
+ ResultType,
+ SubscriptionFilter,
+ Task as ExtensionTask,
+ TaskStatus as ExtensionTaskStatus,
+ CreateTaskResult as ExtensionCreateTaskResult,
+ DetailedTask
+} from './schema-2026.ts'
+
+export type { CacheHint, CachingConfig } from './modern/handlers.ts'
+
// Task storage, for callers that want to supply or inspect a backend
-export type { TaskStore, TaskRecord, TaskOutcome } from './stores/task-store.ts'
+export type { TaskStore, TaskRecord, TaskOutcome, TaskInputUpdate } from './stores/task-store.ts'
export { MemoryTaskStore } from './stores/memory-task-store.ts'
export { RedisTaskStore } from './stores/redis-task-store.ts'
@@ -367,6 +600,7 @@ export type {
McpClient,
McpClientOptions,
McpClientRequestOptions,
+ McpClientCallToolOptions,
McpClientInitializeOptions,
McpClientResponse
} from './client.ts'
diff --git a/src/modern/handlers.ts b/src/modern/handlers.ts
new file mode 100644
index 00000000..045f5a7c
--- /dev/null
+++ b/src/modern/handlers.ts
@@ -0,0 +1,1399 @@
+/**
+ * Request dispatch for the 2026-07-28 revision.
+ *
+ * The business logic — looking a tool up, validating arguments, running the
+ * handler — is shared with the legacy path; what differs is the envelope. Every
+ * result here carries `resultType`, servers identify themselves in `_meta`,
+ * cacheable operations carry freshness hints, and anything the server needs
+ * from the client comes back as an `InputRequiredResult` instead of a
+ * server-initiated request on a stream.
+ */
+
+import { randomUUID } from 'node:crypto'
+import type {
+ JSONRPCRequest,
+ JSONRPCResponse,
+ JSONRPCError,
+ Implementation
+} from '../schema.ts'
+import {
+ INVALID_PARAMS,
+ INVALID_REQUEST,
+ INTERNAL_ERROR,
+ METHOD_NOT_FOUND,
+ HEADER_MISMATCH,
+ MISSING_REQUIRED_CLIENT_CAPABILITY,
+ UNSUPPORTED_PROTOCOL_VERSION,
+ SUPPORTED_PROTOCOL_VERSIONS,
+ MODERN_PROTOCOL_VERSIONS
+} from '../schema.ts'
+import type {
+ CacheableResult,
+ ClientCapabilities,
+ DiscoverResult,
+ InputRequests,
+ InputRequiredResult,
+ Result,
+ ServerCapabilities,
+ Task,
+ TaskStatus
+} from '../schema-2026.ts'
+import { META_SERVER_INFO, TASKS_EXTENSION } from '../schema-2026.ts'
+import type { HandlerDependencies } from '../handlers.ts'
+import {
+ createError,
+ createResponse,
+ executeToolCall,
+ emitToolCallComplete,
+ resolveRegisteredTool,
+ handleToolsList,
+ handleResourcesList,
+ handleResourceTemplatesList,
+ handlePromptsList,
+ handleResourcesRead,
+ handlePromptsGet
+} from '../handlers.ts'
+import type { RequestContext } from './request-meta.ts'
+import { supportsTasksExtension } from './request-meta.ts'
+import { InputRequired, requiredCapabilityFor } from './input-required.ts'
+import type { RequestStateSealer } from './request-state.ts'
+import { RequestTooDeepError } from './request-state.ts'
+import { collectHeaderParams, validateToolParamHeaders } from './headers.ts'
+import type { TaskRecord } from '../stores/task-store.ts'
+import { isTerminal } from '../stores/task-store.ts'
+import { principalOf } from '../principal.ts'
+import { validateElicitationRequest, validateElicitationUrl } from '../security.ts'
+import { NO_NOTIFIERS } from './request-stream.ts'
+import { MAX_TIMER_MS, TaskStopped, quotaKeyOf, reserveTask } from '../task-registry.ts'
+
+/** Freshness hints applied to one cacheable operation. */
+export interface CacheHint {
+ ttlMs: number
+ cacheScope: 'public' | 'private'
+}
+
+export interface CachingConfig {
+ discover: CacheHint
+ toolsList: CacheHint
+ promptsList: CacheHint
+ resourcesList: CacheHint
+ resourceTemplatesList: CacheHint
+ resourcesRead: CacheHint
+}
+
+/** Methods `dispatchModern` serves (`subscriptions/listen` is answered by the route). */
+const DISPATCHED_METHODS = new Set([
+ 'server/discover',
+ 'tools/list',
+ 'resources/list',
+ 'resources/templates/list',
+ 'prompts/list',
+ 'tools/call',
+ 'resources/read',
+ 'prompts/get',
+ 'tasks/get',
+ 'tasks/update',
+ 'tasks/cancel'
+])
+
+/** The hint for a result that must not be cached at all. */
+const UNCACHEABLE: CacheHint = { ttlMs: 0, cacheScope: 'private' }
+
+export interface ModernDependencies extends HandlerDependencies {
+ context: RequestContext
+ sealer: RequestStateSealer
+ caching: CachingConfig
+ /** Advertised on `server/discover` and in version errors. */
+ supportedVersions: readonly string[]
+ enableTasks: boolean
+ /**
+ * Whether the transport mirrors body fields into headers. False for stdio,
+ * which has no header layer, so there is nothing to reconcile.
+ */
+ headerLayer: boolean
+}
+
+/**
+ * Methods this revision removed. Answering `-32601` (rather than silently
+ * doing something) is what lets a dual-era client tell a modern server from a
+ * legacy one.
+ */
+const REMOVED_METHODS = new Set([
+ 'initialize',
+ 'ping',
+ 'logging/setLevel',
+ 'resources/subscribe',
+ 'resources/unsubscribe',
+ 'tasks/list',
+ 'tasks/result'
+])
+
+/** Stamp the envelope fields every modern result carries. */
+function complete> (
+ body: T,
+ serverInfo: Implementation | undefined
+): Result {
+ // `resultType` is the dispatcher's to set: a handler returning its own
+ // would otherwise relabel the result, or forge an `input_required` that
+ // skips the sealed-state path.
+ const result: Result = { ...body, resultType: 'complete' }
+ if (serverInfo) {
+ result._meta = { ...(result._meta ?? {}), [META_SERVER_INFO]: serverInfo }
+ }
+ return result
+}
+
+function withCache> (
+ body: T,
+ hint: CacheHint,
+ serverInfo: Implementation | undefined
+): CacheableResult {
+ return {
+ ...complete(body, serverInfo),
+ ttlMs: Math.max(0, hint.ttlMs),
+ cacheScope: hint.cacheScope
+ } as CacheableResult
+}
+
+/**
+ * Rewrap a legacy handler's response in the modern envelope.
+ *
+ * The legacy path answers "not found" for tools, resources and prompts with
+ * `-32601`; this revision requires `-32602` for all three, so remap rather than
+ * duplicate the lookups.
+ */
+function filterInvalidHeaderTools (
+ response: JSONRPCResponse | JSONRPCError,
+ dependencies: ModernDependencies
+): JSONRPCResponse | JSONRPCError {
+ if ('error' in response) return response
+
+ const result = response.result as Record | undefined
+ if (!result || !Array.isArray(result.tools)) return response
+
+ const tools = result.tools.filter((tool) => {
+ if (!tool || typeof tool !== 'object' || !('inputSchema' in tool)) return true
+ const collected = collectHeaderParams((tool as { inputSchema: unknown }).inputSchema)
+ if (collected.ok) return true
+
+ dependencies.app.log.warn({
+ tool: (tool as { name?: unknown }).name,
+ reason: collected.message
+ }, 'Excluded tool with invalid x-mcp-header annotation')
+ return false
+ })
+
+ if (tools.length === result.tools.length) return response
+ return createResponse(response.id, { ...result, tools })
+}
+
+function adapt (
+ response: JSONRPCResponse | JSONRPCError,
+ serverInfo: Implementation | undefined,
+ hint?: CacheHint
+): JSONRPCResponse | JSONRPCError {
+ if ('error' in response) {
+ if (response.error.code === METHOD_NOT_FOUND) {
+ return createError(response.id ?? null, INVALID_PARAMS, response.error.message, response.error.data)
+ }
+ return response
+ }
+
+ const body = (response.result ?? {}) as Record
+ return createResponse(
+ response.id,
+ hint ? withCache(body, hint, serverInfo) : complete(body, serverInfo)
+ )
+}
+
+function unsupportedVersion (id: JSONRPCRequest['id'], requested: string, supported: readonly string[]): JSONRPCError {
+ return createError(
+ id,
+ UNSUPPORTED_PROTOCOL_VERSION,
+ 'Unsupported protocol version',
+ { supported: [...supported], requested }
+ )
+}
+
+function missingCapability (
+ id: JSONRPCRequest['id'],
+ requiredCapabilities: Record
+): JSONRPCError {
+ return createError(
+ id,
+ MISSING_REQUIRED_CLIENT_CAPABILITY,
+ 'The request requires a client capability that was not declared',
+ { requiredCapabilities }
+ )
+}
+
+/**
+ * The client capabilities `inputRequests` would need but the client did not
+ * declare, or `undefined` when it can answer all of them. The server must
+ * never ask for something the client cannot do.
+ */
+function missingInputCapabilities (
+ inputRequests: Record,
+ clientCapabilities: ClientCapabilities
+): Record> | undefined {
+ const missing: Record> = {}
+ const need = (capability: string, sub?: string) => {
+ const entry = missing[capability] ?? (missing[capability] = {})
+ if (sub) entry[sub] = {}
+ }
+
+ for (const entry of Object.values(inputRequests)) {
+ const needed = requiredCapabilityFor(entry as { method?: string })
+ if (!needed) continue
+ const params = (entry as { params?: Record }).params ?? {}
+
+ if (needed === 'elicitation') {
+ // Form and URL mode are declared separately. An empty object is the
+ // backwards-compatible way to declare form mode only, and a request
+ // without `mode` is form mode.
+ const declared = clientCapabilities.elicitation
+ if (declared === undefined) {
+ need('elicitation')
+ } else if (params.mode === 'url') {
+ if (declared.url === undefined) need('elicitation', 'url')
+ } else if (declared.form === undefined && Object.keys(declared).length > 0) {
+ need('elicitation', 'form')
+ }
+ continue
+ }
+
+ if (needed === 'sampling') {
+ const declared = clientCapabilities.sampling
+ if (declared === undefined) {
+ need('sampling')
+ continue
+ }
+ if ((params.tools !== undefined || params.toolChoice !== undefined) && declared.tools === undefined) {
+ need('sampling', 'tools')
+ }
+ if (params.includeContext !== undefined && params.includeContext !== 'none' && declared.context === undefined) {
+ need('sampling', 'context')
+ }
+ continue
+ }
+
+ if (clientCapabilities[needed] === undefined) need(needed)
+ }
+ return Object.keys(missing).length > 0 ? missing : undefined
+}
+
+/**
+ * What is wrong with the `inputRequests` a handler produced, or `undefined`.
+ *
+ * Each entry must be an elicitation, sampling or roots request, and a URL-mode
+ * elicitation must carry a valid URL. Anything else is a bug in the handler,
+ * never something to forward to the client.
+ */
+function invalidInputRequests (inputRequests: Record): string | undefined {
+ for (const [key, entry] of Object.entries(inputRequests)) {
+ const method = (entry as { method?: unknown })?.method
+ if (!requiredCapabilityFor(entry as { method?: string })) {
+ return `input request '${key}' has unsupported method '${method}'`
+ }
+ const params = (entry as { params?: unknown }).params
+ const problem = method === 'elicitation/create'
+ ? invalidElicitation(params)
+ : method === 'sampling/createMessage'
+ ? invalidSampling(params)
+ : undefined
+ if (problem) return `input request '${key}' ${problem}`
+ }
+ return undefined
+}
+
+const FLAT_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean'])
+
+/** An elicitation needs a message, and per mode a flat schema or a safe URL. */
+function invalidElicitation (params: unknown): string | undefined {
+ if (!params || typeof params !== 'object') return 'has no params'
+ const { mode, message, requestedSchema, url } = params as Record
+ if (typeof message !== 'string' || message.length === 0) return 'has no message'
+ try {
+ if (mode === 'url') {
+ if (typeof url !== 'string') return 'has no URL'
+ // The same rules as the legacy path: http(s) only, no credentials.
+ validateElicitationUrl(message, url)
+ return undefined
+ }
+ if (mode !== undefined && mode !== 'form') return `has unknown mode '${String(mode)}'`
+ validateElicitationRequest(message, requestedSchema)
+ } catch (error) {
+ return `is invalid: ${error instanceof Error ? error.message : String(error)}`
+ }
+ // Form mode collects flat data: an object of primitive properties.
+ const schema = requestedSchema as { type?: unknown, properties?: unknown } | undefined
+ if (!schema || schema.type !== 'object' || !schema.properties || typeof schema.properties !== 'object') {
+ return 'needs a requestedSchema of type object'
+ }
+ for (const [name, property] of Object.entries(schema.properties as Record)) {
+ const type = (property as { type?: unknown } | undefined)?.type
+ if (typeof type !== 'string' || !FLAT_SCHEMA_TYPES.has(type)) {
+ return `has requestedSchema property '${name}' that is not a primitive`
+ }
+ }
+ return undefined
+}
+
+function invalidSampling (params: unknown): string | undefined {
+ if (!params || typeof params !== 'object') return 'has no params'
+ const { messages, maxTokens } = params as Record
+ if (!Array.isArray(messages)) return 'has no messages'
+ if (typeof maxTokens !== 'number') return 'has no maxTokens'
+ return undefined
+}
+
+const ELICIT_ACTIONS = new Set(['accept', 'decline', 'cancel'])
+
+/**
+ * What is wrong with the answers the server asked for, if anything. Each is a
+ * result object, and an elicitation result's `action` is one of three values.
+ */
+function invalidAnswers (inputResponses: Record | undefined): string | undefined {
+ for (const [key, value] of Object.entries(inputResponses ?? {})) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return `Invalid "inputResponses.${key}": expected a result object`
+ }
+ const action = (value as { action?: unknown }).action
+ if (action !== undefined && !ELICIT_ACTIONS.has(action as string)) {
+ return `Invalid "inputResponses.${key}.action": expected accept, decline or cancel`
+ }
+ }
+ return undefined
+}
+
+/**
+ * Turn a handler's {@link InputRequired} into the wire result.
+ *
+ * The state is sealed here rather than by the handler so that integrity,
+ * expiry and principal binding cannot be forgotten at a call site.
+ */
+function inputRequired (
+ request: JSONRPCRequest,
+ thrown: InputRequired,
+ dependencies: ModernDependencies
+): JSONRPCResponse | JSONRPCError {
+ const { context, sealer, serverInfo, authContext } = dependencies
+
+ if (thrown.inputRequests) {
+ const invalid = invalidInputRequests(thrown.inputRequests)
+ if (invalid) {
+ dependencies.app.log.error({ method: request.method, reason: invalid }, 'Handler produced invalid input requests')
+ return createError(request.id, INTERNAL_ERROR, 'Internal server error')
+ }
+ const missing = missingInputCapabilities(thrown.inputRequests, context.clientCapabilities)
+ if (missing) return missingCapability(request.id, missing)
+ }
+
+ let requestState: string
+ try {
+ requestState = sealer.seal({
+ principal: principalOf(authContext),
+ method: request.method,
+ params: request.params,
+ payload: thrown.state ?? null,
+ inputKeys: Object.keys(thrown.inputRequests ?? {})
+ })
+ } catch (error) {
+ dependencies.app.log.warn({ err: error, method: request.method }, 'Could not seal request state')
+ if (error instanceof RequestTooDeepError) {
+ return createError(request.id, INVALID_PARAMS, `Invalid request: ${error.message}`)
+ }
+ // Sealing refuses an unidentified caller when the deployment identifies
+ // callers. Answer this request rather than escaping to a bare 500.
+ return createError(request.id, INVALID_REQUEST, 'This request needs additional input, which requires an authenticated caller')
+ }
+
+ const result: InputRequiredResult = {
+ resultType: 'input_required',
+ ...(thrown.inputRequests ? { inputRequests: thrown.inputRequests } : {}),
+ requestState
+ }
+
+ if (serverInfo) {
+ result._meta = { [META_SERVER_INFO]: serverInfo }
+ }
+
+ return createResponse(request.id, result)
+}
+
+/**
+ * Open the `requestState` a client echoed back, if any.
+ *
+ * Failing verification is reported as invalid params: the state is either
+ * forged, stale, or belongs to a different caller or request, and in every case
+ * the right move is for the client to start the exchange again.
+ */
+function openRequestState (
+ request: JSONRPCRequest,
+ dependencies: ModernDependencies
+): { ok: true, payload: unknown, inputKeys?: string[] } | { ok: false, error: JSONRPCError } {
+ const state = (request.params as { requestState?: unknown } | undefined)?.requestState
+ if (state === undefined) return { ok: true, payload: undefined }
+
+ if (typeof state !== 'string') {
+ return { ok: false, error: createError(request.id, INVALID_PARAMS, 'Invalid "requestState": expected a string') }
+ }
+
+ const opened = dependencies.sealer.open(state, {
+ principal: principalOf(dependencies.authContext),
+ method: request.method,
+ params: request.params
+ })
+
+ if (!opened.ok) {
+ dependencies.app.log.warn({ reason: opened.reason, method: request.method }, 'Rejected MRTR request state')
+ return { ok: false, error: createError(request.id, INVALID_PARAMS, `Invalid "requestState": ${opened.reason}`) }
+ }
+
+ return { ok: true, payload: opened.claims.payload, inputKeys: opened.claims.inputKeys ?? [] }
+}
+
+/**
+ * Attach the MRTR fields so handlers see them on their context.
+ *
+ * `inputResponses` comes off the JSON-RPC params, not the HTTP request — the
+ * two are easy to confuse here because `dependencies.request` is the Fastify
+ * one. Only answers to keys the server actually asked for, as recorded in the
+ * sealed state, reach the handler; anything else is information the server
+ * does not recognise and ignores.
+ */
+function withMrtrContext (
+ request: JSONRPCRequest,
+ dependencies: ModernDependencies,
+ opened: { payload: unknown, inputKeys?: string[] }
+): ModernDependencies {
+ const params = request.params as { inputResponses?: Record } | undefined
+ const asked = new Set(opened.inputKeys ?? [])
+ const inputResponses = params?.inputResponses === undefined
+ ? undefined
+ : Object.fromEntries(Object.entries(params.inputResponses).filter(([key]) => asked.has(key)))
+
+ return {
+ ...dependencies,
+ strictErrors: true,
+ mrtr: {
+ inputResponses,
+ requestState: opened.payload
+ }
+ }
+}
+
+/** Is `inputResponses`, when present, an object as `InputResponses` requires? */
+function invalidInputResponses (params: unknown): string | undefined {
+ const value = (params as { inputResponses?: unknown } | undefined)?.inputResponses
+ if (value === undefined) return undefined
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return 'Invalid "inputResponses": expected an object'
+ }
+ return undefined
+}
+
+/* ------------------------------------------------------------------ */
+/* Tasks extension */
+/* ------------------------------------------------------------------ */
+
+const DEFAULT_POLL_INTERVAL_MS = 1000
+
+/**
+ * How many times a task's handler may come back asking for more input before we
+ * give up on it. Bounded so a handler that keeps asking cannot pin the task for
+ * its whole ttl.
+ */
+const MAX_TASK_INPUT_ROUNDS = 8
+
+/** Project a stored record onto the extension's wire `Task`. */
+function toExtensionTask (record: TaskRecord): Task {
+ return {
+ taskId: record.taskId,
+ status: record.status as TaskStatus,
+ ...(record.statusMessage !== undefined ? { statusMessage: record.statusMessage } : {}),
+ createdAt: record.createdAt,
+ lastUpdatedAt: record.lastUpdatedAt,
+ ttlMs: record.ttl ?? null,
+ pollIntervalMs: record.pollInterval ?? DEFAULT_POLL_INTERVAL_MS
+ }
+}
+
+/**
+ * The full task state `tasks/get` returns, with the status-specific payload
+ * inlined. The 2025-11-25 split between `tasks/get` and a blocking
+ * `tasks/result` is gone: everything a poller needs is in one response.
+ */
+function toDetailedTask (record: TaskRecord): Record {
+ const task: Record = { ...toExtensionTask(record) }
+
+ switch (record.status) {
+ case 'input_required':
+ task.inputRequests = record.inputRequests ?? {}
+ break
+ case 'completed':
+ task.result = record.outcome && 'result' in record.outcome ? record.outcome.result : {}
+ break
+ case 'failed':
+ task.error = record.outcome && 'error' in record.outcome
+ ? record.outcome.error
+ : { code: INTERNAL_ERROR, message: record.statusMessage ?? 'Task failed' }
+ break
+ }
+
+ return task
+}
+
+/**
+ * A task is reachable only by the subject that created it, when the deployment
+ * can identify subjects at all. Without identity resolution the random task id
+ * is the capability, which is why we never enumerate tasks.
+ */
+function taskVisibleTo (record: TaskRecord | null, dependencies: ModernDependencies): TaskRecord | null {
+ if (!record) return null
+ // 2025-11-25 core tasks share the store but not the protocol.
+ if (record.era !== 'modern') return null
+ const identifiesRequestors = dependencies.opts.authorization?.enabled === true ||
+ dependencies.opts.resolveAuthorizationContext !== undefined
+ if (!identifiesRequestors) return record
+
+ const subject = principalOf(dependencies.authContext)
+ if (subject === undefined || record.authSubject !== subject) return null
+ return record
+}
+
+/**
+ * Load a task for a `tasks/*` request, failing it first if the instance that
+ * was running it has stopped renewing its lease. A crashed worker would
+ * otherwise leave the task `working` until its ttl, and then just vanish.
+ */
+async function loadTask (taskId: string, dependencies: ModernDependencies): Promise {
+ const store = dependencies.taskStore!
+ const record = await store.get(taskId)
+ if (!record || isTerminal(record.status) || record.leaseExpiresAt === undefined) return record
+ const failed = await store.expireStaleLease(
+ taskId,
+ WORKER_LOST,
+ createError(null, INTERNAL_ERROR, WORKER_LOST)
+ )
+ if (failed) {
+ dependencies.app.log.warn({ taskId }, 'Task failed: the instance running it stopped renewing its lease')
+ dependencies.taskWaiters?.notify(failed)
+ return failed
+ }
+ return record
+}
+
+const WORKER_LOST = 'The server running this task stopped before it finished'
+
+async function handleTasksGet (
+ request: JSONRPCRequest,
+ dependencies: ModernDependencies
+): Promise {
+ const taskId = (request.params as { taskId?: unknown } | undefined)?.taskId
+ if (typeof taskId !== 'string') {
+ return createError(request.id, INVALID_PARAMS, 'Invalid "taskId": expected a string')
+ }
+
+ const record = taskVisibleTo(await loadTask(taskId, dependencies), dependencies)
+ if (!record) {
+ return createError(request.id, INVALID_PARAMS, `Task '${taskId}' not found`)
+ }
+
+ return createResponse(request.id, complete(toDetailedTask(record), dependencies.serverInfo))
+}
+
+async function handleTasksUpdate (
+ request: JSONRPCRequest,
+ dependencies: ModernDependencies
+): Promise {
+ const params = request.params as { taskId?: unknown, inputResponses?: unknown } | undefined
+ if (typeof params?.taskId !== 'string') {
+ return createError(request.id, INVALID_PARAMS, 'Invalid "taskId": expected a string')
+ }
+ if (!params.inputResponses || typeof params.inputResponses !== 'object' || Array.isArray(params.inputResponses)) {
+ return createError(request.id, INVALID_PARAMS, 'Invalid "inputResponses": expected an object')
+ }
+
+ const record = taskVisibleTo(await loadTask(params.taskId, dependencies), dependencies)
+ if (!record) {
+ return createError(request.id, INVALID_PARAMS, `Task '${params.taskId}' not found`)
+ }
+
+ // Atomically move new answers into the store-backed publication outbox.
+ // Previously this only marked keys answered, so a broker failure made the
+ // values unrecoverable and a retry was silently ignored.
+ const update = await dependencies.taskStore!.updateInputResponses(
+ params.taskId,
+ params.inputResponses as Record,
+ randomUUID()
+ )
+ if (!update) {
+ return createError(request.id, INVALID_PARAMS, `Task '${params.taskId}' not found`)
+ }
+
+ const deliveries = new Map>()
+ for (const [key, value] of Object.entries(update.responses)) {
+ const deliveryId = update.responseIds[key]
+ if (!deliveryId) throw new Error(`Task input '${key}' has no delivery id`)
+ const batch = deliveries.get(deliveryId) ?? {}
+ Object.defineProperty(batch, key, {
+ value,
+ enumerable: true,
+ configurable: true,
+ writable: true
+ })
+ deliveries.set(deliveryId, batch)
+ }
+
+ for (const [deliveryId, responses] of deliveries) {
+ const keys = Object.keys(responses)
+ dependencies.app.log.debug({ taskId: params.taskId, keys, deliveryId }, 'Publishing task input responses')
+
+ // Publish the durable values but leave them in the outbox: the broker
+ // accepting a message does not mean the waiting worker received it. The
+ // worker acknowledges once it has the answers, and reads the outbox itself
+ // if a publication goes missing, so a failed publish (a broker outage, or
+ // this instance shutting down) loses nothing and the update has succeeded.
+ // A retry republishes with the same delivery id, which receivers
+ // deduplicate.
+ try {
+ await dependencies.taskInputs?.publish(params.taskId, responses, deliveryId)
+ } catch (error) {
+ dependencies.app.log.debug({ err: error, taskId: params.taskId }, 'Could not publish task input; the worker will read it from the store')
+ }
+ }
+
+ return createResponse(request.id, complete({}, dependencies.serverInfo))
+}
+
+async function handleTasksCancel (
+ request: JSONRPCRequest,
+ dependencies: ModernDependencies
+): Promise {
+ const taskId = (request.params as { taskId?: unknown } | undefined)?.taskId
+ if (typeof taskId !== 'string') {
+ return createError(request.id, INVALID_PARAMS, 'Invalid "taskId": expected a string')
+ }
+
+ const record = taskVisibleTo(await loadTask(taskId, dependencies), dependencies)
+ if (!record) {
+ return createError(request.id, INVALID_PARAMS, `Task '${taskId}' not found`)
+ }
+
+ // Cancellation is cooperative: acknowledge the intent, and only move the task
+ // if it has not already settled. A task that finished first stays finished.
+ // A retry of an already-cancelled task republishes cancellation so a transient
+ // broker failure cannot leave the owning instance parked until its ttl.
+ let publishCancellation = record.status === 'cancelled'
+ if (!isTerminal(record.status)) {
+ try {
+ const cancelled = await dependencies.taskStore!.updateStatus(taskId, 'cancelled', {
+ statusMessage: 'Cancelled by the requestor',
+ inputRequests: null,
+ clearPendingInputResponses: true
+ })
+ if (cancelled) {
+ dependencies.taskWaiters?.notify(cancelled)
+ publishCancellation = true
+ }
+ } catch (error) {
+ dependencies.app.log.debug({ err: error, taskId }, 'Task settled before cancellation took effect')
+ // Another cancellation may have won but failed to publish. Re-read the
+ // durable state so this successful retry republishes instead of silently
+ // leaving the owning waiter blocked.
+ const current = await dependencies.taskStore!.get(taskId)
+ publishCancellation = current?.status === 'cancelled'
+ }
+ }
+ if (publishCancellation) {
+ // The cancellation is already durable; a worker that misses this message
+ // still learns of it when it next renews its lease.
+ try {
+ await dependencies.taskInputs?.cancel(taskId)
+ } catch (error) {
+ dependencies.app.log.debug({ err: error, taskId }, 'Could not publish task cancellation; the worker will read it from the store')
+ }
+ }
+
+ return createResponse(request.id, complete({}, dependencies.serverInfo))
+}
+
+/** What a task's handler resumes with after an input round. */
+interface TaskResume {
+ inputResponses: Record
+ requestState: unknown
+}
+
+const DEFAULT_TASK_LEASE_MS = 15_000
+
+/**
+ * Wait until every key of a parked task's current input round is answered.
+ *
+ * A client may answer the outstanding requests in pieces, and resuming the
+ * handler on the first piece would leave it to re-ask for the rest. So the
+ * answers accumulate here until the round is complete, the task ends, or its
+ * time runs out.
+ */
+async function awaitTaskRound (
+ taskId: string,
+ keys: string[],
+ timeoutMs: number,
+ stopped: AbortSignal,
+ dependencies: ModernDependencies
+): Promise> {
+ const deadline = Date.now() + timeoutMs
+ const received: Record = {}
+ while (!keys.every(key => Object.hasOwn(received, key))) {
+ const remaining = deadline - Date.now()
+ if (remaining <= 0) throw new Error('timed out waiting for client input')
+ const batch = await awaitTaskInput(taskId, remaining, stopped, dependencies)
+ for (const [key, value] of Object.entries(batch)) {
+ Object.defineProperty(received, key, { value, enumerable: true, configurable: true, writable: true })
+ }
+ }
+ return received
+}
+
+/**
+ * Wait for the next answers to a parked task's current input round.
+ *
+ * The broker is the fast path, but a resolved publication only means the
+ * broker took the message: it can still be lost on the way here, say while this
+ * instance's subscriber is reconnecting. `tasks/update` therefore leaves the
+ * answers in the task store's outbox, and this reads that outbox every poll
+ * interval as well. The same read notices a task that ended while parked, so a
+ * cancellation whose broker message was lost (or that came through the legacy
+ * `tasks/cancel`) still releases the worker. Whichever source supplies the
+ * answers, the outbox entries are acknowledged only once they are in hand.
+ */
+async function awaitTaskInput (
+ taskId: string,
+ timeoutMs: number,
+ stopped: AbortSignal,
+ dependencies: ModernDependencies
+): Promise> {
+ const taskStore = dependencies.taskStore!
+ const taskInputs = dependencies.taskInputs!
+ const settled = new AbortController()
+ const signal = AbortSignal.any([AbortSignal.timeout(timeoutMs), settled.signal, stopped])
+
+ let timer: NodeJS.Timeout | undefined
+ const fromOutbox = new Promise>((resolve, reject) => {
+ const check = async () => {
+ try {
+ const task = await taskStore.get(taskId)
+ if (!task || isTerminal(task.status)) {
+ reject(new Error('task ended while waiting for input'))
+ return
+ }
+ const pending = currentRoundResponses(task)
+ if (pending) {
+ // A late broker copy of these must not reach a later round.
+ for (const deliveryId of new Set(Object.values(pending.ids))) {
+ taskInputs.markConsumed(taskId, deliveryId)
+ }
+ resolve(pending.responses)
+ return
+ }
+ } catch (error) {
+ dependencies.app.log.debug({ err: error, taskId }, 'Could not read the task input outbox')
+ }
+ if (!signal.aborted) timer = setTimeout(check, DEFAULT_POLL_INTERVAL_MS)
+ }
+ timer = setTimeout(check, DEFAULT_POLL_INTERVAL_MS)
+ signal.addEventListener('abort', () => {
+ clearTimeout(timer)
+ reject(new Error('aborted'))
+ }, { once: true })
+ })
+
+ let responses: Record
+ try {
+ responses = await Promise.race([taskInputs.wait(taskId, signal), fromOutbox])
+ } finally {
+ settled.abort()
+ }
+
+ try {
+ const ids = currentRoundResponses(await taskStore.get(taskId))?.ids ?? {}
+ const received = Object.fromEntries(Object.keys(responses)
+ .filter(key => ids[key] !== undefined)
+ .map(key => [key, ids[key]]))
+ await taskStore.acknowledgeInputResponses(taskId, received)
+ } catch (error) {
+ // The entries stay in the outbox, scoped to a round that is now over.
+ dependencies.app.log.debug({ err: error, taskId }, 'Could not acknowledge task input responses')
+ }
+
+ return responses
+}
+
+/** The outbox entries answering the task's current input round, if any. */
+function currentRoundResponses (
+ task: TaskRecord | null
+): { responses: Record, ids: Record } | undefined {
+ if (!task?.pendingInputResponses) return undefined
+
+ const round = task.inputRequestRound ?? 0
+ const responses: Record = {}
+ const ids: Record = {}
+ for (const [key, value] of Object.entries(task.pendingInputResponses)) {
+ const id = task.pendingInputResponseIds?.[key]
+ if (id === undefined) continue
+ if ((task.pendingInputResponseRounds?.[key] ?? round) !== round) continue
+ responses[key] = value
+ ids[key] = id
+ }
+ return Object.keys(responses).length > 0 ? { responses, ids } : undefined
+}
+
+/**
+ * Give each of a handler's input requests a key not yet used on this task.
+ *
+ * Keys on the wire must stay unique for the task's lifetime, so a client can
+ * tell a new question from a replay. A handler, though, may reasonably ask
+ * again under the same key (a re-prompt after a declined answer, or an
+ * MRTR-style handler re-asking for everything it still lacks), so a reused key
+ * gets a fresh wire key and its answer is mapped back.
+ */
+function assignWireKeys (
+ inputRequests: Record,
+ used: Set
+): { wire: Record, handlerKeys: Map } {
+ const wire: Record = {}
+ const handlerKeys = new Map()
+ for (const [key, value] of Object.entries(inputRequests)) {
+ let wireKey = key
+ for (let attempt = 2; used.has(wireKey); attempt++) wireKey = `${key}~${attempt}`
+ used.add(wireKey)
+ handlerKeys.set(wireKey, key)
+ Object.defineProperty(wire, wireKey, { value, enumerable: true, configurable: true, writable: true })
+ }
+ return { wire, handlerKeys }
+}
+
+/**
+ * Run a tool call as a task and answer immediately with a `CreateTaskResult`.
+ *
+ * The task is created before we respond, so the `tasks/get` the client makes
+ * next always resolves. Returns `undefined` when no task can be created right
+ * now (the instance is at its task limit, the store is full, or the caller
+ * cannot own one), leaving the caller to run the call synchronously or refuse.
+ */
+async function runAsTask (
+ request: JSONRPCRequest,
+ execute: (resume: TaskResume | undefined, signal: AbortSignal) => Promise,
+ dependencies: ModernDependencies
+): Promise {
+ const { taskStore, taskWaiters, taskInputs, app, opts } = dependencies
+
+ // When the deployment identifies callers, a task is visible only to its
+ // creator. One created for an unidentified caller would be unreachable.
+ const identifiesCallers = opts.authorization?.enabled === true || opts.resolveAuthorizationContext !== undefined
+ if (identifiesCallers && dependencies.authContext?.userId === undefined) return undefined
+
+ const createdAt = Date.now()
+ const now = new Date(createdAt).toISOString()
+ const ttl = Math.min(opts.taskDefaultTtlMs ?? 60_000, opts.taskMaxTtlMs ?? 3600_000)
+ const record: TaskRecord = {
+ taskId: randomUUID(),
+ status: 'working',
+ createdAt: now,
+ lastUpdatedAt: now,
+ ttl,
+ pollInterval: DEFAULT_POLL_INTERVAL_MS,
+ method: request.method,
+ authSubject: principalOf(dependencies.authContext),
+ era: 'modern',
+ // Leased from the start, so a worker that dies before its first renewal is
+ // still reaped. The renewal right after creation switches to the store's clock.
+ leaseExpiresAt: Date.now() + (opts.taskLeaseMs ?? DEFAULT_TASK_LEASE_MS)
+ }
+
+ // A task outlives the request that created it, so its handler must not see
+ // that request's disconnect. It is stopped by tasks/cancel, by its ttl, or
+ // by a shutdown, and the reason becomes its recorded failure.
+ const stopController = new AbortController()
+ const stop = (reason: TaskStopped) => {
+ if (!stopController.signal.aborted) stopController.abort(reason)
+ }
+ const stopped = stopController.signal
+ const stopMessage = () => stopped.reason instanceof TaskStopped ? stopped.reason.message : 'Task stopped'
+
+ // Reserve the slot before the first await, so concurrent requests cannot all
+ // pass the limit check before any of them is counted.
+ const reservation = reserveTask(taskStore!, record.taskId, quotaKeyOf(dependencies.authContext), opts)
+ if (!reservation.ok) {
+ app.log.warn({ reason: reservation.reason }, 'Not creating a task')
+ return undefined
+ }
+ const live = reservation.live
+ live.stop = stop
+
+ try {
+ await taskStore!.create(record)
+ } catch (error) {
+ reservation.release()
+ app.log.warn({ err: error }, 'Could not create task')
+ return undefined
+ }
+ const expiresAt = createdAt + ttl
+
+ // The lease lets any instance tell a crashed worker from a slow one. Each
+ // renewal also reports the task's status, catching a cancellation whose
+ // broker message was lost.
+ const leaseMs = opts.taskLeaseMs ?? DEFAULT_TASK_LEASE_MS
+ const renew = async () => {
+ try {
+ const current = await taskStore!.renewLease(record.taskId, leaseMs)
+ if (current === null || isTerminal(current)) {
+ stop(new TaskStopped(current === 'cancelled' ? 'Task cancelled' : 'Task ended'))
+ }
+ } catch (error) {
+ app.log.debug({ err: error, taskId: record.taskId }, 'Could not renew task lease')
+ }
+ }
+ await renew()
+ const leaseTimer = setInterval(renew, Math.max(1, Math.floor(leaseMs / 3))).unref()
+
+ // Past its ttl the task is gone for the client; stop the handler and free
+ // the slot even if the handler ignores the signal.
+ const stopListening = taskInputs?.onCancel(record.taskId, () => stop(new TaskStopped('Task cancelled')))
+ // A handler that ignores its signal may never settle; it must not keep
+ // renewing a lease or listening for a task that has expired. Node clamps
+ // longer timers to 1ms, so a ttl beyond the timer range gets none.
+ const ttlTimer = ttl <= MAX_TIMER_MS
+ ? setTimeout(() => {
+ stop(new TaskStopped('Task expired before it finished'))
+ reservation.release()
+ clearInterval(leaseTimer)
+ stopListening?.()
+ taskInputs?.forget(record.taskId)
+ }, ttl).unref()
+ : undefined
+ taskInputs?.claim(record.taskId)
+
+ const execution = (async () => {
+ let outcome: TaskRecord['outcome']
+ let status: 'completed' | 'failed' = 'completed'
+ let statusMessage: string | undefined
+
+ // Answers gathered so far, keyed as the handler asked for them. A handler
+ // may ask more than once, so they accumulate across rounds, and a later
+ // answer to a key the handler asked again replaces the earlier one.
+ let gathered: Record | undefined
+ // What the handler saved in `InputRequired.state` before its last round,
+ // handed back as `context.requestState` exactly as an MRTR retry would.
+ let state: unknown
+ const usedWireKeys = new Set()
+
+ const fail = (message: string, error?: JSONRPCError) => {
+ status = 'failed'
+ statusMessage = message
+ outcome = error ?? createError(request.id, INTERNAL_ERROR, message)
+ }
+
+ // Bound the number of rounds: a handler that asks for the same thing
+ // forever would otherwise pin the task until its ttl elapses.
+ for (let round = 0; round <= MAX_TASK_INPUT_ROUNDS; round++) {
+ try {
+ // A completed task's `result` is what the call would have returned
+ // synchronously, envelope included.
+ const response = adapt(
+ await execute(
+ (gathered || state !== undefined) ? { inputResponses: gathered ?? {}, requestState: state } : undefined,
+ stopped
+ ),
+ dependencies.serverInfo
+ )
+ outcome = response
+ if ('error' in response) {
+ status = 'failed'
+ statusMessage = response.error.message
+ }
+ // Whatever a stopped handler returned (often an error it caught from
+ // the aborted signal), the task ends for the reason it was stopped.
+ if (stopped.aborted) fail(stopMessage())
+ break
+ } catch (error: any) {
+ if (stopped.aborted) {
+ fail(stopMessage())
+ break
+ }
+ if (!(error instanceof InputRequired) || !taskInputs || round >= MAX_TASK_INPUT_ROUNDS) {
+ fail(`Tool execution failed: ${error?.message ?? error}`)
+ break
+ }
+
+ // Nothing to ask the client: the handler only wants to resume with
+ // its state, which a task can do at once, like an immediate MRTR retry.
+ const requests = error.inputRequests ?? {}
+ if (Object.keys(requests).length === 0) {
+ state = error.state
+ continue
+ }
+
+ const invalid = invalidInputRequests(requests)
+ if (invalid) {
+ app.log.error({ taskId: record.taskId, reason: invalid }, 'Task handler produced invalid input requests')
+ fail('Internal server error')
+ break
+ }
+
+ // Parking a request the client cannot answer would only leave the
+ // task stuck in `input_required` until it expires.
+ const missing = missingInputCapabilities(requests, dependencies.context.clientCapabilities)
+ if (missing) {
+ const refused = missingCapability(request.id, missing)
+ fail(refused.error.message, refused)
+ break
+ }
+
+ // The ttl bounds the task's whole lifetime, not each round.
+ const remaining = expiresAt - Date.now()
+ if (remaining <= 0) {
+ fail('Timed out waiting for client input')
+ break
+ }
+
+ const { wire, handlerKeys } = assignWireKeys(requests as Record, usedWireKeys)
+ try {
+ const parked = await taskStore!.updateStatus(record.taskId, 'input_required', {
+ statusMessage: error.message,
+ inputRequests: wire,
+ incrementInputRequestRound: true
+ })
+ if (parked) taskWaiters?.notify(parked)
+
+ const responses = await awaitTaskRound(record.taskId, [...handlerKeys.keys()], remaining, stopped, dependencies)
+ gathered = { ...(gathered ?? {}) }
+ for (const [wireKey, value] of Object.entries(responses)) {
+ const handlerKey = handlerKeys.get(wireKey)
+ if (handlerKey === undefined) continue
+ Object.defineProperty(gathered, handlerKey, { value, enumerable: true, configurable: true, writable: true })
+ }
+ state = error.state
+
+ // Nothing is outstanding now, so nothing may be answered while the
+ // handler runs, and the round's prompt no longer describes the task.
+ await taskStore!.updateStatus(record.taskId, 'working', { inputRequests: null, statusMessage: null })
+ continue
+ } catch (waitError) {
+ // Stopped (cancelled, expired, shutting down) or the wait timed out.
+ fail(stopped.aborted ? stopMessage() : 'Timed out waiting for client input')
+ app.log.debug({ err: waitError, taskId: record.taskId }, 'Task input wait ended without responses')
+ break
+ }
+ }
+ }
+
+ try {
+ const updated = await taskStore!.updateStatus(record.taskId, status, {
+ statusMessage: statusMessage ?? null,
+ outcome,
+ inputRequests: null,
+ clearPendingInputResponses: true
+ })
+ if (updated) taskWaiters?.notify(updated)
+ } catch (error) {
+ app.log.debug({ err: error, taskId: record.taskId }, 'Could not record task outcome')
+ } finally {
+ clearInterval(leaseTimer)
+ clearTimeout(ttlTimer)
+ stopListening?.()
+ taskInputs?.forget(record.taskId)
+ }
+ })()
+
+ live.done = execution
+ .catch((error) => {
+ app.log.error({ err: error, taskId: record.taskId }, 'Task execution failed unexpectedly')
+ })
+ .finally(() => {
+ reservation.release()
+ })
+
+ const result: Result = {
+ resultType: 'task',
+ ...toExtensionTask(record)
+ }
+ if (dependencies.serverInfo) {
+ result._meta = { [META_SERVER_INFO]: dependencies.serverInfo }
+ }
+
+ return createResponse(request.id, result)
+}
+
+/* ------------------------------------------------------------------ */
+/* tools/call */
+/* ------------------------------------------------------------------ */
+
+async function modernToolsCall (
+ request: JSONRPCRequest,
+ dependencies: ModernDependencies
+): Promise {
+ const params = request.params as { name?: unknown, arguments?: unknown } | undefined
+ if (typeof params?.name !== 'string') {
+ return createError(request.id, INVALID_PARAMS, 'Invalid tool call parameters: "name" is required')
+ }
+
+ if (params.arguments !== undefined &&
+ (!params.arguments || typeof params.arguments !== 'object' || Array.isArray(params.arguments))) {
+ return createError(request.id, INVALID_PARAMS, 'Invalid tool call parameters: "arguments" must be an object')
+ }
+
+ const startedAt = performance.now()
+ const args = (params.arguments ?? {}) as Record
+
+ // Resolve through the shared authorization gate. Denied and unknown tools
+ // are deliberately indistinguishable so authorization cannot leak names.
+ const resolved = await resolveRegisteredTool(params.name, dependencies)
+ if (!resolved.ok) {
+ const observed = resolved.reason === 'access-denied'
+ ? { ok: false as const, reason: 'not-found' as const }
+ : resolved
+ await emitToolCallComplete('json-rpc', params.name, args, observed, startedAt, dependencies)
+ return createError(request.id, INVALID_PARAMS, `Unknown tool: ${params.name}`)
+ }
+ const tool = resolved.tool
+
+ // Any parameter the tool mirrors into a header must agree with the body.
+ if ('inputSchema' in tool.definition) {
+ // A malformed `x-mcp-header` annotation is the server's own bug, not a
+ // client header mismatch. `tools/list` already hides such a tool, so
+ // calling it looks the same as calling any other unknown tool.
+ if (!collectHeaderParams(tool.definition.inputSchema).ok) {
+ dependencies.app.log.warn({ tool: params.name }, 'Refusing call to tool with an invalid x-mcp-header annotation')
+ await emitToolCallComplete('json-rpc', params.name, args, { ok: false, reason: 'not-found' }, startedAt, dependencies)
+ return createError(request.id, INVALID_PARAMS, `Unknown tool: ${params.name}`)
+ }
+
+ const headerCheck = dependencies.headerLayer
+ ? validateToolParamHeaders(dependencies.request.headers, tool.definition.inputSchema, params.arguments)
+ : { ok: true as const }
+ if (!headerCheck.ok) {
+ await emitToolCallComplete('json-rpc', params.name, args, {
+ ok: false,
+ reason: 'invalid-arguments',
+ detail: headerCheck.message
+ }, startedAt, dependencies)
+ return createError(request.id, HEADER_MISMATCH, headerCheck.message)
+ }
+ }
+
+ const taskSupport = (tool.definition as any).execution?.taskSupport ?? 'forbidden'
+ const clientHasTasks = supportsTasksExtension(dependencies.context.clientCapabilities)
+ const tasksAvailable = dependencies.enableTasks && dependencies.taskStore !== undefined
+
+ if (taskSupport === 'required') {
+ if (!tasksAvailable) {
+ await emitToolCallComplete('json-rpc', params.name, args, { ok: false, reason: 'task-required' }, startedAt, dependencies)
+ return createError(request.id, INVALID_PARAMS, `Tool '${params.name}' requires task-augmented execution, which is not enabled`)
+ }
+ if (!clientHasTasks) {
+ await emitToolCallComplete('json-rpc', params.name, args, { ok: false, reason: 'task-required' }, startedAt, dependencies)
+ return missingCapability(request.id, { extensions: { [TASKS_EXTENSION]: {} } })
+ }
+ }
+
+ const run = (
+ resume: TaskResume | undefined,
+ observation: { source: 'json-rpc' | 'task', startedAt: number },
+ signal?: AbortSignal
+ ) => executeToolCall(
+ request,
+ tool,
+ { name: params.name as string, arguments: params.arguments as Record | undefined },
+ undefined,
+ // On a task's later rounds the answers come from `tasks/update` and the
+ // state from the handler's last `InputRequired`, not from the original
+ // request, so the handler context is rebuilt around them. A task also
+ // brings its own cancellation signal.
+ {
+ ...dependencies,
+ ...(resume ? { mrtr: resume } : {}),
+ // A task's request has already been answered, so it has no stream to
+ // report on; reporting there would take over a reply in flight.
+ ...(signal ? { signal, notifiers: NO_NOTIFIERS } : {})
+ },
+ observation
+ )
+
+ // 2026-07-28 lets the server decide: a client that declared the extension may
+ // get a task handle back without having asked for one per request.
+ if (tasksAvailable && clientHasTasks && taskSupport !== 'forbidden') {
+ const task = await runAsTask(
+ request,
+ (resume, signal) => run(resume, { source: 'task', startedAt: performance.now() }, signal),
+ dependencies
+ )
+ if (task) return task
+ // No task could be created right now. A tool that merely supports tasks
+ // still works synchronously; one that requires a task cannot run.
+ if (taskSupport === 'required') {
+ await emitToolCallComplete('json-rpc', params.name, args, { ok: false, reason: 'task-required' }, startedAt, dependencies)
+ return createError(request.id, INVALID_REQUEST, `Tool '${params.name}' requires a task, and none can be created for this request`)
+ }
+ }
+
+ return adapt(await run(undefined, { source: 'json-rpc', startedAt }), dependencies.serverInfo)
+}
+
+/* ------------------------------------------------------------------ */
+/* Dispatch */
+/* ------------------------------------------------------------------ */
+
+export function buildServerCapabilities (
+ base: ServerCapabilities,
+ options: { enableTasks: boolean }
+): ServerCapabilities {
+ const capabilities: ServerCapabilities = { ...base }
+
+ // The 2025-11-25 core `tasks` capability has no meaning in this revision;
+ // support is advertised as an extension instead.
+ delete (capabilities as Record).tasks
+ // Nothing on this path answers `completion/complete`, so advertising it
+ // would be a promise the server cannot keep. `logging` stays: handlers log
+ // to the request's stream through `context.log`.
+ delete (capabilities as Record).completions
+
+ if (options.enableTasks) {
+ capabilities.extensions = { ...capabilities.extensions, [TASKS_EXTENSION]: {} }
+ }
+
+ return capabilities
+}
+
+/** Is this the client coming back with answers to an `InputRequiredResult`? */
+function isMrtrRetry (request: JSONRPCRequest): boolean {
+ const params = request.params as { inputResponses?: unknown, requestState?: unknown } | undefined
+ return params?.inputResponses !== undefined || params?.requestState !== undefined
+}
+
+function handleDiscover (
+ request: JSONRPCRequest,
+ dependencies: ModernDependencies
+): JSONRPCResponse {
+ const result: DiscoverResult = {
+ ...withCache({
+ supportedVersions: [...dependencies.supportedVersions],
+ capabilities: buildServerCapabilities(dependencies.capabilities, {
+ enableTasks: dependencies.enableTasks
+ }),
+ ...(dependencies.opts.instructions ? { instructions: dependencies.opts.instructions } : {})
+ }, dependencies.caching.discover, dependencies.serverInfo)
+ } as DiscoverResult
+
+ return createResponse(request.id, result)
+}
+
+/**
+ * Dispatch one modern request.
+ *
+ * `subscriptions/listen` is not handled here: it answers with a long-lived
+ * stream rather than a value, so the transport owns it.
+ */
+export async function dispatchModern (
+ request: JSONRPCRequest,
+ dependencies: ModernDependencies
+): Promise {
+ const { app, context, supportedVersions } = dependencies
+
+ app.log.info({
+ method: request.method,
+ id: request.id,
+ protocolVersion: context.protocolVersion,
+ client: context.clientInfo?.name
+ }, `MCP request: ${request.method}`)
+
+ // A legacy revision named in `_meta` is still unsupported *here*: 2024-11-05
+ // has no notion of `resultType` or caching hints, so serving it a modern
+ // envelope would be worse than refusing. The error still advertises every
+ // version we speak, so the client can drop back to the handshake.
+ if (!MODERN_PROTOCOL_VERSIONS.includes(context.protocolVersion as never)) {
+ return unsupportedVersion(request.id, context.protocolVersion, supportedVersions)
+ }
+
+ if (REMOVED_METHODS.has(request.method)) {
+ return createError(
+ request.id,
+ METHOD_NOT_FOUND,
+ `Method '${request.method}' was removed in protocol version ${context.protocolVersion}`
+ )
+ }
+
+ // An unknown method is -32601 whatever else is wrong with the request.
+ if (!DISPATCHED_METHODS.has(request.method)) {
+ return createError(request.id, METHOD_NOT_FOUND, `Method '${request.method}' not found`)
+ }
+
+ const invalidResponses = invalidInputResponses(request.params)
+ if (invalidResponses) return createError(request.id, INVALID_PARAMS, invalidResponses)
+
+ const opened = openRequestState(request, dependencies)
+ if (!opened.ok) return opened.error
+ const scoped = withMrtrContext(request, dependencies, opened)
+ const invalidAnswer = invalidAnswers(scoped.mrtr?.inputResponses)
+ if (invalidAnswer) return createError(request.id, INVALID_PARAMS, invalidAnswer)
+
+ // A result produced from `inputResponses`/`requestState` depends on inputs
+ // that are not part of the cache key, so it must not be cached. Complete
+ // results still must carry hints, so a retry says exactly that: stale at
+ // once and never shared.
+ const hint = (which: keyof CachingConfig): CacheHint =>
+ isMrtrRetry(request) ? UNCACHEABLE : scoped.caching[which]
+
+ try {
+ switch (request.method) {
+ case 'server/discover':
+ return handleDiscover(request, scoped)
+
+ case 'tools/list':
+ return adapt(
+ filterInvalidHeaderTools(await handleToolsList(request, scoped), scoped),
+ scoped.serverInfo,
+ hint('toolsList')
+ )
+ case 'resources/list':
+ return adapt(handleResourcesList(request, scoped), scoped.serverInfo, hint('resourcesList'))
+ case 'resources/templates/list':
+ return adapt(handleResourceTemplatesList(request, scoped), scoped.serverInfo, hint('resourceTemplatesList'))
+ case 'prompts/list':
+ return adapt(handlePromptsList(request, scoped), scoped.serverInfo, hint('promptsList'))
+
+ case 'tools/call':
+ return await modernToolsCall(request, scoped)
+ case 'resources/read':
+ return adapt(await handleResourcesRead(request, undefined, scoped), scoped.serverInfo, hint('resourcesRead'))
+ case 'prompts/get':
+ return adapt(await handlePromptsGet(request, undefined, scoped), scoped.serverInfo)
+
+ case 'tasks/get':
+ case 'tasks/update':
+ case 'tasks/cancel': {
+ if (!scoped.enableTasks || !scoped.taskStore) {
+ return createError(request.id, METHOD_NOT_FOUND, `Method '${request.method}' not found`)
+ }
+ if (!supportsTasksExtension(context.clientCapabilities)) {
+ return missingCapability(request.id, { extensions: { [TASKS_EXTENSION]: {} } })
+ }
+ if (request.method === 'tasks/get') return await handleTasksGet(request, scoped)
+ if (request.method === 'tasks/update') return await handleTasksUpdate(request, scoped)
+ return await handleTasksCancel(request, scoped)
+ }
+
+ default:
+ return createError(request.id, METHOD_NOT_FOUND, `Method '${request.method}' not found`)
+ }
+ } catch (error) {
+ if (error instanceof InputRequired) {
+ return inputRequired(request, error, scoped)
+ }
+ app.log.error({ err: error, method: request.method }, 'Unhandled error in MCP request')
+ return createError(request.id, INTERNAL_ERROR, 'Internal server error')
+ }
+}
+
+export type { InputRequests }
+export { SUPPORTED_PROTOCOL_VERSIONS }
diff --git a/src/modern/headers.ts b/src/modern/headers.ts
new file mode 100644
index 00000000..8154ebb5
--- /dev/null
+++ b/src/modern/headers.ts
@@ -0,0 +1,474 @@
+/**
+ * Header/body reconciliation for the 2026-07-28 Streamable HTTP transport.
+ *
+ * The transport mirrors selected body fields into HTTP headers so gateways can
+ * route without parsing JSON. That only stays safe if the two agree, otherwise
+ * a load balancer and the server can be made to disagree about what is being
+ * called. Any mismatch is a `HeaderMismatch` (-32020) with HTTP 400.
+ */
+
+import type { IncomingHttpHeaders } from 'node:http'
+import { TextDecoder } from 'node:util'
+
+const BASE64_PREFIX = '=?base64?'
+const BASE64_SUFFIX = '?='
+
+/** Header field-name token characters, RFC 9110 5.1. */
+const TCHAR = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
+
+/** Visible ASCII, space and horizontal tab — what a header value may contain. */
+const SAFE_HEADER_VALUE = /^[\x20-\x7E\t]*$/
+
+/** Reject malformed byte sequences rather than replacing them with U+FFFD. */
+const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
+
+export type HeaderCheck = { ok: true } | { ok: false, message: string }
+
+/**
+ * Decode the Base64 sentinel wrapper if present, otherwise return as-is.
+ *
+ * Clients use `=?base64?...?=` whenever a value cannot ride in a header
+ * literally (non-ASCII, control characters, surrounding whitespace), and also
+ * for plain values that would otherwise be mistaken for the sentinel.
+ */
+export function decodeHeaderValue (raw: string): string | null {
+ if (!raw.startsWith(BASE64_PREFIX) || !raw.endsWith(BASE64_SUFFIX)) {
+ return raw
+ }
+
+ const encoded = raw.slice(BASE64_PREFIX.length, raw.length - BASE64_SUFFIX.length)
+ try {
+ const buffer = Buffer.from(encoded, 'base64')
+ // Buffer.from is lenient: round-trip to catch input that was not valid
+ // base64 rather than silently accepting a truncated value.
+ if (buffer.toString('base64').replace(/=+$/, '') !== encoded.replace(/=+$/, '')) {
+ return null
+ }
+ return UTF8_DECODER.decode(buffer)
+ } catch {
+ return null
+ }
+}
+
+/** Encode a value the way a conforming client would, for tests and clients. */
+export function encodeHeaderValue (value: string): string {
+ const needsEncoding =
+ !SAFE_HEADER_VALUE.test(value) ||
+ value !== value.trim() ||
+ (value.startsWith(BASE64_PREFIX) && value.endsWith(BASE64_SUFFIX))
+
+ if (!needsEncoding) return value
+ return `${BASE64_PREFIX}${Buffer.from(value, 'utf8').toString('base64')}${BASE64_SUFFIX}`
+}
+
+function single (headers: IncomingHttpHeaders, name: string): string | undefined {
+ const value = headers[name]
+ if (value === undefined) return undefined
+ return Array.isArray(value) ? value[0] : value
+}
+
+/**
+ * Read a mirrored header and decode it.
+ *
+ * The raw value must already be safe to carry literally: Node hands bytes
+ * 0x80-0xFF through as latin1, so a gateway reading them as UTF-8 would see a
+ * different value from the one compared here. Anything else must use the
+ * Base64 sentinel.
+ */
+function readMirroredHeader (
+ headers: IncomingHttpHeaders,
+ headerName: string,
+ label: string,
+ allowBase64: boolean = true
+): { ok: true, value: string | undefined } | { ok: false, message: string } {
+ const raw = single(headers, headerName)
+ if (raw === undefined) return { ok: true, value: undefined }
+ if (!SAFE_HEADER_VALUE.test(raw)) {
+ return { ok: false, message: `Header mismatch: ${label} header value contains invalid characters` }
+ }
+ // Only `Mcp-Name` and `Mcp-Param-*` may use the sentinel. Decoding anything
+ // else would let a request route on an encoded method a gateway never sees.
+ if (!allowBase64) return { ok: true, value: raw }
+ const decoded = decodeHeaderValue(raw)
+ if (decoded === null) {
+ return { ok: false, message: `Header mismatch: ${label} header value is not valid Base64` }
+ }
+ return { ok: true, value: decoded }
+}
+
+/** An integer header value: plain decimal, optionally with a zero fraction. */
+const INTEGER_HEADER_VALUE = /^-?(0|[1-9][0-9]*)(\.0+)?$/
+
+/** Methods for which `Mcp-Name` is REQUIRED, and the body field it mirrors. */
+const NAME_SOURCE: Record = {
+ 'tools/call': 'name',
+ 'prompts/get': 'name',
+ 'resources/read': 'uri'
+}
+
+/** Does this method have to carry `Mcp-Name`, whatever the body looks like? */
+export function requiresName (method: string): boolean {
+ return method in NAME_SOURCE
+}
+
+/**
+ * The body value that `Mcp-Name` mirrors, which differs per method:
+ * `params.name` for tools and prompts, `params.uri` for resources.
+ *
+ * Returns undefined when the method carries no name *or* when the body is
+ * missing the field — callers must distinguish those two cases via
+ * {@link requiresName}, since a missing body field does not excuse a missing
+ * header.
+ */
+export function expectedNameFor (method: string, params: unknown): string | undefined {
+ const field = NAME_SOURCE[method]
+ if (!field) return undefined
+
+ const value = (params as Record | undefined)?.[field]
+ return typeof value === 'string' ? value : undefined
+}
+
+/**
+ * Validate `Mcp-Method` and `Mcp-Name` against the body.
+ *
+ * `MCP-Protocol-Version` is checked separately because an unsupported version
+ * has its own error code, and the two failures are reported differently.
+ */
+export function validateStandardHeaders (
+ headers: IncomingHttpHeaders,
+ method: string,
+ params: unknown
+): HeaderCheck {
+ const methodHeader = readMirroredHeader(headers, 'mcp-method', 'Mcp-Method', false)
+ if (!methodHeader.ok) return methodHeader
+ const mcpMethod = methodHeader.value
+ if (mcpMethod === undefined) {
+ return { ok: false, message: 'Missing required Mcp-Method header' }
+ }
+ if (mcpMethod !== method) {
+ return {
+ ok: false,
+ message: `Header mismatch: Mcp-Method header value '${mcpMethod}' does not match body value '${method}'`
+ }
+ }
+
+ if (!requiresName(method)) {
+ // Methods without a name source do not carry the header; a stray one is
+ // ignored rather than rejected, since it mirrors nothing.
+ return { ok: true }
+ }
+
+ const nameHeader = readMirroredHeader(headers, 'mcp-name', 'Mcp-Name')
+ if (!nameHeader.ok) return nameHeader
+ const decoded = nameHeader.value
+ if (decoded === undefined) {
+ // Required for this method regardless of what the body contains — a body
+ // that omits `name`/`uri` is malformed, but that is a separate failure and
+ // must not excuse the missing header.
+ return { ok: false, message: 'Missing required Mcp-Name header' }
+ }
+
+ const expectedName = expectedNameFor(method, params)
+ if (expectedName === undefined) {
+ return {
+ ok: false,
+ message: `Header mismatch: Mcp-Name header was sent but the request body has no ${method === 'resources/read' ? 'uri' : 'name'}`
+ }
+ }
+
+ if (decoded !== expectedName) {
+ return {
+ ok: false,
+ message: `Header mismatch: Mcp-Name header value '${decoded}' does not match body value '${expectedName}'`
+ }
+ }
+
+ return { ok: true }
+}
+
+const MIRRORABLE_TYPES = new Set(['string', 'integer', 'boolean'])
+
+/**
+ * The primitive type an annotated parameter carries, or `undefined` when it
+ * cannot be mirrored into a header. A nullable parameter qualifies: the
+ * transport has a rule for null (the client omits the header), so
+ * `type: ["string", "null"]` and an `anyOf`/`oneOf` of a primitive and null
+ * (what TypeBox's `Type.Union([..., Type.Null()])` produces) are accepted.
+ */
+function mirroredType (schema: Record): string | undefined {
+ const alternatives = schema.anyOf ?? schema.oneOf
+ const types: unknown[] = Array.isArray(schema.type)
+ ? schema.type
+ : Array.isArray(alternatives)
+ ? alternatives.map((entry: unknown) => (entry as { type?: unknown } | undefined)?.type)
+ : [schema.type]
+ const nonNull = types.filter(type => type !== 'null')
+ if (nonNull.length !== 1 || typeof nonNull[0] !== 'string') return undefined
+ return MIRRORABLE_TYPES.has(nonNull[0]) ? nonNull[0] : undefined
+}
+
+/**
+ * Read the `x-mcp-header` annotations off a tool's `inputSchema`.
+ *
+ * Only properties statically reachable through a chain of `properties` keys
+ * count. Anything behind `items`, a composition keyword, `if`/`then`/`else` or
+ * a `$ref` is not addressable, so an annotation there makes the tool invalid.
+ */
+export function collectHeaderParams (
+ inputSchema: unknown
+): { ok: true, params: Map } | { ok: false, message: string } {
+ const params = new Map()
+ const seen = new Set()
+ const ancestors = new WeakSet