From f2ef2849ff5556c570e13b806aa743cb912180d8 Mon Sep 17 00:00:00 2001 From: Sergei Khomenkov Date: Fri, 4 Sep 2026 15:11:23 +0300 Subject: [PATCH 1/4] fix(mcp): reject in the modern era the two methods 2026-07-28 removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 51bbaa0 gave every result that flows through complete()/publicComplete() the members revision 2026-07-28 requires, which is what #186 reported. Two branches of handleRpc do not flow through it: `ping` and `logging/setLevel` still answer a bare `ok(id, {})`. That revision cannot accept either shape. `Result.required` is `["resultType"]` and `EmptyResult` is a `$ref` to `Result`, so the bare `{}` they return is schema-invalid — a conforming client rejects it exactly as it rejected the tools/list result before 51bbaa0. Stamping them instead would be no better: schema.json for that revision defines no `PingRequest` and no `SetLevelRequest`, and neither method appears in its `ClientRequest` union, so a stamped result would report success for a method the era does not have. `logging/setLevel` gave way to the io.modelcontextprotocol/logLevel `_meta` key (SEP-2577). So the modern era answers -32601, paired with HTTP 404 by the mapping already there. The legacy era is untouched: both methods answer exactly as before, and a batch — which can only be legacy, batching was removed in 2025-06-18 — never reaches the gate. `initialize` is deliberately not in the list. It selects the handshake era for its own message (basic/versioning), so it is answered in that shape. The gate reads a SUPPORTED version rather than the mere presence of the `_meta` key, which the 404 status mapping now shares. validateModernRequest() returns early for a message with no usable id, so an id-less request is never validated at all and only its version is read; gating on presence alone would answer such a request under an era it never asked for. MODERN_PROTOCOL_VERSIONS and LEGACY_ONLY_METHODS are exported so the assertions in the next commit can pin them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JFy2qPKK546AEFGixoxYoE --- mcp/src/index.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/mcp/src/index.ts b/mcp/src/index.ts index de1bca24..be1d4141 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -51,7 +51,7 @@ const PROTOCOL_VERSION = '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]; +export const MODERN_PROTOCOL_VERSIONS = [PROTOCOL_VERSION]; // Handshake-based revisions this server still answers via `initialize`. The // feature surface (tool annotations, structured output / outputSchema) is @@ -189,6 +189,20 @@ function err( return { jsonrpc: '2.0', id: id ?? null, error: { code, message, data } }; } +// Methods handleRpc answers that 2026-07-28 removed. Neither appears in that +// revision's `ClientRequest` union, and its schema.json defines no +// `PingRequest` or `SetLevelRequest` at all; `logging/setLevel` gave way to the +// io.modelcontextprotocol/logLevel `_meta` key (SEP-2577). Both reply with a +// bare `{}`, which the modern era cannot serve either way: `Result.required` is +// `["resultType"]` there and `EmptyResult` is a `$ref` to `Result`, so an +// unstamped `{}` is schema-invalid and a stamped one reports success for a +// method the revision does not define. The modern era answers -32601; the +// legacy era answers both exactly as before. +// +// `initialize` is not here — it selects the handshake era for its own message +// (basic/versioning), and is answered in that shape. +export const LEGACY_ONLY_METHODS = ['ping', 'logging/setLevel']; + function handleRpc(req: RpcRequest): RpcResponse | null { const { id, method, params = {} } = req; @@ -587,7 +601,11 @@ async function handleMcp(request: Request, env: Env): Promise { // 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. + // The gate below is a SUPPORTED version, not the mere presence of the key: + // validateModernRequest() returns early for a message with no usable `id`, so + // an id-less request is never validated at all — only its version is checked. const bodyVersion = modernVersionOf(req); + const isModern = bodyVersion !== null && MODERN_PROTOCOL_VERSIONS.includes(bodyVersion); if (bodyVersion !== null) { const rejection = validateModernRequest(request, req, bodyVersion); if (rejection) { @@ -596,7 +614,10 @@ async function handleMcp(request: Request, env: Env): Promise { } } - const response = handleRpc(req); + const response = + isModern && LEGACY_ONLY_METHODS.includes(req.method) + ? err(req.id, -32601, `Method not found: ${req.method}`) + : handleRpc(req); logMcpCall(env, request, req, response, 'remote'); if (response === null) { // Streamable HTTP requires accepted notifications to return 202 with no body. @@ -604,8 +625,7 @@ async function handleMcp(request: Request, env: Env): Promise { } // 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; + const status = isModern && '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 From 2a6bb62d2f34b6ecd8ed5cfd749a647bf54cde5b Mon Sep 17 00:00:00 2001 From: Sergei Khomenkov Date: Fri, 4 Sep 2026 15:11:41 +0300 Subject: [PATCH 2/4] add(mcp): assertions for the Worker's two-era protocol surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint served zero tools to every 2026-07-28 client for as long as it did because nothing exercised it. #186 was found by a person running the conformance suite by hand, and the two branches the previous commit fixes were still there afterwards. A read-through does not catch a missing member on one of two eras; a table of cases does. Same idiom as scripts/test-websub.mjs at the repo root — plain node + node:assert/strict, a check() that counts failures, no framework and no dependency — so this repo keeps one way of writing assertions. Unlike that file it drives the real fetch handler rather than pure helpers, which needs nothing extra: the Worker is a plain `export default { fetch(request, env) }`, it never touches an execution context, and logMcpCall() returns immediately when env.MCP_LOG is absent, so an empty env is enough. No wrangler, no network. 114 assertions. Each was checked by breaking the thing it guards and confirming the run goes red: the era gate, `resultType`, the cache hints, get_checklist's structuredContent, and tools/list back to a bare result all fail it. What it pins is the wire contract, not what the tools compute — a tools/call row asserts the result's shape and that `content` is there, not that `search` ranked anything. Rows are hand-maintained per tool and per return site, because nothing derives how many return sites a tool has: the row for an empty result set exists because that is the branch get_checklist's structuredContent was missing from, and each such row asserts the count it expects to be zero so a content change fails it instead of silently testing the populated branch. The suite also pins that both eras are served the same result members, in both directions. complete() sits inside handleRpc, so a handshake-era client gets `resultType` and the cache hints too. That is legal — 2025-11-25's Result carries `additionalProperties: {}` — and it is a decision rather than an accident, so moving the stamp to the era boundary has to edit those lists rather than quietly change what legacy clients receive. Running the TypeScript sources directly needs a 21-line resolve hook: wrangler bundles the Worker, so its relative imports are extensionless and Node's resolver wants the extension. registerHooks needs Node >= 22.15, above the root's declared >= 22.12. No `engines` field — the floor belongs to one script rather than to the package, and below 22.15 the named import already fails at link time naming the missing export. mcp/README.md says so. Deliberately not in .githooks/pre-commit: that fires on every commit, almost none of which touch mcp/, and it would need this package's separate dependency tree. `pretest` regenerates src/data.json, which is generated and gitignored. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JFy2qPKK546AEFGixoxYoE --- mcp/package.json | 2 + mcp/scripts/test-protocol.mjs | 602 ++++++++++++++++++++++++++++++++++ mcp/ts-resolve-hook.mjs | 23 ++ 3 files changed, 627 insertions(+) create mode 100644 mcp/scripts/test-protocol.mjs create mode 100644 mcp/ts-resolve-hook.mjs diff --git a/mcp/package.json b/mcp/package.json index 7e38e46c..f4921d6d 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -7,6 +7,8 @@ "license": "MIT", "scripts": { "build:data": "node scripts/build-data.mjs", + "pretest": "npm run build:data", + "test": "node --experimental-strip-types --import ./ts-resolve-hook.mjs scripts/test-protocol.mjs", "predev": "npm run build:data", "dev": "wrangler dev --port 31338", "predeploy": "npm run build:data", diff --git a/mcp/scripts/test-protocol.mjs b/mcp/scripts/test-protocol.mjs new file mode 100644 index 00000000..f2a8fe3c --- /dev/null +++ b/mcp/scripts/test-protocol.mjs @@ -0,0 +1,602 @@ +// Assertions for the Worker's two-era protocol surface. +// +// Run with: npm test (from mcp/) +// +// The Worker answers two eras from one handler, and the members a result must +// carry differ between them — which is how `tools/list` came to serve zero +// tools to every 2026-07-28 client while `initialize` still looked healthy +// (#186). That is not a bug a read-through catches, so the shapes are pinned +// with tables of cases here. +// +// Same idiom as scripts/test-websub.mjs at the repo root: plain node + +// node:assert, no framework, no dependency. Unlike that file this drives the +// real fetch handler rather than pure helpers — the Worker is a plain ESM +// `export default { fetch(request, env) }`, it never touches an execution +// context, and logMcpCall() returns immediately when env.MCP_LOG is absent, so +// an empty env is enough. No wrangler, no network. +// +// The suite pins the wire contract, not what the tools compute: a tools/call +// row asserts the result's shape and that `content` is there, not that `search` +// ranked anything. Those rows are hand-maintained per tool and per return site +// — nothing derives how many return sites a tool has, so a tool that grows one +// needs a row added with it. + +import assert from 'node:assert/strict'; +import { MODERN_PROTOCOL_VERSIONS, LEGACY_ONLY_METHODS } from '../src/index.ts'; + +const { default: worker } = await import('../src/index.ts'); + +// --- runner --------------------------------------------------------------- + +let failures = 0; + +function check(label, actual, expected) { + try { + assert.deepStrictEqual(actual, expected); + console.log(` ok ${label}`); + } catch { + failures++; + console.log( + ` FAIL ${label} — got ${JSON.stringify(actual)}, wanted ${JSON.stringify(expected)}`, + ); + } +} + +// --- harness -------------------------------------------------------------- + +const ENDPOINT = 'https://mcp.test/mcp'; +const MODERN_VERSION = '2026-07-28'; +const LEGACY_VERSION = '2025-11-25'; + +const META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion'; +const META_CLIENT_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities'; + +let nextId = 1; + +// The `Mcp-Name` header mirrors a body value for some methods (transports/ +// streamable-http). This MUST stay in step with expectedMcpName() in +// src/index.ts, down to the non-string fallback: the Worker expects '' for a +// name that is not a string, so anything else here produces a -32020 header +// mismatch that reads like a Worker bug. Returns null when the method defines +// no name; sending the header when it is not expected is as wrong as omitting +// it when it is. +function mcpNameFor(method, params) { + 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; + } +} + +// POSTs an arbitrary body. `env` defaults to {} — pass a stub binding to +// exercise logMcpCall. Returns the parsed response plus the id that was sent, +// so callers can assert the JSON-RPC id echo. +async function postRaw(body, headers = {}, env = {}) { + const res = await worker.fetch( + new Request(ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }), + env, + ); + const text = await res.text(); + return { + status: res.status, + body: text, + json: text ? JSON.parse(text) : null, + sentId: Array.isArray(body) ? undefined : body.id, + }; +} + +// A request in the handshake era: no _meta version key, no modern headers. +function callLegacy(method, params = {}, env = {}) { + return postRaw({ jsonrpc: '2.0', id: nextId++, method, params }, {}, env); +} + +// The body and headers of a 2026-07-28 message: the per-request version in +// params._meta, mirrored into MCP-Protocol-Version, plus Mcp-Method and, where +// the method defines one, Mcp-Name. `id` is omitted for a notification. +// +// RequestMetaObject.required is both the version key and clientCapabilities, so +// a request without the latter is one no conforming client would send — an +// empty object is the declaration that the client supports nothing optional. +function modernMessage(method, params, id) { + const headers = { 'mcp-protocol-version': MODERN_VERSION, 'mcp-method': method }; + const name = mcpNameFor(method, params); + if (name !== null) headers['mcp-name'] = name; + + const body = { + jsonrpc: '2.0', + ...(id === undefined ? {} : { id }), + method, + params: { + ...params, + _meta: { [META_PROTOCOL_VERSION]: MODERN_VERSION, [META_CLIENT_CAPABILITIES]: {} }, + }, + }; + return { body, headers }; +} + +// A request in the 2026-07-28 era. +function callModern(method, params = {}, env = {}) { + const { body, headers } = modernMessage(method, params, nextId++); + return postRaw(body, headers, env); +} + +// A modern request with one header overridden or removed (`null` removes it), +// for the transport-validation rejections. +function callModernWithHeaders(method, params, overrides) { + const { body, headers } = modernMessage(method, params, nextId++); + for (const [k, v] of Object.entries(overrides)) { + if (v === null) delete headers[k]; + else headers[k] = v; + } + return postRaw(body, headers); +} + +// Notifications carry no id and must be answered with 202 and no body. +function notify(method, modern = false) { + if (!modern) return postRaw({ jsonrpc: '2.0', method, params: {} }); + const { body, headers } = modernMessage(method, {}, undefined); + return postRaw(body, headers); +} + +// --- the method table ----------------------------------------------------- + +// A new protocol method is a row here and nothing else, unless it also mirrors +// a body value into `Mcp-Name` — then mcpNameFor() above needs a case too. +// +// Fields: +// method the JSON-RPC method; `params` its params. Both required. +// label row name, for a row sharing its method with another. +// resultField the member 2026-07-28 requires alongside `resultType`. +// Asserted present AND non-empty, so emptying the payload fails. +// cacheable carries `ttlMs` + `cacheScope`. Rows without it assert those +// are ABSENT, so a stray hint fails. +// expect extra members to assert by value. +// resultKeys the exact key list, in order, asserted in BOTH eras. complete() +// sits inside handleRpc, so a handshake-era client is served the +// same members a 2026-07-28 one is; this pins that, in both +// directions. Moving the stamp to the era boundary changes the +// bytes legacy clients see, and has to edit these lists. +// +// Each row cites the schema definition behind it: schema/2026-07-28/schema.json +// in github.com/modelcontextprotocol/modelcontextprotocol. +const METHOD_CASES = [ + { + method: 'tools/list', + params: {}, + // ListToolsResult.required = [cacheScope, resultType, tools, ttlMs] + resultField: 'tools', + cacheable: true, + resultKeys: ['resultType', 'ttlMs', 'cacheScope', 'tools', '_meta'], + }, + { + method: 'prompts/list', + params: {}, + // ListPromptsResult.required = [cacheScope, prompts, resultType, ttlMs] + resultField: 'prompts', + cacheable: true, + resultKeys: ['resultType', 'ttlMs', 'cacheScope', 'prompts', '_meta'], + }, + { + method: 'tools/call', + params: { name: 'get_categories', arguments: {} }, + // CallToolResult.required = [content, resultType] — no cache hints, so the + // `cacheable` omission above is an assertion, not merely a default. + resultField: 'content', + resultKeys: ['resultType', 'content', 'structuredContent', '_meta'], + }, + { + method: 'tools/call', + label: 'tools/call (tool-reported error)', + // A bad argument is a tool execution error: a result with isError, not a + // protocol error (SEP-1303), so it still needs resultType. getTopicTool + // returns the isError result itself. + params: { name: 'get_topic', arguments: { slug: 'no-such-page-exists' } }, + resultField: 'content', + expect: { isError: true }, + resultKeys: ['resultType', 'isError', 'content', '_meta'], + }, + { + method: 'tools/call', + label: 'tools/call (thrown tool error)', + // The other half of SEP-1303: a tool that THROWS is caught in handleRpc and + // turned into an isError result. Different code path from the row above, + // and the key order it produces differs too, which `resultKeys` pins. + params: { name: 'search', arguments: {} }, + resultField: 'content', + expect: { isError: true }, + resultKeys: ['resultType', 'content', 'isError', '_meta'], + }, + { + method: 'prompts/get', + params: { name: 'audit_url', arguments: { url: 'https://example.com' } }, + // GetPromptResult.required = [messages, resultType] + resultField: 'messages', + resultKeys: ['resultType', 'description', 'messages', '_meta'], + }, +]; + +const labelOf = (c) => c.label ?? c.method; + +// Every tool declares an outputSchema, and server/tools obliges a tool that +// does to return `structuredContent`. The obligation is per RETURN SITE, and +// the empty branch is the one that forgets — get_checklist shipped without it. +// Each row is driven by arguments chosen to match nothing, and `empty` names +// the structuredContent members that prove the empty branch is the one that +// ran: without that, a content change would leave the row passing against the +// populated branch instead. +// +// `search` is content-independent — no page can rank for that query. The others +// filter on real enum values, so each pins the count it expects to be zero and +// fails loudly if the spec grows a page that matches. +const EMPTY_RESULT_CASES = [ + { tool: 'search', args: { query: 'zqxjkvwmpb-no-such-topic' }, empty: { count: 0, results: [] } }, + { + tool: 'get_checklist', + args: { category: 'privacy', status: 'avoid' }, + empty: { total: 0, categories: [] }, + }, + { + tool: 'list_topics', + args: { category: 'privacy', status: 'avoid' }, + empty: { count: 0, topics: [] }, + }, + { tool: 'get_changes', args: { since: '2099-01-01' }, empty: { count: 0, changes: [] } }, +]; + +// What a client of the era that still has them would send. A method with no +// required params needs no entry. +const LEGACY_ONLY_PARAMS = { 'logging/setLevel': { level: 'info' } }; + +// The modern era's transport rules (transports/streamable-http). Every row is a +// MUST-reject, answered with HTTP 400 and the listed JSON-RPC code: +// -32020 header mismatch, -32022 unsupported protocol version. `headers` +// overrides one header on an otherwise valid request, or removes it with null. +// `method` and `params` default to 'tools/list' with no params; a row sets them +// only when the rule under test needs a method that mirrors a body value into +// `Mcp-Name`. +const MODERN_REJECTIONS = [ + { label: 'missing MCP-Protocol-Version', headers: { 'mcp-protocol-version': null } }, + { + label: 'MCP-Protocol-Version disagrees with the body', + headers: { 'mcp-protocol-version': '2025-11-25' }, + }, + { label: 'missing Mcp-Method', headers: { 'mcp-method': null } }, + { label: 'Mcp-Method disagrees with the body', headers: { 'mcp-method': 'prompts/list' } }, + { + label: 'missing Mcp-Name', + method: 'tools/call', + params: { name: 'get_categories', arguments: {} }, + headers: { 'mcp-name': null }, + }, + { + label: 'Mcp-Name disagrees with the body', + method: 'tools/call', + params: { name: 'get_categories', arguments: {} }, + headers: { 'mcp-name': 'search' }, + }, +]; + +// 2026-07-28 requires `resultType` on the base Result, so on every result. +// schema.json types it as a bare string; the allowed values live only in +// schema.ts. Hence this constant rather than JSON-schema validation. +const COMPLETE = 'complete'; + +// --- assertions ----------------------------------------------------------- + +console.log('\nmodern era — every result carries the members 2026-07-28 requires'); +for (const c of METHOD_CASES) { + const label = labelOf(c); + const { json, sentId } = await callModern(c.method, c.params); + check(`${label} — echoes the request id`, json.id, sentId); + check(`${label} — resultType`, json.result?.resultType, COMPLETE); + // Presence is not enough. `'tools' in result` is satisfied by zero tools, + // which is the exact symptom this suite exists for. + const value = json.result?.[c.resultField]; + check( + `${label} — '${c.resultField}' is a non-empty array`, + Array.isArray(value) && value.length > 0, + true, + ); + for (const [k, v] of Object.entries(c.expect ?? {})) { + check(`${label} — result.${k}`, json.result?.[k], v); + } +} + +console.log('\nboth eras are served the same result members'); +// complete() sits inside handleRpc, which both eras share, so a handshake-era +// client is served the same members a 2026-07-28 one is. That is a deliberate +// choice and not a free one — `resultType` and the cache hints do not exist +// before 2026-07-28, and clients on those revisions never asked for them. It is +// legal (2025-11-25's Result carries `additionalProperties: {}`), so what this +// pins is the decision, in both directions: moving the stamp to the era +// boundary changes the bytes legacy clients see, and must edit these lists. +for (const c of METHOD_CASES) { + const label = labelOf(c); + for (const [era, call] of [ + ['modern', callModern], + ['legacy', callLegacy], + ]) { + const { json, sentId } = await call(c.method, c.params); + check(`${era} ${label} — echoes the request id`, json.id, sentId); + check(`${era} ${label} — result key list`, Object.keys(json.result ?? {}), c.resultKeys); + } +} + +console.log('\nan empty result still carries structuredContent'); +for (const c of EMPTY_RESULT_CASES) { + const { json } = await callModern('tools/call', { name: c.tool, arguments: c.args }); + check(`${c.tool} — exercises a success branch`, json.result?.isError, undefined); + for (const [k, v] of Object.entries(c.empty)) { + check(`${c.tool} — structuredContent.${k} (row still matches nothing)`, json.result?.structuredContent?.[k], v); + } +} + +console.log('\nping and logging/setLevel — removed by 2026-07-28'); +// Two hand-written copies agreeing with each other catch an edit to one, not an +// edit to both: that shrinks the loop below instead of failing it, and the +// Worker goes back to answering a method the revision deleted. So the list is +// pinned to its literal as well. A third entry is a decision about the era, not +// a rename, and should have to be made twice. +check('the gate names exactly these two', [...LEGACY_ONLY_METHODS].sort(), [ + 'logging/setLevel', + 'ping', +]); +for (const method of LEGACY_ONLY_METHODS) { + const params = LEGACY_ONLY_PARAMS[method] ?? {}; + // Legacy answers both exactly as before: a bare {}, no resultType. + const legacy = await callLegacy(method, params); + check(`legacy ${method} — still answered, still bare`, Object.keys(legacy.json.result ?? {}), []); + // Modern can answer neither way. That revision requires `resultType` on every + // result (Result.required = ["resultType"], EmptyResult is a $ref to Result) + // while defining neither method — so an unstamped {} is schema-invalid and a + // stamped one reports success for a method that does not exist. + const modern = await callModern(method, params); + check(`modern ${method} — HTTP 404`, modern.status, 404); + check(`modern ${method} — -32601`, modern.json.error?.code, -32601); + check(`modern ${method} — no result`, modern.json.result, undefined); +} + +console.log('\ncache hints — only on the result types that declare them'); +for (const c of METHOD_CASES) { + const label = labelOf(c); + const { json } = await callModern(c.method, c.params); + if (c.cacheable) { + check(`${label} — ttlMs is a positive number`, typeof json.result.ttlMs === 'number' && json.result.ttlMs > 0, true); + check(`${label} — cacheScope`, json.result.cacheScope, 'public'); + } else { + // Not an omission. The revision defines cache hints only for the result + // types that declare them, so a hint here would be wrong — publicComplete() + // creeping onto tools/call would have clients cache a per-request result + // for an hour. + check(`${label} — carries no ttlMs`, 'ttlMs' in json.result, false); + check(`${label} — carries no cacheScope`, 'cacheScope' in json.result, false); + } +} + +console.log('\nserver/discover — what a modern client opens with'); +{ + const { status, json } = await callModern('server/discover'); + check('HTTP 200', status, 200); + // DiscoverResult.required = [cacheScope, capabilities, resultType, + // supportedVersions, ttlMs]. All five are asserted by value: this branch sets + // its own cache hints through publicComplete(), and `capabilities` is pinned + // by key because the point of the method is letting a client skip probing — + // an empty or partial set is schema-valid and leaves the endpoint + // functionally dead for anyone who trusts it. + check('resultType', json.result.resultType, COMPLETE); + check('supportedVersions', json.result.supportedVersions, [MODERN_VERSION]); + check('capabilities', Object.keys(json.result.capabilities).sort(), ['prompts', 'tools']); + check('ttlMs is positive', json.result.ttlMs > 0, true); + check('cacheScope', json.result.cacheScope, 'public'); +} + +// The era boundary applies 2026-07-28's facts: complete()/publicComplete() +// inside handleRpc, the LEGACY_ONLY_METHODS gate in handleMcp(). Any edit to +// this array opts a revision into all of them, whether it appends one or +// replaces the one there. +check( + 'a new modern revision has to be checked against the boundary first', + MODERN_PROTOCOL_VERSIONS, + [MODERN_VERSION], +); + +console.log('\ninitialize — the handshake era, never stamped'); +{ + const params = { + protocolVersion: MODERN_VERSION, + capabilities: {}, + clientInfo: { name: 'harness', version: '0' }, + }; + // A handshake must never be answered with a revision that has no handshake. + const negotiated = await callLegacy('initialize', params); + check('a modern version requested at the handshake is answered with a legacy one', negotiated.json.result.protocolVersion, LEGACY_VERSION); + + // An initialize carrying the modern _meta key is still reachable: the gate + // sees a version and handleRpc answers the handshake. It is the one branch + // complete() must never reach, in either era — 2026-07-28 has no + // InitializeResult, because it has no handshake. + const plain = await postRaw({ jsonrpc: '2.0', id: 1, method: 'initialize', params }); + const withMeta = await postRaw( + { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { ...params, _meta: { [META_PROTOCOL_VERSION]: MODERN_VERSION } }, + }, + { 'mcp-protocol-version': MODERN_VERSION, 'mcp-method': 'initialize' }, + ); + check('unstamped result members', Object.keys(plain.json.result), [ + 'protocolVersion', + 'serverInfo', + 'capabilities', + 'instructions', + ]); + check('_meta on the request changes nothing', JSON.stringify(withMeta.json.result), JSON.stringify(plain.json.result)); +} + +console.log('\nmodern transport rules (transports/streamable-http)'); +// Every one is a MUST. The era gate makes the result-shape fix reachable at +// all, so it has to be more than deletable-with-CI-green. +for (const r of MODERN_REJECTIONS) { + const { status, json } = await callModernWithHeaders( + r.method ?? 'tools/list', + r.params ?? {}, + r.headers, + ); + check(`rejects: ${r.label}`, [status, json.error?.code, json.result], [400, -32020, undefined]); +} +{ + const { status, json } = await postRaw( + { + jsonrpc: '2.0', + id: nextId++, + method: 'tools/list', + params: { _meta: { [META_PROTOCOL_VERSION]: '1999-01-01' } }, + }, + { 'mcp-protocol-version': '1999-01-01', 'mcp-method': 'tools/list' }, + ); + check('rejects: an unsupported protocol version', [status, json.error?.code], [400, -32022]); + check('names what it does support', json.error.data.supported, [MODERN_VERSION]); +} + +// A non-ASCII header value arrives wrapped in `=?base64?…?=` and MUST be +// decoded before comparison (transports/streamable-http#value-encoding). +// Nothing else in the suite reaches decodeHeaderValue(). +{ + const encoded = `=?base64?${Buffer.from('get_categories', 'utf8').toString('base64')}?=`; + const { status, json } = await callModernWithHeaders( + 'tools/call', + { name: 'get_categories', arguments: {} }, + { 'mcp-name': encoded }, + ); + check('decodes a base64-wrapped Mcp-Name before comparing', [status, json.result?.resultType], [200, COMPLETE]); +} + +// `Mcp-Name` mirrors params.name for tools/call and prompts/get, params.uri for +// resources/read. This server does not implement resources/read, so what is +// pinned is that the request reaches the method switch: a harness mirroring the +// wrong member would be rejected at -32020, never reaching this -32601. +{ + const { status, json } = await callModern('resources/read', { uri: 'spec://tools/list' }); + check('resources/read reaches the method switch', [status, json.error?.code], [404, -32601]); +} + +// Mirroring expectedMcpName() means mirroring its non-string fallback. The +// Worker expects an empty Mcp-Name for a non-string name, so a harness that +// coerced 42 to "42" would be rejected at -32020, never reaching this -32602. +{ + const { status, json } = await callModern('tools/call', { name: 42, arguments: {} }); + check('a non-string tool name reaches the tool switch', [status, json.error?.code], [200, -32602]); +} + +console.log('\nthe era gate reads a SUPPORTED version, not the presence of the key'); +// validateModernRequest() returns early for a message with no usable `id` — the +// revision leaves notification header rules undefined — so these requests are +// never validated at all, only their version is read. Gating on presence alone +// would answer them under an era they never asked for. +{ + const { status, json } = await postRaw({ + jsonrpc: '2.0', + id: null, + method: 'ping', + params: { _meta: { [META_PROTOCOL_VERSION]: '1999-01-01' } }, + }); + check('an unsupported version keeps the methods its era still has', [status, Object.keys(json.result ?? {})], [200, []]); +} +{ + const { status, json } = await postRaw({ + jsonrpc: '2.0', + id: null, + method: 'no/such/method', + params: { _meta: { [META_PROTOCOL_VERSION]: '1999-01-01' } }, + }); + check('an unsupported version does not get the modern 404 mapping', [status, json.error.code], [200, -32601]); +} + +console.log('\nJSON-RPC errors and the 404 mapping'); +{ + const legacy = await callLegacy('no/such/method'); + check('legacy method-not-found stays HTTP 200', [legacy.status, legacy.json.error.code], [200, -32601]); + const modern = await callModern('no/such/method'); + // The modern era distinguishes "no such method" from a legacy 404. + check('modern method-not-found is HTTP 404', [modern.status, modern.json.error.code], [404, -32601]); + check('an error envelope carries no result', modern.json.result, undefined); +} +// The 404 mapping is narrow on purpose: method-not-found maps to a missing +// resource, not every error. An unknown TOOL is a valid tools/call, so it stays +// 200 with -32602. +{ + const { status, json } = await callModern('tools/call', { name: 'no_such_tool', arguments: {} }); + check('an unknown tool is 200 with -32602, not a 404', [status, json.error.code], [200, -32602]); +} + +console.log('\nnotifications and batches'); +for (const modern of [false, true]) { + const res = await notify('notifications/initialized', modern); + check(`a notification is 202 with an empty body (modern=${modern})`, [res.status, res.body], [202, '']); +} +{ + const { status, body } = await postRaw([ + { jsonrpc: '2.0', method: 'notifications/initialized', params: {} }, + ]); + check('an all-notification batch is 202 with an empty body', [status, body], [202, '']); +} +// Batching was removed in 2025-06-18, so a batch can only be legacy, and +// handleMcp answers it before the era gate. These pin that. Without them, the +// day someone makes batches modern too, batching clients break silently. +{ + const { status, json } = await postRaw([ + { jsonrpc: '2.0', id: 101, method: 'tools/list', params: {} }, + { jsonrpc: '2.0', id: 102, method: 'ping', params: {} }, + ]); + check('a batch is answered with an array, in order', [status, json.map((r) => r.id)], [200, [101, 102]]); + // `ping` is answered, not rejected: the gate that removes it belongs to the + // modern era, and a batch is never in it. + check('a batch never reaches the era gate', Object.keys(json[1].result), []); +} +{ + const { status, json } = await postRaw([ + { + jsonrpc: '2.0', + id: 103, + method: 'ping', + params: { _meta: { [META_PROTOCOL_VERSION]: MODERN_VERSION } }, + }, + ]); + check('nor does a batch carrying the modern _meta key', [status, Object.keys(json[0].result)], [200, []]); +} + +console.log('\nusage logging'); +// logMcpCall() reads `isError` off the result. A three-line stub binding — not +// a mocking library — is enough to run it. Without one it returns before its +// own try/catch and never executes. +{ + const rows = []; + const env = { MCP_LOG: { writeDataPoint: (row) => rows.push(row) } }; + const { json } = await callModern( + 'tools/call', + { name: 'get_topic', arguments: { slug: 'no-such-page-exists' } }, + env, + ); + check('the response is unchanged', [json.result.resultType, json.result.isError], [COMPLETE, true]); + check('one row written', rows.length, 1); + check('method and tool name', rows[0].blobs.slice(0, 2), ['tools/call', 'get_topic']); + // blob9 is the isError column /admin/stats reads. It comes from the response + // handleMcp returned, so this fails if a tool-reported error stops being + // passed through as a result. + check('the isError column', rows[0].blobs[8], '1'); +} + +if (failures > 0) { + console.error(`\n${failures} assertion(s) failed.`); + process.exit(1); +} +console.log('\nAll MCP protocol assertions passed.\n'); diff --git a/mcp/ts-resolve-hook.mjs b/mcp/ts-resolve-hook.mjs new file mode 100644 index 00000000..fd4d927e --- /dev/null +++ b/mcp/ts-resolve-hook.mjs @@ -0,0 +1,23 @@ +// Lets scripts/test-protocol.mjs load the Worker's TypeScript sources directly. +// +// wrangler bundles the Worker, so its relative imports are extensionless +// (`from './tools'`). Node's resolver wants the extension; this appends `.ts`. +// The hook is process-global and serves require() too, so it skips +// node_modules: a dependency's own `require('./lib/helper')` must still find +// helper.js. +// +// Load with `--import`, never a plain import from the test — resolution happens +// during linking, so a hook registered from a module body is too late for its +// own imports. Needs Node >= 22.15 for registerHooks; below that the named +// import fails at link time, which is why there is no version check. The test +// script's --experimental-strip-types is for 22.15-22.17; stripping is on by +// default from 22.18, where the flag is a no-op. +import { registerHooks } from 'node:module'; + +registerHooks({ + resolve(specifier, context, next) { + const local = specifier.startsWith('.') && !context.parentURL?.includes('/node_modules/'); + const extensionless = local && !/\.[cm]?[jt]s(on)?$/.test(specifier); + return next(extensionless ? `${specifier}.ts` : specifier, context); + }, +}); From 71c12532ec998b8ff8cccdfab5f79734e5f84716 Mon Sep 17 00:00:00 2001 From: Sergei Khomenkov Date: Fri, 4 Sep 2026 15:12:11 +0300 Subject: [PATCH 3/4] docs: the Worker's assertions and its Node floor CLAUDE.md's `mcp/` row now names them, and the Commands section gains the package's own scripts. Records the one non-obvious trap: typecheck fails with TS2307 on a fresh clone until build:data has run, because src/index.ts imports the generated, gitignored src/data.json. Also records why they are deliberately not in the pre-commit hook. The Deployment bullet said ci.yml "only runs type-check + build verification", which was already short of what it ran. It now says ci.yml verifies rather than listing jobs, so the next one added will not stale it again. mcp/README.md gains a Tests section and the Node >= 22.15 floor. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JFy2qPKK546AEFGixoxYoE --- CLAUDE.md | 8 +++++--- mcp/README.md | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ea965cd5..47a6b51b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ These mirror `CONTRIBUTING.md`. Enforce them in your own writing and when review | `wrangler.toml` (root) | Pages bindings — the `AGENT_LOG` (`sw_agent_log`) and `REPORT_LOG` (`sw_report_log`) Analytics Engine datasets. Pages itself is deployed via the Pages dashboard's Git integration. | | `public/_headers` | Cloudflare response headers — strict CSP, HSTS, Permissions-Policy, Vary on .md, content types for well-known files, the discovery `Link` header. | | `public/.well-known/` | Static well-known URIs (security.txt, change-password, api-catalog, mcp/server-card.json, agent-card.json for A2A discovery, agent-skills/index.json + agent-skills//SKILL.md per the Agent Skills Discovery RFC v0.2.0 — if you edit a SKILL.md, recompute its sha256 and update the `digest` in index.json; ai-catalog.json is the ARD AI Catalog, published unsigned — see [agentic-resource-discovery](src/content/spec/agent-readiness/agentic-resource-discovery.md) for why a same-origin trust-manifest signature proves nothing). | -| `mcp/` | Cloudflare Worker exposing the spec at `mcp.specification.website`. Serves the MCP transport at `/mcp`, an A2A (Agent-to-Agent) JSON-RPC endpoint at `/a2a/v1`, and mirrors both discovery cards under `/.well-known/`. Has its own `package.json`, `wrangler.toml`, build script. Reads from the same `src/content/spec/` source of truth at build time. Logs each call to the `MCP_LOG` Analytics Engine dataset (`sw_mcp_log`). | +| `mcp/` | Cloudflare Worker exposing the spec at `mcp.specification.website`. Serves the MCP transport at `/mcp`, an A2A (Agent-to-Agent) JSON-RPC endpoint at `/a2a/v1`, and mirrors both discovery cards under `/.well-known/`. Has its own `package.json`, `wrangler.toml`, build script, and assertions (`npm test` — plain `node:assert`, no dependencies, Node >= 22.15). Reads from the same `src/content/spec/` source of truth at build time. Logs each call to the `MCP_LOG` Analytics Engine dataset (`sw_mcp_log`). | | `public/search-overlay.js` | ⌘K overlay logic. CSP-safe (no inline JS). | | `public/search-init.js` | `/search/` page Pagefind initialiser. CSP-safe. | | `scripts/generate-assets.mjs` | Generates icons + OG image from inline SVGs via `sharp`. Wired through `prebuild`/`predev`. | @@ -116,7 +116,9 @@ npm run assets # regenerate icons + OG image `predev` and `prebuild` run `scripts/generate-assets.mjs` automatically. -**Pre-commit gate.** A tracked git hook at `.githooks/pre-commit` runs `npm run lint` and `npm run format:check` on every `git commit`; `core.hooksPath` is pointed at `.githooks/` by the `prepare` script on `npm install` (no husky). The same two checks run in CI (`ci.yml`). Run them before committing so the hook passes; `prettier --write .` fixes formatting. Bypass only in a genuine emergency with `git commit --no-verify`. +The Worker in `mcp/` has its own scripts, run from there: `npm test`, `npm run typecheck`, `npm run dev` (wrangler on 31338). On a fresh clone run `npm run build:data` first — `src/data.json` is generated, so `typecheck` fails with `TS2307` without it; `pretest` covers `npm test`. + +**Pre-commit gate.** A tracked git hook at `.githooks/pre-commit` runs `npm run lint` and `npm run format:check` on every `git commit`; `core.hooksPath` is pointed at `.githooks/` by the `prepare` script on `npm install` (no husky). The same two checks run in CI (`ci.yml`). Run them before committing so the hook passes; `prettier --write .` fixes formatting. Bypass only in a genuine emergency with `git commit --no-verify`. The Worker's assertions are deliberately **not** in the hook: `mcp/` has its own dependency tree that most contributors never install, and running them would rewrite the generated `mcp/src/data.json` on every unrelated commit. CI is the gate for that. ## Workflow when adding or changing a spec page @@ -198,7 +200,7 @@ Like the changelog, this collection is **not derived** — nothing generates it. ## Deployment -- `main` → Cloudflare Pages, auto-deployed via the Pages dashboard's Git integration. No GitHub Actions deploy workflow (`ci.yml` only runs type-check + build verification). +- `main` → Cloudflare Pages, auto-deployed via the Pages dashboard's Git integration. No GitHub Actions deploy workflow (`ci.yml` verifies; it does not deploy). - Custom domain for the site: `specification.website` (configure in the Cloudflare Pages dashboard). - Functions live in `/functions/` and ship alongside static assets. The Cloudflare build picks them up automatically. - The **MCP server** in `/mcp/` is a separate Cloudflare Worker. It registers `mcp.specification.website` as a custom domain on first deploy. It is **redeployed automatically** by the `Deploy MCP` GitHub Action whenever a push to `main` touches `src/content/spec/**`, `src/content/changelog/**`, or `mcp/**`, so its bundled data stays in sync (the predeploy hook regenerates `mcp/src/data.json`). The Action authenticates with the `CLOUDFLARE_API_TOKEN` repo secret (Workers Scripts: Edit). Manual fallback: `cd mcp && npm run deploy`. diff --git a/mcp/README.md b/mcp/README.md index c46798bc..b1b5712f 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -48,6 +48,29 @@ curl -sX POST http://localhost:31338/mcp \ | jq ``` +## Tests + +```bash +cd mcp +npm test # scripts/test-protocol.mjs +npm run typecheck +``` + +`scripts/test-protocol.mjs` drives the Worker's real fetch handler in-process +and asserts the two-era wire contract: the members revision 2026-07-28 requires +on every result, the cache hints only where that revision defines them, the +methods it removed, the transport rules, and that the handshake era keeps +answering. Same idiom as `scripts/test-websub.mjs` at the repo root — plain +node + `node:assert`, no framework, no dependency, no network. + +Needs **Node >= 22.15**, above the root's declared `>= 22.12`: the sources are +TypeScript and `ts-resolve-hook.mjs` uses `node:module`'s `registerHooks`, added +in 22.15. Below that the run fails at link time naming the missing export. + +`pretest` runs `build:data`, so a fresh clone needs nothing else. `typecheck` +has no such hook — run `npm run build:data` first, or `tsc` cannot resolve the +generated `src/data.json`. + ## Deploy First time: From 9a6b6211de980787223efcb122903437b1edeeda Mon Sep 17 00:00:00 2001 From: Sergei Khomenkov Date: Fri, 4 Sep 2026 15:12:51 +0300 Subject: [PATCH 4/4] add(ci): run the Worker's type-check and assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A proposal, and deliberately the last commit: adding a gate to this repo's CI is the maintainer's call, so drop this one and everything before it still stands. The one line elsewhere that names the job — a sentence in CLAUDE.md — is inside this commit for that reason. mcp/ is a separate package with its own lockfile, so the build job never touched it: the root `npm ci` doesn't install it, and both eslint.config.js and .prettierignore exclude mcp/ deliberately. Its type-check had never run in CI at all. A second job keeps a Worker failure reading distinctly from a site-build failure. The build job is unchanged. build:data is an explicit step: src/index.ts imports the generated, gitignored src/data.json, so `tsc --noEmit` fails with TS2307 on a clean checkout. pretest would regenerate it, but relying on that makes step order quietly load-bearing. The runner is pinned to 22.15 rather than the build job's 22: ts-resolve-hook .mjs needs node:module's registerHooks, added there. `22` resolves to something newer today, so this only matters the day it doesn't — and the failure it prevents is a link-time missing export with nothing to point at. Not added to .githooks/pre-commit: that fires on every commit, almost none of which touch mcp/, and it would need this package's separate dependency tree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JFy2qPKK546AEFGixoxYoE --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eba6391..825230d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,44 @@ jobs: name: dist path: dist retention-days: 7 + + # The MCP Worker in mcp/ is a separate package with its own lockfile, so the + # job above never touches it: the root `npm ci` doesn't install it, and both + # eslint.config.js and .prettierignore exclude mcp/ deliberately. Its + # type-check had never run in CI either. A separate job so a Worker failure + # reads distinctly from a site-build failure. + mcp: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + # ts-resolve-hook.mjs needs registerHooks, added in 22.15. The `22` + # the job above uses would satisfy that today, but pinning the floor + # here keeps a runner rolling back from failing at link time with + # nothing to point at. + node-version: 22.15 + cache: npm + cache-dependency-path: mcp/package-lock.json + + - name: Install MCP dependencies + run: npm ci + working-directory: mcp + + # src/data.json is generated rather than committed, and src/index.ts + # imports it — so tsc cannot resolve the module until the manifest + # exists. `npm test` regenerates it as well (pretest), but the + # type-check has no such hook and runs first. + - name: Build data manifest + run: npm run build:data + working-directory: mcp + + - name: Type-check (tsc) + run: npm run typecheck + working-directory: mcp + + - name: Test (node:assert) + run: npm test + working-directory: mcp diff --git a/CLAUDE.md b/CLAUDE.md index 47a6b51b..6889cb1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ npm run assets # regenerate icons + OG image `predev` and `prebuild` run `scripts/generate-assets.mjs` automatically. -The Worker in `mcp/` has its own scripts, run from there: `npm test`, `npm run typecheck`, `npm run dev` (wrangler on 31338). On a fresh clone run `npm run build:data` first — `src/data.json` is generated, so `typecheck` fails with `TS2307` without it; `pretest` covers `npm test`. +The Worker in `mcp/` has its own scripts, run from there: `npm test`, `npm run typecheck`, `npm run dev` (wrangler on 31338). On a fresh clone run `npm run build:data` first — `src/data.json` is generated, so `typecheck` fails with `TS2307` without it; `pretest` covers `npm test`. CI's `mcp` job runs the three in that order. **Pre-commit gate.** A tracked git hook at `.githooks/pre-commit` runs `npm run lint` and `npm run format:check` on every `git commit`; `core.hooksPath` is pointed at `.githooks/` by the `prepare` script on `npm install` (no husky). The same two checks run in CI (`ci.yml`). Run them before committing so the hook passes; `prettier --write .` fixes formatting. Bypass only in a genuine emergency with `git commit --no-verify`. The Worker's assertions are deliberately **not** in the hook: `mcp/` has its own dependency tree that most contributors never install, and running them would rewrite the generated `mcp/src/data.json` on every unrelated commit. CI is the gate for that.