Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,40 @@ 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:
node-version: 22
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:test)
run: npm test
working-directory: mcp
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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 test suite (`npm test` — `node:test`, no dependencies). 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`. |
Expand All @@ -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` (`node:test`, no dependencies, Node >= 22.15), `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 suite is deliberately **not** in the hook: `mcp/` has its own dependency tree that most contributors never install, and running it 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

Expand Down Expand Up @@ -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`.
Expand Down
11 changes: 11 additions & 0 deletions mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ curl -sX POST http://localhost:31338/mcp \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq
```

Tests and type-check:

```bash
npm test # node:test, both protocol eras (pretest regenerates src/data.json)
npm run typecheck # tsc --noEmit; needs src/data.json, so run build:data first
```

No dependencies beyond what `npm install` already put here, but Node >= 22.15: the tests load the TypeScript sources directly through `module.registerHooks`, added in 22.15.

`test-lib/cases.mjs` holds the method × era table, so a new protocol method is mostly one row there — a method that mirrors a body value into the `Mcp-Name` header needs a case in `mcpNameFor()` in `test-lib/harness.mjs` as well.

## Deploy

First time:
Expand Down
2 changes: 2 additions & 0 deletions mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 --test",
"predev": "npm run build:data",
"dev": "wrangler dev --port 31338",
"predeploy": "npm run build:data",
Expand Down
56 changes: 51 additions & 5 deletions mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -154,6 +154,43 @@ function err(
return { jsonrpc: '2.0', id: id ?? null, error: { code, message, data } };
}

// Cache hints (server/utilities/caching) are a property of the method, not the
// result. Keyed here rather than set inside handleRpc, which would put them on
// legacy responses too. A cacheable result type added later — resources/list,
// resources/read, resources/templates/list — needs an entry.
const MODERN_CACHE_HINTS: Record<string, { ttlMs: number; cacheScope: 'public' }> = {
// The manifest is baked in at build time and only changes on deploy.
'tools/list': { ttlMs: 3_600_000, cacheScope: 'public' },
'prompts/list': { ttlMs: 3_600_000, cacheScope: 'public' },
};

// Methods handleRpc answers that 2026-07-28 removed. Neither is in its
// ClientRequest union; `logging/setLevel` gave way to the
// io.modelcontextprotocol/logLevel `_meta` key (SEP-2577). Stamping one would
// report success for a method the era does not define, so the modern era
// answers -32601. `initialize` is not here — it selects the handshake era for
// its own message, and is answered in that shape.
export const LEGACY_ONLY_METHODS = ['ping', 'logging/setLevel'];

// Every 2026-07-28 result carries a `resultType` discriminator
// (basic/index#result). This server has no multi round-trip paths, so all of
// them are complete. Applied at the era boundary, so handleRpc stays
// era-agnostic and a method added later is covered by default. Whatever a
// branch already set wins, so server/discover passes through unchanged.
// `initialize` is excluded: 2026-07-28 has no InitializeResult.
export function asModernResult(resp: RpcResponse, method: string): RpcResponse {
if (method === 'initialize') return resp;
if (!('result' in resp)) return resp; // errors carry none, and this narrows the union below
const result = resp.result;
// Unreachable today: every `ok()` passes an object literal. But an array must
// pass through, not be spread into numeric keys.
if (typeof result !== 'object' || result === null || Array.isArray(result)) return resp;
return {
...resp,
result: { resultType: 'complete', ...MODERN_CACHE_HINTS[method], ...(result as object) },
};
}

function handleRpc(req: RpcRequest): RpcResponse | null {
const { id, method, params = {} } = req;

Expand Down Expand Up @@ -533,7 +570,9 @@ async function handleMcp(request: Request, env: Env): Promise<Response> {
});
}

// Batch or single
// Batch or single. A batch can only be legacy: batching was removed in
// 2025-06-18, so no client conforming to 2026-07-28 sends one. This path
// stays legacy-shaped.
if (Array.isArray(body)) {
const responses = body
.map((r) => {
Expand All @@ -554,7 +593,11 @@ async function handleMcp(request: Request, env: Env): Promise<Response> {
// 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 is a SUPPORTED version, not the mere presence of the key.
// validateModernRequest() returns early for a message with no `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) {
Expand All @@ -563,16 +606,19 @@ async function handleMcp(request: Request, env: Env): Promise<Response> {
}
}

const response = handleRpc(req);
const raw =
isModern && LEGACY_ONLY_METHODS.includes(req.method)
? err(req.id, -32601, `Method not found: ${req.method}`)
: handleRpc(req);
const response = isModern && raw !== null ? asModernResult(raw, req.method) : raw;
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 });
}
// 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
Expand Down
Loading