diff --git a/mcp/src/index.ts b/mcp/src/index.ts index fb83c377..10ef3e20 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -3,10 +3,20 @@ // Streamable HTTP transport (the modern MCP transport): the client POSTs // JSON-RPC 2.0 messages to /mcp and gets back JSON-RPC responses. No // sessions, no server-initiated messages, no SSE — this server is stateless -// and read-only. Advertises the 2025-11-25 protocol revision and negotiates -// down to 2025-06-18 / 2025-03-26 when a client requests one of those — -// the feature surface (tool annotations, structured output / outputSchema) -// is identical across them for this server. +// and read-only. +// +// DUAL-ERA (spec: 2026-07-28 basic/versioning#backward-compatibility). +// Revision 2026-07-28 removed the `initialize` handshake: every request now +// declares its own protocol version in `params._meta`, mirrored into the +// MCP-Protocol-Version header. Earlier revisions ("legacy") negotiate once +// via `initialize`. This server answers both on the same endpoint, and picks +// which by how the client opens: +// +// * a request carrying the modern _meta version key → modern, stateless +// * an `initialize` request → legacy semantics +// +// Being stateless already, the modern shape costs us nothing structurally — +// the work is per-request validation and the mandatory server/discover RPC. // // All spec content is bundled at build time via scripts/build-data.mjs. // The Worker holds the manifest in module scope, so it is parsed once per @@ -33,11 +43,52 @@ interface Env { const manifest = data as unknown as Manifest; -const PROTOCOL_VERSION = '2025-11-25'; -// Older revisions this server is also fully compatible with. Version -// negotiation (spec: basic/lifecycle) echoes the client's requested version -// when it is in this set, and answers with PROTOCOL_VERSION otherwise. -const SUPPORTED_PROTOCOL_VERSIONS = [PROTOCOL_VERSION, '2025-06-18', '2025-03-26']; +const PROTOCOL_VERSION = '2026-07-28'; + +// Versions valid for the MODERN per-request mechanism. Only 2026-07-28 goes +// here, and deliberately so: the `_meta` protocol-version key does not exist +// in earlier revisions, so a request carrying it can only mean 2026-07-28. +// Advertising a legacy version through server/discover would invite a client +// to send it as per-request metadata, which is not a thing that revision +// defines. Legacy versions stay reachable through `initialize` below. +const MODERN_PROTOCOL_VERSIONS = [PROTOCOL_VERSION]; + +// Handshake-based revisions this server still answers via `initialize`. The +// feature surface (tool annotations, structured output / outputSchema) is +// identical across them for this server. +const LEGACY_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26']; +const LEGACY_DEFAULT_VERSION = LEGACY_PROTOCOL_VERSIONS[0]; + +const SUPPORTED_PROTOCOL_VERSIONS = [...MODERN_PROTOCOL_VERSIONS, ...LEGACY_PROTOCOL_VERSIONS]; + +// Our own support window for the handshake, announced on the wire. +// +// Note what this is NOT: MCP does not deprecate the `initialize` handshake. +// 2025-11-25 is a Final revision, and the deprecated-features registry +// (specification/2026-07-28/deprecated) does not list it. Revisions do not +// sunset — how long a given server keeps honouring one is that server's +// policy. So these dates are ours, not the specification's, and Deprecation +// (RFC 9745) + Sunset (RFC 8594) are the right shape for exactly that: a +// commitment this endpoint is making about its own future behaviour. +// +// Worked example for /spec/resilience/deprecation-and-sunset/. +const HANDSHAKE_DEPRECATION = '@1785196800'; // 2026-07-28T00:00:00Z +const HANDSHAKE_SUNSET = 'Wed, 28 Jul 2027 00:00:00 GMT'; +const HANDSHAKE_DOCS = + 'https://specification.website/spec/agent-readiness/mcp-and-tool-discovery/'; +const HANDSHAKE_HEADERS = { + Deprecation: HANDSHAKE_DEPRECATION, + Sunset: HANDSHAKE_SUNSET, + Link: `<${HANDSHAKE_DOCS}>; rel="deprecation"; type="text/html"`, +}; + +// Reserved `_meta` keys from the 2026-07-28 revision (basic/index#meta). +const META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion'; +const META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo'; + +// Protocol-defined JSON-RPC error codes (basic/index#error-codes). +const ERR_HEADER_MISMATCH = -32020; +const ERR_UNSUPPORTED_PROTOCOL_VERSION = -32022; const SERVER_INFO = { name: 'specification-website', version: '0.2.0', @@ -54,11 +105,25 @@ const SERVER_INFO = { ], }; +// Natural-language guidance for the calling model. Returned by both eras — +// `initialize` (legacy) and `server/discover` (modern) — so keep it one string. +const SERVER_INSTRUCTIONS = + 'Read-only MCP server for The Website Specification at https://specification.website. ' + + 'Use `search` for free-text queries, `list_topics` for filtered lists, `get_topic` to fetch ' + + 'a single page as Markdown, and `get_checklist` for audit-style output. ' + + 'Spec items have one of four statuses: `required` (platform contract breaks without it), ' + + '`recommended` (modern site should do it), `optional` (context-dependent), `avoid` (outdated or harmful). ' + + 'The `list_topics` and `get_checklist` tools return ALL statuses by default — pass `status` to filter. ' + + 'The `audit_url` prompt is the exception: with no `focus`, it defaults to `required`-only.'; + const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version', - 'Access-Control-Expose-Headers': 'Mcp-Session-Id', + 'Access-Control-Allow-Headers': + 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Mcp-Method, Mcp-Name', + // Deprecation/Sunset/Link are exposed so a browser-based client can + // actually read them — without this they are invisible to fetch(). + 'Access-Control-Expose-Headers': 'Mcp-Session-Id, Deprecation, Sunset, Link', 'Access-Control-Max-Age': '86400', }; @@ -93,29 +158,53 @@ function handleRpc(req: RpcRequest): RpcResponse | null { const { id, method, params = {} } = req; switch (method) { + // Legacy era only. An `initialize` request selects handshake semantics + // (spec: 2026-07-28 basic/versioning), so it can never yield the modern + // revision — echo the requested legacy version, else the newest legacy one. case 'initialize': { const requested = String((params as Record).protocolVersion || ''); return ok(id, { - protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requested) + protocolVersion: LEGACY_PROTOCOL_VERSIONS.includes(requested) ? requested - : PROTOCOL_VERSION, + : LEGACY_DEFAULT_VERSION, serverInfo: SERVER_INFO, capabilities: { tools: { listChanged: false }, prompts: { listChanged: false }, logging: {}, }, - instructions: - 'Read-only MCP server for The Website Specification at https://specification.website. ' + - 'Use `search` for free-text queries, `list_topics` for filtered lists, `get_topic` to fetch ' + - 'a single page as Markdown, and `get_checklist` for audit-style output. ' + - 'Spec items have one of four statuses: `required` (platform contract breaks without it), ' + - '`recommended` (modern site should do it), `optional` (context-dependent), `avoid` (outdated or harmful). ' + - 'The `list_topics` and `get_checklist` tools return ALL statuses by default — pass `status` to filter. ' + - 'The `audit_url` prompt is the exception: with no `focus`, it defaults to `required`-only.', + instructions: SERVER_INSTRUCTIONS, }); } + // Modern era. Servers MUST implement server/discover: it returns the + // supported versions, capabilities and identity in one request, so a + // client can skip probing tools/list + prompts/list separately. + case 'server/discover': + return ok(id, { + resultType: 'complete', + supportedVersions: MODERN_PROTOCOL_VERSIONS, + // No `logging` here. Logging is Deprecated as of 2026-07-28 + // (specification/2026-07-28/deprecated, SEP-2577), and new + // implementations SHOULD NOT adopt it — advertising it in a discovery + // call written after that date would be adopting it. The legacy + // `initialize` response below still declares it, because clients + // written against those revisions may already expect it there. + capabilities: { + tools: {}, + prompts: {}, + }, + _meta: { + [META_SERVER_INFO]: SERVER_INFO, + }, + instructions: SERVER_INSTRUCTIONS, + // The manifest is baked in at build time and only changes on deploy, + // so a discovery result stays valid for as long as a client cares to + // hold it. Public: there is nothing per-client in this response. + ttlMs: 3_600_000, + cacheScope: 'public', + }); + case 'notifications/initialized': case 'notifications/cancelled': return null; // notifications get no reply @@ -315,8 +404,120 @@ async function handleA2a(request: Request, env: Env): Promise { return new Response(JSON.stringify(response), { headers: JSON_HEADERS }); } +// --- Modern-era transport validation (2026-07-28) ------------------------- + +// Header values that cannot be represented in plain ASCII arrive wrapped in a +// sentinel: `=?base64??=`. Servers MUST decode +// before comparing to the body value (transports/streamable-http#value-encoding). +function decodeHeaderValue(raw: string): string { + if (!raw.startsWith('=?base64?') || !raw.endsWith('?=')) return raw; + const encoded = raw.slice('=?base64?'.length, -'?='.length); + try { + const bytes = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return raw; // undecodable → will fail the comparison, which is correct + } +} + +// The `Mcp-Name` header mirrors params.name (tools/call, prompts/get) or +// params.uri (resources/read). Returns null when the method does not require it. +function expectedMcpName(method: string, params: Record): string | null { + switch (method) { + case 'tools/call': + case 'prompts/get': + return typeof params.name === 'string' ? params.name : ''; + case 'resources/read': + return typeof params.uri === 'string' ? params.uri : ''; + default: + return null; + } +} + +// A request is "modern" iff it carries the per-request protocol-version key. +// That key does not exist before 2026-07-28, so its presence is unambiguous. +function modernVersionOf(req: RpcRequest): string | null { + const params = (req.params ?? {}) as Record; + const meta = params._meta as Record | undefined; + if (!meta || typeof meta !== 'object') return null; + const version = meta[META_PROTOCOL_VERSION]; + return typeof version === 'string' ? version : null; +} + +// Returns a JSON-RPC error response when the request must be rejected, or +// null when it may proceed. Every rejection here is a MUST in +// transports/streamable-http; all of them are HTTP 400. +function validateModernRequest( + httpReq: Request, + req: RpcRequest, + bodyVersion: string, +): RpcResponse | null { + const { id, method } = req; + const params = (req.params ?? {}) as Record; + + // The revision explicitly leaves header requirements for notification POSTs + // undefined, so validating them would invent a rule and reject conforming + // clients. A JSON-RPC notification is a message with no `id`. + if (id === undefined || id === null) return null; + + const reject = (code: number, message: string, data?: unknown) => + err(id, code, message, data); + + // The header mirrors the body value; a mismatch means two components in the + // path disagree about what is being asked, which is a security problem. + const headerVersion = httpReq.headers.get('mcp-protocol-version'); + if (!headerVersion) { + return reject(ERR_HEADER_MISMATCH, 'Missing required header: MCP-Protocol-Version'); + } + if (headerVersion !== bodyVersion) { + return reject( + ERR_HEADER_MISMATCH, + `Header mismatch: MCP-Protocol-Version header value '${headerVersion}' does not match body value '${bodyVersion}'`, + ); + } + + if (!MODERN_PROTOCOL_VERSIONS.includes(bodyVersion)) { + return reject( + ERR_UNSUPPORTED_PROTOCOL_VERSION, + 'Unsupported protocol version', + { supported: MODERN_PROTOCOL_VERSIONS, requested: bodyVersion }, + ); + } + + const headerMethod = httpReq.headers.get('mcp-method'); + if (!headerMethod) { + return reject(ERR_HEADER_MISMATCH, 'Missing required header: Mcp-Method'); + } + if (headerMethod !== method) { + return reject( + ERR_HEADER_MISMATCH, + `Header mismatch: Mcp-Method header value '${headerMethod}' does not match body value '${method}'`, + ); + } + + const wantName = expectedMcpName(method, params); + if (wantName !== null) { + const rawName = httpReq.headers.get('mcp-name'); + if (rawName === null) { + return reject(ERR_HEADER_MISMATCH, 'Missing required header: Mcp-Name'); + } + const gotName = decodeHeaderValue(rawName); + if (gotName !== wantName) { + return reject( + ERR_HEADER_MISMATCH, + `Header mismatch: Mcp-Name header value '${gotName}' does not match body value '${wantName}'`, + ); + } + } + + return null; +} + async function handleMcp(request: Request, env: Env): Promise { if (request.method !== 'POST') { + // 2026-07-28 removed the GET stream and the DELETE session teardown; both + // are 405 now. Mcp-Session-Id and Last-Event-ID are ignored wherever they + // appear — this server never had sessions to resume. return new Response( JSON.stringify(err(null, -32600, 'MCP endpoint requires POST with a JSON-RPC body.')), { status: 405, headers: JSON_HEADERS }, @@ -348,13 +549,37 @@ async function handleMcp(request: Request, env: Env): Promise { return new Response(JSON.stringify(responses), { headers: JSON_HEADERS }); } const req = body as RpcRequest; + + // Era selection (spec: basic/versioning). A request carrying the modern + // per-request version key is served under 2026-07-28 and validated + // accordingly; anything else falls through to the legacy path untouched, + // so existing `initialize`-based clients keep working exactly as before. + const bodyVersion = modernVersionOf(req); + if (bodyVersion !== null) { + const rejection = validateModernRequest(request, req, bodyVersion); + if (rejection) { + logMcpCall(env, request, req, rejection, 'remote'); + return new Response(JSON.stringify(rejection), { status: 400, headers: JSON_HEADERS }); + } + } + const response = handleRpc(req); logMcpCall(env, request, req, response, 'remote'); if (response === null) { // Streamable HTTP requires accepted notifications to return 202 with no body. return new Response(null, { status: 202, headers: CORS_HEADERS }); } - return new Response(JSON.stringify(response), { headers: JSON_HEADERS }); + // Modern era distinguishes "no such method" from a legacy 404 by pairing + // HTTP 404 with a JSON-RPC -32601 body. + const status = + bodyVersion !== null && 'error' in response && response.error.code === -32601 ? 404 : 200; + + // A legacy handshake gets our support window on the wire. Scoped to the + // `initialize` response rather than every response from /mcp: the endpoint + // is not going away, only our willingness to answer the handshake on it. + const headers = + req.method === 'initialize' ? { ...JSON_HEADERS, ...HANDSHAKE_HEADERS } : JSON_HEADERS; + return new Response(JSON.stringify(response), { status, headers }); } // --- Usage logging -------------------------------------------------------- @@ -397,6 +622,20 @@ function logMcpCall( let clientVersion = ''; let isError = ''; + // Modern era (2026-07-28) carries clientInfo in `_meta` on EVERY request, + // not once at `initialize`. Read it first so the client-mix column keeps + // filling as clients migrate; the `initialize` branch below still covers + // legacy clients, for which this key is absent. + const meta = (params as Record)._meta as Record | undefined; + if (meta && typeof meta === 'object') { + const modernClient = (meta['io.modelcontextprotocol/clientInfo'] || {}) as { + name?: unknown; + version?: unknown; + }; + clientName = String(modernClient.name || ''); + clientVersion = String(modernClient.version || ''); + } + if (method === 'tools/call') { toolName = String((params as Record).name || ''); try { diff --git a/public/.well-known/agent-skills/index.json b/public/.well-known/agent-skills/index.json index 7798f311..1406b70b 100644 --- a/public/.well-known/agent-skills/index.json +++ b/public/.well-known/agent-skills/index.json @@ -6,7 +6,7 @@ "type": "skill-md", "description": "Query and apply The Website Specification — a platform-agnostic specification of what a good website does. Use when the user asks what their site should have, whether something is required, how to audit a URL, what's missing for agent readiness, or anything else where you'd otherwise be guessing at web best practice. Backs answers with primary sources and ships an MCP server with search, list, fetch, checklist, and audit tools.", "url": "/.well-known/agent-skills/specification-website/SKILL.md", - "digest": "sha256:71188d9a6b563ac302db37f26e319e246ccba55d03ead299385d9828075a680d" + "digest": "sha256:9d40c04e08404c5e1d3fc754e9f007c0900230f1fffc73786e192c03ca74087b" } ] } diff --git a/public/.well-known/agent-skills/specification-website/SKILL.md b/public/.well-known/agent-skills/specification-website/SKILL.md index bda1d32f..4612d0e0 100644 --- a/public/.well-known/agent-skills/specification-website/SKILL.md +++ b/public/.well-known/agent-skills/specification-website/SKILL.md @@ -19,7 +19,8 @@ If you can speak MCP, use it. The server is stateless Streamable HTTP, no auth, - Endpoint: `https://mcp.specification.website/mcp` - Server card: `https://specification.website/.well-known/mcp/server-card.json` -- Protocol revision: 2025-11-25 +- Protocol revision: 2026-07-28 +- Also answers the handshake-based revisions 2025-11-25, 2025-06-18 and 2025-03-26 via `initialize`, so older clients keep working unchanged Tools: diff --git a/public/.well-known/mcp/server-card.json b/public/.well-known/mcp/server-card.json index e127a888..76581bb6 100644 --- a/public/.well-known/mcp/server-card.json +++ b/public/.well-known/mcp/server-card.json @@ -14,7 +14,8 @@ }, "endpoint": "https://mcp.specification.website/mcp", "transport": "http", - "protocolVersion": "2025-11-25", + "protocolVersion": "2026-07-28", + "supportedProtocolVersions": ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26"], "authentication": "none", "capabilities": { "tools": true, diff --git a/src/content/changelog/2026-07-28-mcp-2026-07-28.md b/src/content/changelog/2026-07-28-mcp-2026-07-28.md new file mode 100644 index 00000000..bc95e0a6 --- /dev/null +++ b/src/content/changelog/2026-07-28-mcp-2026-07-28.md @@ -0,0 +1,8 @@ +--- +title: MCP dropped the handshake, so the page describing it had to change +date: "2026-07-28" +type: changed +relatedSlugs: [mcp-and-tool-discovery, deprecation-and-sunset, agent-readiness-overview] +--- + +MCP's 2026-07-28 revision removes the `initialize` handshake: every request now declares its own protocol version, sessions and the standalone `GET` stream are gone, and a new `server/discover` call is mandatory. [MCP and tool discovery](/spec/agent-readiness/mcp-and-tool-discovery/) now describes that model, and adds a section on serving both eras from one endpoint — because older clients open with a handshake and have no way to fall forward when it is refused. [Deprecation and sunset](/spec/resilience/deprecation-and-sunset/) gains a related distinction: announcing your own support window for something a standard has moved on from but never scheduled for removal, which is what this site's MCP server now does on every legacy `initialize` response. diff --git a/src/content/spec/agent-readiness/mcp-and-tool-discovery.md b/src/content/spec/agent-readiness/mcp-and-tool-discovery.md index 9ff85623..3540d453 100644 --- a/src/content/spec/agent-readiness/mcp-and-tool-discovery.md +++ b/src/content/spec/agent-readiness/mcp-and-tool-discovery.md @@ -6,8 +6,8 @@ summary: "The Model Context Protocol is an emerging way for sites to expose quer status: optional order: 80 appliesTo: [all] -relatedSlugs: [agent-readiness-overview, machine-readable-formats, llms-txt, link-headers, api-catalog, agent-skills-discovery, a2a-agent-cards, webmcp, nlweb, agentic-resource-discovery, oauth-protected-resource] -updated: "2026-07-16T00:00:00.000Z" +relatedSlugs: [agent-readiness-overview, machine-readable-formats, llms-txt, link-headers, api-catalog, agent-skills-discovery, a2a-agent-cards, webmcp, nlweb, agentic-resource-discovery, oauth-protected-resource, deprecation-and-sunset] +updated: "2026-07-28T00:00:00.000Z" sources: - title: "Model Context Protocol" url: "https://modelcontextprotocol.io/" @@ -15,12 +15,12 @@ sources: - title: "MCP specification" url: "https://modelcontextprotocol.io/specification" publisher: "MCP project" - - title: "MCP server tools: annotations & structured content" - url: "https://modelcontextprotocol.io/specification/2025-11-25/server/tools" + - title: "MCP 2026-07-28 — versioning and backward compatibility" + url: "https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning" + publisher: "MCP project" + - title: "MCP 2026-07-28 — Streamable HTTP transport" + url: "https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http" publisher: "MCP project" - - title: "Is It Agent Ready?" - url: "https://isitagentready.com/" - publisher: "Is It Agent Ready?" --- ## What it is @@ -29,9 +29,13 @@ The Model Context Protocol (MCP) is an open protocol, originally proposed by Ant MCP is built on JSON-RPC over two transports — stdio for local servers, Streamable HTTP for remote ones (the older HTTP-plus-Server-Sent-Events transport is deprecated). A tool definition includes a name, a description, and a JSON Schema for inputs. +**Since the 2026-07-28 revision there is no connection handshake.** Earlier revisions opened with an `initialize` exchange that negotiated a protocol version once and established a session for everything that followed. That is gone: each request now declares its own version in a `_meta` field, mirrored into an `MCP-Protocol-Version` header, and the server accepts or rejects it independently. A server that does not implement the requested version answers with `UnsupportedProtocolVersionError` listing what it does support, and the client retries. Sessions, the standalone `GET` stream, and resumable streams went with the handshake. + +The practical consequence is that an MCP server is now a plain request/response HTTP endpoint, which is why it can sit on serverless and edge infrastructure without sticky routing. One new call is mandatory: `server/discover` returns the supported versions, capabilities, and identity in a single request, so a client can learn what a server is before calling anything. + This is relevant when your site exposes actions a user might want an agent to take: search a catalogue, create a ticket, book an appointment, query an account. For static content sites and blogs, MCP often adds little — well-cached HTML and a feed are enough. The exception is structured content sites where the data is filterable: a documentation set, a spec, a knowledge base. There an MCP server lets an agent ask "list all required SEO topics" or "give me the canonical CSP page" in a single typed call, instead of crawling and parsing. -This site ships such a server as a worked example. See [mcp.specification.website](https://mcp.specification.website/) for the live endpoint, [`/.well-known/mcp/server-card.json`](/.well-known/mcp/server-card.json) for the discovery document, and the [`mcp/` directory of the source repo](https://github.com/jdevalk/specification.website/tree/main/mcp) for a ~300-line Cloudflare Worker implementation. Every tool it exposes is annotated `readOnlyHint` and declares an `outputSchema`, so clients get typed results alongside the human-readable text. +This site ships such a server as a worked example. See [mcp.specification.website](https://mcp.specification.website/) for the live endpoint, [`/.well-known/mcp/server-card.json`](/.well-known/mcp/server-card.json) for the discovery document, and the [`mcp/` directory of the source repo](https://github.com/jdevalk/specification.website/tree/main/mcp) for the Cloudflare Worker implementation. Every tool it exposes is annotated `readOnlyHint` and declares an `outputSchema`, so clients get typed results alongside the human-readable text. It serves 2026-07-28 and still answers the handshake-based revisions back to 2025-03-26 on the same endpoint — see [supporting both eras](#supporting-both-eras) below. Larger reference sites are reaching the same conclusion. In 2026 MDN — the canonical web-platform documentation — [shipped its own MCP server](https://developer.mozilla.org/en-US/blog/introducing-mdn-mcp-server/), exposing its docs and Baseline browser-compatibility data over the same protocol so agents query it directly instead of scraping pages. When the material a developer would otherwise read by hand becomes a typed, queryable tool, that is the signal a corpus is worth serving over MCP. @@ -56,6 +60,23 @@ Adoption is real but uneven. Treat it as an emerging convention worth investing - **Return structured output, not just prose.** Since the 2025-06-18 revision a tool can declare an `outputSchema` and return a matching `structuredContent` object next to the human-readable text. A calling agent then consumes typed data instead of re-parsing Markdown. Document every field and version the output schema as carefully as the input one. - Version the schema. Renaming a tool or changing its input shape is a breaking change. +### Supporting both eras + +The handshake did not disappear from the field the day it disappeared from the specification. Clients written against `2025-11-25` and earlier will keep opening with `initialize` for a long time, and they have no way to fall forward — a legacy client meeting a modern-only server simply fails, with no mechanism to discover why. If you already run a server, answering both is the courteous option and is explicitly provided for. + +A dual-era server decides which semantics to apply from how the client opens, not from configuration: + +- A request carrying the per-request version metadata is served under the new revision, statelessly. +- An `initialize` request selects the older, session-based semantics. + +Two details are easy to get wrong. First, an `initialize` handshake must never be answered with `2026-07-28` — that revision has no handshake, so echoing it back promises something the server cannot then honour; answer with the newest revision that does have one. Second, apply the new header validation only to requests that declare the new version. Imposing `Mcp-Method` and `Mcp-Name` on a legacy request rejects a client that is behaving correctly for the revision it speaks. + +Validation matters because the transport now mirrors body fields into headers so intermediaries can route without parsing JSON. If a header and the body disagree, a load balancer and your server are acting on different instructions — so the specification requires you to reject the request with a `HeaderMismatch` error rather than pick a winner. + +Decide how long you will answer the handshake, and publish it. The protocol will not decide for you: earlier revisions are Final rather than withdrawn, and nothing in MCP says when a server should stop honouring one. This site's server answers `initialize` until 28 July 2027 and says so on the wire — those responses carry `Deprecation`, `Sunset` and a `rel="deprecation"` link, so a client learns its remaining runway without reading a blog post. The endpoint itself carries no such headers; `/mcp` is not being retired. See [deprecation and sunset](/spec/resilience/deprecation-and-sunset/) for the mechanics, and note the dates there are a server's policy, not the specification's. + +Watch the [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated) for the things the protocol *has* scheduled for removal — Roots, Sampling, Logging and Dynamic Client Registration are all Deprecated as of 2026-07-28. Do not advertise a deprecated capability in a server you are writing now; new implementations are told not to adopt them. + ## Common mistakes - Exposing every internal API as an MCP tool. Curate; agents reason better about a small, well-named surface. @@ -63,9 +84,14 @@ Adoption is real but uneven. Treat it as an emerging convention worth investing - Mixing read and write tools without clear naming. Make destructive actions obvious in the tool name. - Annotations that lie. Marking a write tool `readOnlyHint: true` is worse than omitting the hint — clients trust it and skip the confirmation a destructive call deserves. - Treating MCP as a replacement for documentation. It complements it; it does not replace it. +- Assuming a session exists. Anything the old handshake let you stash for the duration of a connection — negotiated version, client identity, per-session state — now has to arrive on each request or be derived from it. +- Dropping legacy support the day you adopt the new revision. Older clients cannot fall forward; they just break. ## Verification - Connect your server with the MCP Inspector or a reference client and confirm tools list, call, and return as expected. +- Call `server/discover` and check it reports the versions you actually serve. A version you list here is one a client may send on its next request. +- Send a request whose `MCP-Protocol-Version` header disagrees with the body, and one for a version you do not implement. The first must be rejected as a header mismatch, the second with the list of versions you do support. +- If you serve both eras, run an old client through `initialize` after every change. It is the path that breaks silently, because nothing in your own tooling exercises it. - Review the OAuth flow end to end. - Watch logs after a public launch for unexpected call patterns. diff --git a/src/content/spec/resilience/deprecation-and-sunset.md b/src/content/spec/resilience/deprecation-and-sunset.md index 0a857de0..b9cd0f9b 100644 --- a/src/content/spec/resilience/deprecation-and-sunset.md +++ b/src/content/spec/resilience/deprecation-and-sunset.md @@ -6,8 +6,8 @@ summary: "When you retire an endpoint, announce it in machine-readable form: the status: optional order: 55 appliesTo: [all] -relatedSlugs: [error-pages, redirects, maintenance-pages, stable-urls] -updated: "2026-07-08T00:00:00.000Z" +relatedSlugs: [error-pages, redirects, maintenance-pages, stable-urls, mcp-and-tool-discovery] +updated: "2026-07-28T00:00:00.000Z" sources: - title: "RFC 9745 — The Deprecation HTTP Response Header Field" url: "https://www.rfc-editor.org/rfc/rfc9745" @@ -51,12 +51,21 @@ Announce as early as you can — the point is lead time. Do not start failing be This site ships a worked example. `/.well-known/ai.txt` was retired, and rather than a bare 404 it returns `410 Gone` carrying `Deprecation`, `Sunset`, and a `rel="deprecation"` link back to this page (see `functions/_middleware.ts`). The `ai.txt` convention itself proved defunct — express AI-crawler preferences via [robots.txt](/spec/seo/robots-txt/) and content signals instead. +### Announcing your own support window + +The headers are usually read as "this URL is being retired", but they answer a broader question: *how long will this keep working?* That question also arises when nothing is being retired at all — when a still-valid way of talking to your endpoint is one you intend to stop accepting. + +Our MCP server is the case in point. The 2026-07-28 revision of the protocol replaced the `initialize` handshake with per-request negotiation, but it did not deprecate the handshake: the older revisions are Final, not withdrawn, and the protocol says nothing about when a server should stop honouring them. That makes the support window a decision by the server, not by the specification — and a decision nobody can discover by reading the standard. So responses to a legacy `initialize` carry `Deprecation`, a `Sunset` of 28 July 2027, and a `rel="deprecation"` link, while the endpoint itself carries nothing: `/mcp` is not going anywhere, only our willingness to answer the handshake on it. See [MCP and tool discovery](/spec/agent-readiness/mcp-and-tool-discovery/). + +Two things make that legitimate rather than a misuse of the headers. The scope is a real one — the response to a particular request, not the URL in general — and the commitment is one we are actually making and can be held to. If you borrow this pattern, be clear in your own documentation that the dates are yours. Attributing them to an upstream standard that never set them is a claim someone will check. + ## Common mistakes - A `Sunset` earlier than `Deprecation`. Invalid per RFC 9745. - Emitting `Deprecation` but never a `Sunset` or a successor link — consumers learn it is deprecated but not when it dies or what to use instead. - Failing the resource _before_ the announced Sunset. Deprecation is a promise to keep working until then. - Using a bare `Deprecation: true`. The value is a structured-field date, not a boolean. +- Announcing a sunset you have no intention of honouring, because a standard "moved on" from a feature. Check whether the thing is actually scheduled for removal; if the date is your policy rather than the standard's, say so. ## Verification