From f28a914653d095269cea6a62319699ec6cd901a9 Mon Sep 17 00:00:00 2001 From: Riff Date: Fri, 16 Oct 2026 08:06:38 +0200 Subject: [PATCH 1/2] WebMCP audit fixes: spec page corrections, typed tool defs, budgets, evals Spec page (agent-readiness/webmcp.md): - consequentialHint, not destructiveHint (not a WebMCP annotation) - drop mode: 'summarize' (not in the spec) - verification: execute() resolves strings, not MCP { content: [...] } - document untrustedContentHint + debugging, opt-in-only rule - summary: document.modelContext is the current surface - changelog entry added Implementation (src/lib/webmcp.ts): - tool metadata is now typed data checked against webmcp-types at build time (new devDependency); helpers/execute emitted via Function.toString() - list_topics: paginated (page/page_size, default 10) instead of dumping all 177 topics (~53k chars) - get_topic: offset/limit line paging with a continuation hint instead of dumping up to ~12k chars - registerTool() awaited per tool; rejections warn instead of unhandled - execute() forwards the AbortSignal to fetch() - titles added to all five tools - structured error payloads { error, code, retryable?, suggestion? } - removed annotations: { readOnlyHint: false } from the UI tools (annotations are strictly opt-in; omission means false) Also: - webmcp.schema.json (generated from the built bundle via scripts/emit-webmcp-schema.mjs, new npm run webmcp:schema) and webmcp.evals.json (11 cases in the Chrome Labs evals format) for npx webmcp-evals local -t webmcp.schema.json -e webmcp.evals.json - /mcp page documents the browser WebMCP tools - eslint override for the verbatim-emitted helpers (repo precedent: BaseLayout byte-frozen script) --- eslint.config.js | 11 + package-lock.json | 177 ++-- package.json | 6 +- scripts/emit-webmcp-schema.mjs | 51 ++ .../2026-10-16-webmcp-annotations.md | 8 + src/content/spec/agent-readiness/webmcp.md | 26 +- src/lib/webmcp.ts | 788 ++++++++++++------ src/pages/mcp.astro | 60 ++ webmcp.evals.json | 217 +++++ webmcp.schema.json | 149 ++++ 10 files changed, 1112 insertions(+), 381 deletions(-) create mode 100644 scripts/emit-webmcp-schema.mjs create mode 100644 src/content/changelog/2026-10-16-webmcp-annotations.md create mode 100644 webmcp.evals.json create mode 100644 webmcp.schema.json diff --git a/eslint.config.js b/eslint.config.js index 38e26b97..2125ea75 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -85,4 +85,15 @@ export default [ "@typescript-eslint/no-unused-vars": ["error", { caughtErrors: "none" }], }, }, + + // src/lib/webmcp.ts emits its helper/execute functions verbatim into + // /webmcp.js via Function.toString(). Their `var` style and loose `any` + // types are the shipped browser code, not build-time style — don't lint + // them as if they were. + { + files: ["src/lib/webmcp.ts"], + rules: { + "no-var": "off", + }, + }, ]; diff --git a/package-lock.json b/package-lock.json index 12b5a4ac..a38e7109 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,8 @@ "prettier-plugin-astro": "^1.1.1", "sharp": "^0.35.5", "typescript": "^6.0.3", - "typescript-eslint": "^8.71.1" + "typescript-eslint": "^8.71.1", + "webmcp-types": "^0.1.10" }, "engines": { "node": ">=22.12.0" @@ -519,9 +520,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -535,9 +533,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -551,9 +546,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -567,9 +559,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -791,6 +780,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -807,6 +797,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -823,6 +814,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -839,6 +831,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -855,6 +848,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -871,6 +865,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -887,6 +882,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -903,6 +899,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -919,6 +916,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -935,6 +933,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -951,6 +950,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -967,6 +967,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -983,6 +984,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -999,6 +1001,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1015,6 +1018,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1031,6 +1035,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1047,6 +1052,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1063,6 +1069,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1079,6 +1086,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1095,6 +1103,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1111,6 +1120,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1127,6 +1137,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1143,6 +1154,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1159,6 +1171,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1175,6 +1188,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1191,6 +1205,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1500,6 +1515,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1522,6 +1538,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1541,6 +1558,7 @@ "version": "0.35.5", "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1563,6 +1581,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1579,6 +1598,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1595,9 +1615,7 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1614,9 +1632,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1633,9 +1649,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1652,9 +1666,7 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1671,9 +1683,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1690,9 +1700,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1709,9 +1717,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1728,9 +1734,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1747,9 +1751,7 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1772,9 +1774,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1797,9 +1797,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1822,9 +1820,7 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1847,9 +1843,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1872,9 +1866,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1897,9 +1889,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1922,9 +1912,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1944,6 +1932,7 @@ "version": "0.35.5", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -1960,6 +1949,7 @@ "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1973,6 +1963,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1992,6 +1983,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2011,6 +2003,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2030,6 +2023,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2380,9 +2374,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2399,9 +2390,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2418,9 +2406,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2437,9 +2422,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2456,9 +2438,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2475,9 +2454,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3785,9 +3761,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3804,9 +3777,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3823,9 +3793,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3842,9 +3809,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6776,9 +6740,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6796,9 +6757,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6816,9 +6774,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6836,9 +6791,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8172,9 +8124,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8195,9 +8144,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8218,9 +8164,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8241,9 +8184,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8615,6 +8555,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/webmcp-types": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/webmcp-types/-/webmcp-types-0.1.10.tgz", + "integrity": "sha512-U4GQ1RpJ0tqCUt2ALMV9Pnhi+hsy8L31f/Q1v4mLVhW64r5sYhh+2+SjP/Lq4/9jP33vZgNkuRZARJl65A/ZIg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 031fdc90..4d6884af 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "check:skill": "node scripts/check-skill.mjs --check", "adoption": "node scripts/adoption/fetch-adoption.mjs", "test:adoption": "node --test scripts/adoption/*.test.mjs", - "test:diagnostics": "node scripts/test-diagnostics.mjs" + "test:diagnostics": "node scripts/test-diagnostics.mjs", + "webmcp:schema": "node scripts/emit-webmcp-schema.mjs" }, "dependencies": { "@astrojs/mdx": "^8.0.3", @@ -60,7 +61,8 @@ "prettier-plugin-astro": "^1.1.1", "sharp": "^0.35.5", "typescript": "^6.0.3", - "typescript-eslint": "^8.71.1" + "typescript-eslint": "^8.71.1", + "webmcp-types": "^0.1.10" }, "overrides": { "esbuild": "$esbuild" diff --git a/scripts/emit-webmcp-schema.mjs b/scripts/emit-webmcp-schema.mjs new file mode 100644 index 00000000..9704cf7d --- /dev/null +++ b/scripts/emit-webmcp-schema.mjs @@ -0,0 +1,51 @@ +// Emit webmcp.schema.json from the built /webmcp.js bundle. +// +// Loads dist/webmcp.js in a stubbed browser environment, captures the tools +// passed to registerTool(), and writes their descriptors (minus `execute`) +// as JSON. Run after `npm run build:astro` (or `npm run build`): +// +// node scripts/emit-webmcp-schema.mjs +// +// The schema feeds `npx webmcp-evals local -t webmcp.schema.json -e webmcp.evals.json`. +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const bundle = readFileSync(`${root}/dist/webmcp.js`, "utf8"); + +const tools = []; +const sandbox = { + console, + document: { + modelContext: { + registerTool: (t) => { + tools.push(t); + return Promise.resolve(); + }, + }, + querySelector: () => null, + }, + navigator: {}, + window: { location: { assign: () => {} } }, + fetch: () => Promise.reject(new Error("no network in schema emit")), +}; +sandbox.globalThis = sandbox; +vm.createContext(sandbox); +vm.runInContext(bundle, sandbox, { filename: "webmcp.js" }); +await new Promise((r) => setImmediate(r)); + +const schema = { + tools: tools.map((t) => { + const descriptor = { ...t }; + delete descriptor.execute; + return descriptor; + }), +}; +writeFileSync( + `${root}/webmcp.schema.json`, + JSON.stringify(schema, null, 2) + "\n", +); +console.log( + `✓ webmcp.schema.json — ${schema.tools.length} tools (${schema.tools.map((t) => t.name).join(", ")})`, +); diff --git a/src/content/changelog/2026-10-16-webmcp-annotations.md b/src/content/changelog/2026-10-16-webmcp-annotations.md new file mode 100644 index 00000000..daee5383 --- /dev/null +++ b/src/content/changelog/2026-10-16-webmcp-annotations.md @@ -0,0 +1,8 @@ +--- +title: "WebMCP page: corrected annotations for the current spec" +date: "2026-10-16" +type: changed +relatedSlugs: [webmcp] +--- + +The [WebMCP page](/spec/agent-readiness/webmcp/) named `destructiveHint`, which does not exist in the WebMCP annotation set — the annotation is `consequentialHint`. It also recommended `mode: 'summarize'`, which is not part of the spec, and its verification step described an MCP-shaped `{ content: [...] }` result, while WebMCP tools resolve plain strings. The annotations guidance now covers the full current set (`readOnlyHint`, `consequentialHint`, `untrustedContentHint`, plus `debugging` on newer implementations), the opt-in-only rule, and structured error payloads. The page's sources now cite the [`webmcp-types`](https://github.com/webmachinelearning/webmcp-types) definitions. diff --git a/src/content/spec/agent-readiness/webmcp.md b/src/content/spec/agent-readiness/webmcp.md index d6477b3c..24ccf634 100644 --- a/src/content/spec/agent-readiness/webmcp.md +++ b/src/content/spec/agent-readiness/webmcp.md @@ -2,12 +2,12 @@ title: "WebMCP — browser-native tools for agents" slug: webmcp category: agent-readiness -summary: "WebMCP lets a page register tools that an in-browser AI agent can call directly, using a `navigator.modelContext` JavaScript API. It turns a site into an agent surface without server-side MCP plumbing." +summary: "WebMCP lets a page register tools that an in-browser AI agent can call directly, using the `document.modelContext` JavaScript API. It turns a site into an agent surface without server-side MCP plumbing." status: optional order: 88 appliesTo: [all] relatedSlugs: [mcp-and-tool-discovery, agent-skills-discovery, agent-readiness-overview, structured-data-for-agents] -updated: "2026-07-23T00:00:00.000Z" +updated: "2026-10-16T00:00:00.000Z" sources: - title: "WebMCP — W3C Web Machine Learning Community Group" url: "https://webmachinelearning.github.io/webmcp/" @@ -15,9 +15,9 @@ sources: - title: "webmachinelearning/webmcp on GitHub" url: "https://github.com/webmachinelearning/webmcp" publisher: "W3C WebML CG" - - title: "Model Context Protocol 2026-07-28 — Tools" - url: "https://modelcontextprotocol.io/specification/2026-07-28/server/tools" - publisher: "MCP" + - title: "webmcp-types — TypeScript type definitions for WebMCP" + url: "https://github.com/webmachinelearning/webmcp-types" + publisher: "W3C WebML CG" --- ## What it is @@ -33,6 +33,7 @@ const mc = mc?.registerTool({ name: 'search_docs', + title: 'Search the docs', description: 'Search the documentation for a query.', inputSchema: { type: 'object', @@ -40,9 +41,9 @@ mc?.registerTool({ required: ['query'], }, annotations: { readOnlyHint: true }, - async execute({ query }) { - const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`); - return { content: [{ type: 'text', text: await res.text() }] }; + async execute({ query }, { signal }) { + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }); + return res.text(); }, }); ``` @@ -63,9 +64,8 @@ The API is early — implementations are shipping behind flags and via polyfill. - **Feature-detect before you download, not just before you register.** The obvious shape — ship the tool bundle on every page, check `modelContext` at the top, return early when it is missing — puts the check inside the thing it is meant to guard. Every visitor pays for a script that today almost none of them can use. Put the detection in a few inline bytes and inject the bundle only when the API is present. If it is absent, do nothing — never throw. - **Keep the payload small; fetch the corpus on demand.** Tool definitions are tiny, the data they search usually is not. Register the tools with complete input schemas up front — an agent needs those to decide what to call — then fetch the data on the first call that actually needs it. The agent is already awaiting a tool result, so the extra round trip costs nothing anyone perceives. - **Mirror your server-side MCP tools.** If you already publish `search_docs` on an HTTP MCP server, register a tool with the same name, description, and input schema in the browser. An agent that knows the server-side tool will recognise the browser one immediately. -- **Use the `annotations` field** to declare safety properties: `readOnlyHint: true` for tools that do not mutate state, `destructiveHint: true` for ones that do. The agent uses these to decide whether to confirm with the user. -- **Pick `mode: 'summarize'`** when the tool returns a single result rather than streaming. Streaming is supported but adds complexity that most page tools do not need. -- **Keep `execute()` small.** A tool is a thin call into existing site functionality (search, navigation, account actions), not a bespoke pipeline. +- **Use the `annotations` field** to declare safety properties. The WebMCP surface defines `readOnlyHint` (the tool only reads data), `consequentialHint` (executing it commits a significant, real-world, or non-reversible action — bookings, payments, deletions), and `untrustedContentHint` (the output includes user- or third-party-authored text, which the agent must treat as untrusted). Newer implementations also expose `debugging` for developer-tooling-only tools. All hints default to `false`: declare a hint only when it is `true`, never declare `: false`. The agent uses these to decide whether to confirm with the user and how to sandbox the output. +- **Keep `execute()` small and its errors structured.** A tool is a thin call into existing site functionality (search, navigation, account actions), not a bespoke pipeline. On failure, resolve a structured payload (`{ error, code, retryable }`) rather than rejecting: an unhandled rejection surfaces to the agent as a generic `UnknownError` with the reason discarded. Never report a failure as a success string. - **Document the tools.** Add them to your [agent skills index](/spec/agent-readiness/agent-skills-discovery/) so agents that read the site's discovery surfaces — not just ones already in the browser — know they exist. ## Common mistakes @@ -73,12 +73,12 @@ The API is early — implementations are shipping behind flags and via polyfill. - Registering tools that bypass the site's own access controls. A WebMCP tool runs as the logged-in user; treat it like any other JavaScript-callable action and apply the same authorisation checks server-side. - Feature-detecting inside the bundle rather than before it. The early return is correct but it runs too late — the bytes are already on the wire and parsed. Guard the load, then guard the call. - Designing tools that only make sense in the browser. If a server-side MCP server can do the same job, ship both — agents should be able to use whichever transport they have. -- Treating annotations as cosmetic. `readOnlyHint` and `destructiveHint` change agent behaviour; declare them honestly. +- Treating annotations as cosmetic. `readOnlyHint` and `consequentialHint` change agent behaviour; declare them honestly, and never declare a hint as `: false` — omission already means `false`. ## Verification - `typeof navigator.modelContext?.registerTool === 'function'` in a console on a supporting browser. - On a browser *without* the API, the network panel shows no request for the tool bundle at all — not a request whose script returns early. - The browser's agent UI lists your registered tools by name and description. -- Calling a registered tool from a test agent returns a well-formed MCP-shaped result (`{ content: [...] }`). +- Calling a registered tool from a test agent returns the string the tool's `execute()` resolved with — not an MCP-shaped `{ content: [...] }` payload, which belongs to server-side MCP, not WebMCP. - If you ship a parallel HTTP MCP server, the tool names match across both surfaces. diff --git a/src/lib/webmcp.ts b/src/lib/webmcp.ts index a9c8a29c..af814cf8 100644 --- a/src/lib/webmcp.ts +++ b/src/lib/webmcp.ts @@ -7,18 +7,27 @@ // moves; the per-build SRI of /webmcp.js rides along in a data- attribute, // which CSP does not hash. check-integrity.mjs asserts the two agree. // -// 2. webmcpBody() — the tool definitions. It no longer embeds the spec -// manifest: that is served separately at /webmcp-manifest.json and fetched -// on first use by the tools that need it. Embedding it made this file ~86 kB, -// which every visitor paid for on every page whether or not an agent existed -// to call the tools. Only the two enums stay inline, because inputSchema has -// to be complete at registration time. +// 2. webmcpBody() — the tool definitions. Tool metadata (name, title, +// description, inputSchema, annotations) is declared as typed data in TOOLS +// and checked at build time against the WebMCP spec via the `webmcp-types` +// package, so a spec change (like destructiveHint -> consequentialHint) +// fails the build instead of shipping silently. The helpers and execute +// implementations are plain functions emitted verbatim with +// Function.toString(): write them exactly as the shipped JS should read +// (single-level escaping) and never close over build-time values — +// everything they need is emitted into the IIFE scope (MANIFEST_URL). +// +// The spec manifest is served separately at /webmcp-manifest.json and fetched +// on first use by the tools that need it; the category/status enums stay +// inline in the input schemas, because inputSchema has to be complete at +// registration time. // // webmcpBody() is memoised so the bytes BaseLayout hashes are the bytes the // endpoint serves. import { getCollection } from "astro:content"; import { categories, site } from "~/lib/site"; import { sriOf } from "~/lib/integrity"; +import type { WebMCP } from "webmcp-types"; /** Path of the lazily-fetched spec manifest. */ export const WEBMCP_MANIFEST_PATH = "/webmcp-manifest.json"; @@ -31,6 +40,486 @@ export const WEBMCP_MANIFEST_PATH = "/webmcp-manifest.json"; export const WEBMCP_GUARD = '(function(){var d=document.currentScript.dataset;if(!document.modelContext&&!navigator.modelContext)return;function load(){var s=document.createElement("script");s.src=d.webmcpSrc;s.integrity=d.webmcpSri;s.crossOrigin="anonymous";document.head.appendChild(s)}if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",load,{once:true});else load()})();'; +// A browser tool: spec-typed metadata plus the implementation, which is +// emitted verbatim into /webmcp.js. `execute` must be a plain `function` +// expression — never an arrow function or method — so Function.toString() +// yields portable source. The metadata is typechecked against the spec: +// a renamed or removed annotation is a build error, not a silent ship. +type BrowserTool = Omit & { + inputSchema: Record; + execute: (input: any, options?: { signal?: AbortSignal }) => unknown; +}; + +// Emitted into the IIFE scope; the helpers below reference it by name. +// (Declared for the typechecker; the IIFE defines the real var.) +declare const MANIFEST_URL: string; + +const CATEGORY_SLUGS: string[] = categories.map((c) => c.slug); +const STATUS_SLUGS: string[] = ["required", "recommended", "optional", "avoid"]; + +// --------------------------------------------------------------------------- +// Emitted helpers. These run in the browser, not at build time. They are +// typechecked here but only their source text ships, so keep them free of +// build-time references and write escaping exactly as the final JS needs it. +// --------------------------------------------------------------------------- + +let manifestPromise: any = null; + +// Fetched once, on the first tool call that needs it. Tools that only drive +// the UI (open_search, open_checklist) never trigger it. +function wmcFetchManifest(manifestUrl: string, signal: any): Promise { + if (!manifestPromise) { + manifestPromise = fetch(manifestUrl, { + headers: { Accept: "application/json" }, + signal: signal, + }) + .then(function (res: any) { + if (!res.ok) throw new Error("HTTP " + res.status); + return res.json(); + }) + .catch(function (err: any) { + manifestPromise = null; // let a later call retry + throw err; + }); + } + return manifestPromise; +} + +function wmcToolError( + code: string, + message: string, + extra?: Record, +): Record { + var out: Record = { error: message, code: code }; + if (extra) { + for (var k in extra) { + out[k] = extra[k]; + } + } + return out; +} + +function wmcManifestError( + manifestUrl: string, + err: any, +): Record { + return wmcToolError( + "MANIFEST_ERROR", + "ERROR loading the spec manifest from " + + manifestUrl + + ": " + + (err && err.message ? err.message : String(err)), + { retryable: true }, + ); +} + +function wmcTokenise(s: any): string[] { + return String(s || "") + .toLowerCase() + .replace(/[^a-z0-9\s/_.-]/g, " ") + .split(/\s+/) + .filter(function (t: string): boolean { + return t.length >= 2; + }); +} + +function wmcRank(pages: any[], query: any, limit: number): any[] { + var tokens = wmcTokenise(query); + if (!tokens.length) return []; + var phrase = String(query || "").toLowerCase(); + var scored: any[] = []; + for (var i = 0; i < pages.length; i++) { + var p = pages[i]; + var title = p.title.toLowerCase(); + var slug = p.slug.toLowerCase(); + var summary = p.summary.toLowerCase(); + var score = 0; + for (var j = 0; j < tokens.length; j++) { + var t = tokens[j]; + if (title.indexOf(t) >= 0) score += 8; + if (slug.indexOf(t) >= 0) score += 6; + if (summary.indexOf(t) >= 0) score += 4; + } + if (title.indexOf(phrase) >= 0) score += 12; + if (score > 0) scored.push({ page: p, score: score }); + } + scored.sort(function (a: any, b: any): number { + return b.score - a.score || a.page.order - b.page.order; + }); + return scored.slice(0, limit); +} + +function wmcFilterPages(pages: any[], args: any): any[] { + return pages.filter(function (p: any): boolean { + if (args && args.category && p.category !== args.category) return false; + if (args && args.status && p.status !== args.status) return false; + return true; + }); +} + +function wmcOpenSearchOverlay(): boolean { + var trigger = document.querySelector("[data-search-trigger]"); + if (trigger) { + (trigger as HTMLElement).click(); + return true; + } + var opener = (window as any).__swOpenSearch; + if (typeof opener === "function") { + opener(); + return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Tool definitions. +// --------------------------------------------------------------------------- + +const TOOLS: BrowserTool[] = [ + { + name: "search_spec", + title: "Search the spec", + description: + 'Search The Website Specification for matching topics. Returns ranked results with title, status, category, canonical URL, and a one-line summary. Use this when the user asks about a topic by keyword (e.g. "CSP", "alt text", "llms.txt").', + annotations: { readOnlyHint: true }, + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: "Free-text query.", + minLength: 1, + }, + limit: { + type: "integer", + description: "Maximum results to return.", + minimum: 1, + maximum: 25, + default: 5, + }, + }, + required: ["query"], + }, + execute: function (input: any, options: any): unknown { + var q = input && input.query; + var limit = (input && input.limit) || 5; + if (limit < 1) limit = 1; + if (limit > 25) limit = 25; + var signal = options && options.signal; + return wmcFetchManifest(MANIFEST_URL, signal).then( + function (d: any) { + var hits = wmcRank(d.pages, q, limit); + if (!hits.length) { + return wmcToolError( + "NOT_FOUND", + 'No spec pages matched "' + q + '".', + { + suggestion: + "Try broader keywords, or call list_topics to browse by category.", + }, + ); + } + var lines = []; + lines.push( + "Found " + + hits.length + + " result" + + (hits.length === 1 ? "" : "s") + + ' for "' + + q + + '":', + ); + lines.push(""); + for (var i = 0; i < hits.length; i++) { + var p = hits[i].page; + lines.push( + i + 1 + ". " + p.title + " — " + p.status + " · " + p.category, + ); + lines.push(" " + p.summary); + lines.push(" " + p.url); + } + return lines.join("\n"); + }, + function (err: any) { + return wmcManifestError(MANIFEST_URL, err); + }, + ); + }, + }, + { + name: "list_topics", + title: "List spec topics", + description: + 'List spec topics, optionally filtered by category and/or status. Returns title, status, category, summary, and URL per topic. Use this when the user wants the canonical list (e.g. "all required SEO topics"). Results are paged — follow page/page_size to walk the whole list.', + annotations: { readOnlyHint: true }, + inputSchema: { + type: "object", + properties: { + category: { + type: "string", + enum: CATEGORY_SLUGS, + description: "Restrict to one category.", + }, + status: { + type: "string", + enum: STATUS_SLUGS, + description: "Restrict to one status.", + }, + page: { + type: "integer", + description: "Page of results to return.", + minimum: 1, + default: 1, + }, + page_size: { + type: "integer", + description: "Topics per page.", + minimum: 1, + maximum: 50, + default: 10, + }, + }, + }, + execute: function (input: any, options: any): unknown { + var args = input || {}; + var page = args.page || 1; + var pageSize = args.page_size || 10; + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 1; + if (pageSize > 50) pageSize = 50; + var signal = options && options.signal; + return wmcFetchManifest(MANIFEST_URL, signal).then( + function (d: any) { + var pages = wmcFilterPages(d.pages, args); + if (!pages.length) { + return wmcToolError("NOT_FOUND", "No topics matched the filters.", { + suggestion: + "Loosen the category/status filters, or call list_topics without filters.", + }); + } + var totalPages = Math.ceil(pages.length / pageSize); + if (page > totalPages) { + return wmcToolError( + "INVALID_INPUT", + "Page " + page + " is out of range.", + { suggestion: "The last page is " + totalPages + "." }, + ); + } + var items = pages.slice((page - 1) * pageSize, page * pageSize); + var lines = []; + lines.push( + "Page " + + page + + " of " + + totalPages + + " — " + + items.length + + " of " + + pages.length + + " matching topics:", + ); + lines.push(""); + for (var i = 0; i < items.length; i++) { + var p = items[i]; + lines.push( + "- " + + p.title + + " (" + + p.status + + ", " + + p.category + + ") — " + + p.summary, + ); + lines.push(" " + p.url); + } + return lines.join("\n"); + }, + function (err: any) { + return wmcManifestError(MANIFEST_URL, err); + }, + ); + }, + }, + { + name: "get_topic", + title: "Read a spec topic", + description: + "Fetch the Markdown for one spec page by its slug, returned as lines you can page through. Use this after search_spec or list_topics to read the canonical guidance.", + annotations: { readOnlyHint: true }, + inputSchema: { + type: "object", + properties: { + slug: { + type: "string", + description: + "Kebab-case slug, as listed by list_topics or search_spec. Examples: 'content-security-policy', 'meta-robots', 'llms-txt'.", + minLength: 1, + }, + offset: { + type: "integer", + description: "First line to return (0-based).", + minimum: 0, + default: 0, + }, + limit: { + type: "integer", + description: "Maximum lines to return.", + minimum: 1, + maximum: 500, + default: 120, + }, + }, + required: ["slug"], + }, + execute: function (input: any, options: any): unknown { + var slug = input && input.slug; + if (!slug) { + return wmcToolError( + "INVALID_INPUT", + "The slug parameter is required.", + { + suggestion: "Use search_spec or list_topics to find a valid slug.", + }, + ); + } + var offset = (input && input.offset) || 0; + var limit = (input && input.limit) || 120; + if (offset < 0) offset = 0; + if (limit < 1) limit = 1; + if (limit > 500) limit = 500; + var signal = options && options.signal; + return wmcFetchManifest(MANIFEST_URL, signal).then( + function (d: any) { + var page: any = null; + for (var i = 0; i < d.pages.length; i++) { + if ( + d.pages[i].slug === slug || + d.pages[i].slug.toLowerCase() === String(slug).toLowerCase() + ) { + page = d.pages[i]; + break; + } + } + if (!page) { + var close = []; + for (var j = 0; j < d.pages.length && close.length < 5; j++) { + var s = d.pages[j].slug; + if (s.indexOf(slug) >= 0 || String(slug).indexOf(s) >= 0) + close.push(s); + } + return wmcToolError( + "NOT_FOUND", + 'No spec page with slug "' + slug + '".', + { + suggestion: close.length + ? "Closer matches: " + close.join(", ") + "." + : "Use search_spec to find the right slug.", + }, + ); + } + return fetch(page.mdUrl, { + headers: { Accept: "text/markdown" }, + signal: signal, + }) + .then(function (res: any) { + if (!res.ok) throw new Error("HTTP " + res.status); + return res.text(); + }) + .then( + function (text: string) { + var lines = text.split("\n"); + var slice = lines.slice(offset, offset + limit); + var out = slice.join("\n"); + if (offset + slice.length < lines.length) { + out += + "\n\n…truncated after line " + + (offset + slice.length) + + " of " + + lines.length + + "; call get_topic with offset=" + + (offset + slice.length) + + " to continue."; + } + return out; + }, + function (err: any) { + return wmcToolError( + "FETCH_ERROR", + "ERROR fetching " + + page.mdUrl + + ": " + + (err && err.message ? err.message : String(err)), + { retryable: true }, + ); + }, + ); + }, + function (err: any) { + return wmcManifestError(MANIFEST_URL, err); + }, + ); + }, + }, + { + name: "open_search", + title: "Open site search", + description: + "Open the on-page ⌘K search overlay so the user can search the spec interactively. Optionally pre-fills a query. UI action — does not return results; use search_spec for that.", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: "Optional initial query to pre-fill.", + }, + }, + }, + execute: function (input: any): unknown { + var q = input && input.query; + var opener = (window as any).__swOpenSearch; + // Prefer the modal driver directly when a query is supplied — it + // handles lazy-loading the bundle and prefilling the input itself. + if (q && typeof opener === "function") { + opener(q); + return 'Search overlay opened with query "' + q + '".'; + } + var ok = wmcOpenSearchOverlay(); + if (!ok) { + return wmcToolError( + "UNAVAILABLE", + "Search overlay is not available on this page.", + { suggestion: "Use search_spec to search programmatically." }, + ); + } + return "Search overlay opened."; + }, + }, + { + name: "open_checklist", + title: "Open the checklist", + description: + "Navigate the user to the full spec checklist page. Optionally jumps to a category section via URL hash. UI action — does not return data.", + inputSchema: { + type: "object", + properties: { + category: { + type: "string", + enum: CATEGORY_SLUGS, + description: "Optional category to scroll to.", + }, + }, + }, + execute: function (input: any): unknown { + var url = "/checklist/"; + if (input && input.category) + url += "#" + encodeURIComponent(input.category); + window.location.assign(url); + return "Navigating to " + url + "."; + }, + }, +]; + let bodyPromise: Promise | null = null; let integrityPromise: Promise | null = null; @@ -84,8 +573,29 @@ export async function webmcpManifest() { } async function build(): Promise { - const CATEGORY_ENUM = categories.map((c) => c.slug); - const STATUS_ENUM = ["required", "recommended", "optional", "avoid"]; + const helpers = [ + wmcFetchManifest, + wmcManifestError, + wmcToolError, + wmcTokenise, + wmcRank, + wmcFilterPages, + wmcOpenSearchOverlay, + ] + .map((fn) => fn.toString()) + .join("\n\n"); + + const toolsSrc = TOOLS.map((t) => { + const fields = [ + `name: ${JSON.stringify(t.name)}`, + t.title ? `title: ${JSON.stringify(t.title)}` : "", + `description: ${JSON.stringify(t.description)}`, + t.annotations ? `annotations: ${JSON.stringify(t.annotations)}` : "", + `inputSchema: ${JSON.stringify(t.inputSchema)}`, + `execute: ${t.execute.toString()}`, + ].filter(Boolean); + return `{\n ${fields.join(",\n ")}\n }`; + }).join(",\n "); return `/* The Website Specification — WebMCP browser-side tools. * Exposes spec lookup + navigation as tools an in-browser AI agent can call. @@ -103,254 +613,30 @@ async function build(): Promise { if (!mc) return; var MANIFEST_URL = ${JSON.stringify(WEBMCP_MANIFEST_PATH)}; - var CATEGORY_ENUM = ${JSON.stringify(CATEGORY_ENUM)}; - var STATUS_ENUM = ${JSON.stringify(STATUS_ENUM)}; - - // Fetched once, on the first tool call that needs it. Tools that only drive - // the UI (open_search, open_checklist) never trigger it. - var dataPromise = null; - function data() { - if (!dataPromise) { - dataPromise = fetch(MANIFEST_URL, { headers: { Accept: 'application/json' } }) - .then(function (res) { - if (!res.ok) throw new Error('HTTP ' + res.status); - return res.json(); - }) - .catch(function (err) { - dataPromise = null; // let a later call retry - throw err; - }); - } - return dataPromise; - } - - function failed(err) { - return 'ERROR loading the spec manifest from ' + MANIFEST_URL + ': ' + - (err && err.message ? err.message : String(err)); - } - - function tokenise(s) { - return String(s || '') - .toLowerCase() - .replace(/[^a-z0-9\\s\\/_.-]/g, ' ') - .split(/\\s+/) - .filter(function (t) { return t.length >= 2; }); - } - - function rank(pages, query, limit) { - var tokens = tokenise(query); - if (!tokens.length) return []; - var phrase = String(query || '').toLowerCase(); - var scored = []; - for (var i = 0; i < pages.length; i++) { - var p = pages[i]; - var title = p.title.toLowerCase(); - var slug = p.slug.toLowerCase(); - var summary = p.summary.toLowerCase(); - var score = 0; - for (var j = 0; j < tokens.length; j++) { - var t = tokens[j]; - if (title.indexOf(t) >= 0) score += 8; - if (slug.indexOf(t) >= 0) score += 6; - if (summary.indexOf(t) >= 0) score += 4; - } - if (title.indexOf(phrase) >= 0) score += 12; - if (score > 0) scored.push({ page: p, score: score }); - } - scored.sort(function (a, b) { - return b.score - a.score || a.page.order - b.page.order; - }); - return scored.slice(0, limit); - } - function filterPages(pages, args) { - return pages.filter(function (p) { - if (args && args.category && p.category !== args.category) return false; - if (args && args.status && p.status !== args.status) return false; - return true; - }); - } - - function openSearchOverlay() { - var trigger = document.querySelector('[data-search-trigger]'); - if (trigger) { trigger.click(); return true; } - if (typeof window.__swOpenSearch === 'function') { window.__swOpenSearch(); return true; } - return false; - } + var manifestPromise = null; +${helpers} var tools = [ - { - name: 'search_spec', - description: - 'Search The Website Specification for matching topics. Returns ranked results with title, status, category, canonical URL, and a one-line summary. Use this when the user asks about a topic by keyword (e.g. "CSP", "alt text", "llms.txt").', - annotations: { readOnlyHint: true }, - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Free-text query.', minLength: 1 }, - limit: { type: 'integer', minimum: 1, maximum: 25, default: 5 }, - }, - required: ['query'], - }, - execute: function (input) { - var q = input && input.query; - var limit = (input && input.limit) || 5; - if (limit < 1) limit = 1; - if (limit > 25) limit = 25; - return data().then(function (d) { - var hits = rank(d.pages, q, limit); - if (!hits.length) { - return 'No spec pages matched "' + q + '".'; - } - var lines = []; - lines.push('Found ' + hits.length + ' result' + (hits.length === 1 ? '' : 's') + ' for "' + q + '":'); - lines.push(''); - for (var i = 0; i < hits.length; i++) { - var p = hits[i].page; - lines.push((i + 1) + '. ' + p.title + ' — ' + p.status + ' · ' + p.category); - lines.push(' ' + p.summary); - lines.push(' ' + p.url); - } - return lines.join('\\n'); - }, failed); - }, - }, - { - name: 'list_topics', - description: - 'List spec topics, optionally filtered by category and/or status. Returns title, status, category, summary, and URL for each. Use this when the user wants the canonical list (e.g. "all required SEO topics").', - annotations: { readOnlyHint: true }, - inputSchema: { - type: 'object', - properties: { - category: { type: 'string', enum: CATEGORY_ENUM, description: 'Restrict to one category.' }, - status: { type: 'string', enum: STATUS_ENUM, description: 'Restrict to one status.' }, - limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Cap the number of items returned.' }, - }, - }, - execute: function (input) { - return data().then(function (d) { - var pages = filterPages(d.pages, input || {}); - var limit = input && input.limit ? input.limit : pages.length; - if (limit < 1) limit = 1; - if (limit > 200) limit = 200; - var items = pages.slice(0, limit); - if (!items.length) return 'No topics matched the filters.'; - var lines = []; - lines.push(items.length + ' of ' + pages.length + ' matching topics:'); - lines.push(''); - for (var i = 0; i < items.length; i++) { - var p = items[i]; - lines.push('- ' + p.title + ' (' + p.status + ', ' + p.category + ') — ' + p.summary); - lines.push(' ' + p.url); - } - return lines.join('\\n'); - }, failed); - }, - }, - { - name: 'get_topic', - description: - "Fetch the full Markdown for one spec page by its slug. Returns the rendered body with YAML frontmatter (title, status, sources). Use this after search_spec or list_topics to read the canonical guidance.", - annotations: { readOnlyHint: true }, - inputSchema: { - type: 'object', - properties: { - slug: { - type: 'string', - description: "Kebab-case slug, as listed by list_topics or search_spec. Examples: 'content-security-policy', 'meta-robots', 'llms-txt'.", - minLength: 1, - }, - }, - required: ['slug'], - }, - execute: function (input) { - var slug = input && input.slug; - if (!slug) return 'ERROR: slug is required.'; - return data().then(function (d) { - var page = null; - for (var i = 0; i < d.pages.length; i++) { - if (d.pages[i].slug === slug || d.pages[i].slug.toLowerCase() === String(slug).toLowerCase()) { - page = d.pages[i]; - break; - } - } - if (!page) { - var close = []; - for (var j = 0; j < d.pages.length && close.length < 5; j++) { - var s = d.pages[j].slug; - if (s.indexOf(slug) >= 0 || String(slug).indexOf(s) >= 0) close.push(s); - } - var hint = close.length ? ' Closer matches: ' + close.join(', ') + '.' : ''; - return 'No spec page with slug "' + slug + '".' + hint; - } - return fetch(page.mdUrl, { headers: { Accept: 'text/markdown' } }) - .then(function (res) { - if (!res.ok) throw new Error('HTTP ' + res.status); - return res.text(); - }) - .catch(function (err) { - return 'ERROR fetching ' + page.mdUrl + ': ' + (err && err.message ? err.message : String(err)); - }); - }, failed); - }, - }, - { - name: 'open_search', - description: - 'Open the on-page ⌘K search overlay so the user can search the spec interactively. Optionally pre-fills a query. UI action — does not return results; use search_spec for that.', - annotations: { readOnlyHint: false }, - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Optional initial query to pre-fill.' }, - }, - }, - execute: function (input) { - var q = input && input.query; - // Prefer the modal driver directly when a query is supplied — it - // handles lazy-loading the bundle and prefilling the input itself. - if (q && typeof window.__swOpenSearch === 'function') { - window.__swOpenSearch(q); - return 'Search overlay opened with query "' + q + '".'; - } - var ok = openSearchOverlay(); - if (!ok) return 'ERROR: search overlay is not available on this page.'; - return 'Search overlay opened.'; - }, - }, - { - name: 'open_checklist', - description: - 'Navigate the user to the full spec checklist page. Optionally jumps to a category section via URL hash. UI action — does not return data.', - annotations: { readOnlyHint: false }, - inputSchema: { - type: 'object', - properties: { - category: { type: 'string', enum: CATEGORY_ENUM, description: 'Optional category to scroll to.' }, - }, - }, - execute: function (input) { - var url = '/checklist/'; - if (input && input.category) url += '#' + encodeURIComponent(input.category); - window.location.assign(url); - return 'Navigating to ' + url + '.'; - }, - }, + ${toolsSrc} ]; - // Register. Prefer registerTool() (per the W3C draft + Chrome Labs demos). - // Fall back to provideContext() if the implementation only exposes that. - try { - if (typeof mc.registerTool === 'function') { - for (var i = 0; i < tools.length; i++) mc.registerTool(tools[i]); - } else if (typeof mc.provideContext === 'function') { - mc.provideContext({ tools: tools }); - } - } catch (e) { - if (typeof console !== 'undefined' && console.warn) { - console.warn('[webmcp] tool registration failed:', e); - } + // Register. registerTool() is async per the spec, so register sequentially + // and catch per tool: a rejection (e.g. a schema the browser won't accept) + // warns instead of surfacing as an unhandled rejection. Fall back to + // provideContext() if the implementation only exposes that. + if (typeof mc.registerTool === 'function') { + (async function registerAll() { + for (var i = 0; i < tools.length; i++) { + try { + await mc.registerTool(tools[i]); + } catch (e) { + if (typeof console !== 'undefined' && console.warn) console.warn('[webmcp] registration failed for ' + tools[i].name + ':', e); + } + } + })(); + } else if (typeof mc.provideContext === 'function') { + mc.provideContext({ tools: tools }); } })(); `; diff --git a/src/pages/mcp.astro b/src/pages/mcp.astro index 6b8b1646..3ff11ada 100644 --- a/src/pages/mcp.astro +++ b/src/pages/mcp.astro @@ -118,6 +118,66 @@ const claudeJson = JSON.stringify(desktopConfig, null, 2); +

Browser tools (WebMCP)

+

+ The same lookup surface is also exposed in the browser via{" "} + WebMCP:{" "} + /webmcp.js registers tools on{" "} + document.modelContext for in-browser agents, generated at + build time from the same content collection. Tool names and intent + mirror the HTTP server above. A browser without a WebMCP implementation + downloads none of it — a small inline guard injects the script only when + the API is present. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolWhat it does
+ search_spec(query, limit?) + + Ranked keyword search over the spec — title, status, category, + URL, summary. +
+ + list_topics({"{ category?, status?, page?, page_size? }"}) + + Paged topic index with the same filters as the HTTP tool.
+ get_topic({"{ slug, offset?, limit? }"}) + + One spec page as Markdown, returned in line pages with a + continuation hint. +
+ open_search({"{ query? }"}) + Opens the on-page ⌘K search overlay for the user.
+ open_checklist({"{ category? }"}) + Navigates the user to the checklist page.
+

Prompts

diff --git a/webmcp.evals.json b/webmcp.evals.json new file mode 100644 index 00000000..dbf9c13a --- /dev/null +++ b/webmcp.evals.json @@ -0,0 +1,217 @@ +[ + { + "expectedCall": [ + { "arguments": { "query": "CSP" }, "functionName": "search_spec" } + ], + "messages": [ + { + "content": "What does the spec say about CSP?", + "role": "user", + "type": "message" + } + ], + "name": "Direct keyword search" + }, + { + "expectedCall": [ + { + "arguments": { "query": { "$pattern": "sitemap" } }, + "functionName": "search_spec" + } + ], + "messages": [ + { + "content": "Find me the sitemap guidance.", + "role": "user", + "type": "message" + } + ], + "name": "Search by meaning with pattern" + }, + { + "expectedCall": [ + { + "arguments": { "category": "seo", "status": "required" }, + "functionName": "list_topics" + } + ], + "messages": [ + { + "content": "Show me all required SEO topics.", + "role": "user", + "type": "message" + } + ], + "name": "Filtered topic list" + }, + { + "expectedCall": [ + { + "arguments": { "category": "accessibility", "page": 2, "page_size": 5 }, + "functionName": "list_topics" + } + ], + "messages": [ + { + "content": "Show me the second page of accessibility topics, five per page.", + "role": "user", + "type": "message" + } + ], + "name": "Paginated topic list" + }, + { + "expectedCall": [ + { "arguments": { "slug": "llms-txt" }, "functionName": "get_topic" } + ], + "messages": [ + { + "content": "Read the llms.txt guidance.", + "role": "user", + "type": "message" + } + ], + "name": "Read a topic by slug" + }, + { + "expectedCall": [ + { + "arguments": { "slug": "https" }, + "functionName": "get_topic", + "mockOutput": "…truncated after line 120 of 210; call get_topic with offset=120 to continue." + }, + { + "arguments": { "offset": 120, "slug": "https" }, + "functionName": "get_topic" + } + ], + "messages": [ + { + "content": "Read the full HTTPS page, including the parts past the truncation.", + "role": "user", + "type": "message" + } + ], + "name": "Read a long topic across pages" + }, + { + "expectedCall": [ + { + "ordered": [ + { + "arguments": { "query": { "$pattern": "meta robots" } }, + "functionName": "search_spec", + "mockOutput": "1. Meta robots — required · seo\n https://specification.website/spec/seo/meta-robots/" + }, + { + "arguments": { "slug": "meta-robots" }, + "functionName": "get_topic" + } + ] + } + ], + "messages": [ + { + "content": "Find the page about meta robots and read it.", + "role": "user", + "type": "message" + } + ], + "name": "Search then read journey" + }, + { + "expectedCall": [ + { + "arguments": { "query": { "$pattern": "quantum|seo" } }, + "functionName": "search_spec" + } + ], + "messages": [ + { + "content": "Read the page about quantum SEO.", + "role": "user", + "type": "message" + }, + { + "arguments": { "slug": "quantum-seo" }, + "name": "get_topic", + "role": "model", + "type": "functioncall" + }, + { + "name": "get_topic", + "response": { + "result": { + "code": "NOT_FOUND", + "error": "No spec page with slug \"quantum-seo\".", + "suggestion": "Use search_spec to find the right slug." + } + }, + "role": "user", + "type": "functionresponse" + } + ], + "name": "Unknown slug recovery" + }, + { + "expectedCall": [{ "arguments": {}, "functionName": "list_topics" }], + "messages": [ + { + "content": "Is there anything in the spec about AMP?", + "role": "user", + "type": "message" + }, + { + "arguments": { "query": "AMP" }, + "name": "search_spec", + "role": "model", + "type": "functioncall" + }, + { + "name": "search_spec", + "response": { + "result": { + "code": "NOT_FOUND", + "error": "No spec pages matched \"AMP\".", + "suggestion": "Try broader keywords, or call list_topics to browse by category." + } + }, + "role": "user", + "type": "functionresponse" + } + ], + "name": "Empty search recovery" + }, + { + "expectedCall": [ + { + "arguments": { "category": "privacy" }, + "functionName": "open_checklist" + } + ], + "messages": [ + { + "content": "Take me to the privacy checklist.", + "role": "user", + "type": "message" + } + ], + "name": "Open the checklist for a category" + }, + { + "expectedCall": [ + { + "arguments": { "query": { "$pattern": "canonical" } }, + "functionName": "open_search" + } + ], + "messages": [ + { + "content": "Search the site for canonical URLs.", + "role": "user", + "type": "message" + } + ], + "name": "Open site search with a query" + } +] diff --git a/webmcp.schema.json b/webmcp.schema.json new file mode 100644 index 00000000..e51f3ebc --- /dev/null +++ b/webmcp.schema.json @@ -0,0 +1,149 @@ +{ + "tools": [ + { + "name": "search_spec", + "title": "Search the spec", + "description": "Search The Website Specification for matching topics. Returns ranked results with title, status, category, canonical URL, and a one-line summary. Use this when the user asks about a topic by keyword (e.g. \"CSP\", \"alt text\", \"llms.txt\").", + "annotations": { + "readOnlyHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Free-text query.", + "minLength": 1 + }, + "limit": { + "type": "integer", + "description": "Maximum results to return.", + "minimum": 1, + "maximum": 25, + "default": 5 + } + }, + "required": ["query"] + } + }, + { + "name": "list_topics", + "title": "List spec topics", + "description": "List spec topics, optionally filtered by category and/or status. Returns title, status, category, summary, and URL per topic. Use this when the user wants the canonical list (e.g. \"all required SEO topics\"). Results are paged — follow page/page_size to walk the whole list.", + "annotations": { + "readOnlyHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": [ + "foundations", + "seo", + "accessibility", + "security", + "well-known", + "agent-readiness", + "performance", + "privacy", + "resilience", + "i18n" + ], + "description": "Restrict to one category." + }, + "status": { + "type": "string", + "enum": ["required", "recommended", "optional", "avoid"], + "description": "Restrict to one status." + }, + "page": { + "type": "integer", + "description": "Page of results to return.", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "description": "Topics per page.", + "minimum": 1, + "maximum": 50, + "default": 10 + } + } + } + }, + { + "name": "get_topic", + "title": "Read a spec topic", + "description": "Fetch the Markdown for one spec page by its slug, returned as lines you can page through. Use this after search_spec or list_topics to read the canonical guidance.", + "annotations": { + "readOnlyHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "Kebab-case slug, as listed by list_topics or search_spec. Examples: 'content-security-policy', 'meta-robots', 'llms-txt'.", + "minLength": 1 + }, + "offset": { + "type": "integer", + "description": "First line to return (0-based).", + "minimum": 0, + "default": 0 + }, + "limit": { + "type": "integer", + "description": "Maximum lines to return.", + "minimum": 1, + "maximum": 500, + "default": 120 + } + }, + "required": ["slug"] + } + }, + { + "name": "open_search", + "title": "Open site search", + "description": "Open the on-page ⌘K search overlay so the user can search the spec interactively. Optionally pre-fills a query. UI action — does not return results; use search_spec for that.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Optional initial query to pre-fill." + } + } + } + }, + { + "name": "open_checklist", + "title": "Open the checklist", + "description": "Navigate the user to the full spec checklist page. Optionally jumps to a category section via URL hash. UI action — does not return data.", + "inputSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": [ + "foundations", + "seo", + "accessibility", + "security", + "well-known", + "agent-readiness", + "performance", + "privacy", + "resilience", + "i18n" + ], + "description": "Optional category to scroll to." + } + } + } + } + ] +} From 02fd3914819f3eb70d922c5124abf87e137d3f72 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 16 Oct 2026 08:55:21 +0200 Subject: [PATCH 2/2] Fix WebMCP cancellation isolation and result guidance --- .github/workflows/ci.yml | 3 + package.json | 3 +- public/og/spec/agent-readiness/webmcp.png | Bin 24989 -> 25518 bytes scripts/test-webmcp.mjs | 148 ++++++++++++++++++ .../2026-10-16-webmcp-annotations.md | 2 +- src/content/spec/agent-readiness/webmcp.md | 2 +- src/lib/webmcp.ts | 24 ++- 7 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 scripts/test-webmcp.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0523532f..a6ca532a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,9 @@ jobs: env: NODE_ENV: production + - name: Test generated WebMCP tools + run: npm run test:webmcp + - name: Upload build artefact uses: actions/upload-artifact@v7 with: diff --git a/package.json b/package.json index 4d6884af..97a813a9 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "adoption": "node scripts/adoption/fetch-adoption.mjs", "test:adoption": "node --test scripts/adoption/*.test.mjs", "test:diagnostics": "node scripts/test-diagnostics.mjs", - "webmcp:schema": "node scripts/emit-webmcp-schema.mjs" + "webmcp:schema": "node scripts/emit-webmcp-schema.mjs", + "test:webmcp": "node --test scripts/test-webmcp.mjs" }, "dependencies": { "@astrojs/mdx": "^8.0.3", diff --git a/public/og/spec/agent-readiness/webmcp.png b/public/og/spec/agent-readiness/webmcp.png index 27532bcb9e02d76d6f94cc0faee82f407a484670..a7724dd894cbd88691efab64eac2e13005eecc45 100644 GIT binary patch literal 25518 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 literal 24989 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 ({ ok: true, json: async () => manifest }); + +async function loadTools(fetch) { + const tools = {}; + vm.runInNewContext(bundle, { + console, + document: { + modelContext: { + registerTool(tool) { + tools[tool.name] = tool; + return Promise.resolve(); + }, + }, + }, + fetch, + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(Object.keys(tools).length, 5); + return tools; +} + +for (const cancelledCaller of ["first", "second"]) { + test(`cancelling the ${cancelledCaller} caller leaves the shared fetch usable`, async () => { + let finish; + let requests = 0; + const tools = await loadTools((_url, options) => { + requests++; + return new Promise((resolve, reject) => { + finish = () => resolve(response()); + options.signal?.addEventListener("abort", () => + reject(options.signal.reason), + ); + }); + }); + const first = new AbortController(); + const second = new AbortController(); + const search = tools.search_spec.execute( + { query: "CSP" }, + { signal: first.signal }, + ); + const list = tools.list_topics.execute({}, { signal: second.signal }); + const cancelled = cancelledCaller === "first" ? search : list; + const surviving = cancelledCaller === "first" ? list : search; + let result; + cancelled.then((value) => { + result = value; + }); + (cancelledCaller === "first" ? first : second).abort( + new Error("cancelled"), + ); + // Cancellation settles without waiting for the shared network request. + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(result?.code, "MANIFEST_ERROR"); + finish(); + assert.equal(typeof (await surviving), "string"); + assert.equal(typeof (await tools.list_topics.execute({})), "string"); + assert.equal(requests, 1); + }); +} + +test("an already-cancelled call does not fetch or read the cached manifest", async () => { + let requests = 0; + const tools = await loadTools(async () => { + requests++; + return response(); + }); + const controller = new AbortController(); + controller.abort(new Error("cancelled")); + const options = { signal: controller.signal }; + assert.equal( + (await tools.list_topics.execute({}, options)).code, + "MANIFEST_ERROR", + ); + assert.equal(requests, 0); + await tools.list_topics.execute({}); + assert.equal( + (await tools.list_topics.execute({}, options)).code, + "MANIFEST_ERROR", + ); + assert.equal(requests, 1); +}); + +test("a cancelled topic read does not start the Markdown fetch", async () => { + let finish; + let requests = 0; + const tools = await loadTools(() => { + requests++; + if (requests > 1) + return Promise.reject(new Error("unexpected Markdown fetch")); + return new Promise((resolve) => { + finish = () => resolve(response()); + }); + }); + const controller = new AbortController(); + const topic = tools.get_topic.execute( + { slug: manifest.pages[0].slug }, + { signal: controller.signal }, + ); + controller.abort(new Error("cancelled")); + finish(); + assert.equal((await topic).code, "MANIFEST_ERROR"); + assert.equal(requests, 1); +}); + +test("network failures remain retryable and listeners are removed", async () => { + let requests = 0; + const tools = await loadTools(async () => { + if (++requests === 1) throw new Error("offline"); + return response(); + }); + const listeners = new Set(); + const signal = { + aborted: false, + addEventListener(_event, listener) { + listeners.add(listener); + }, + removeEventListener(_event, listener) { + listeners.delete(listener); + }, + }; + assert.equal( + (await tools.list_topics.execute({}, { signal })).retryable, + true, + ); + assert.equal(listeners.size, 0); + assert.equal( + typeof (await tools.list_topics.execute({}, { signal })), + "string", + ); + assert.equal(listeners.size, 0); + assert.equal(requests, 2); +}); diff --git a/src/content/changelog/2026-10-16-webmcp-annotations.md b/src/content/changelog/2026-10-16-webmcp-annotations.md index daee5383..7add9ba1 100644 --- a/src/content/changelog/2026-10-16-webmcp-annotations.md +++ b/src/content/changelog/2026-10-16-webmcp-annotations.md @@ -5,4 +5,4 @@ type: changed relatedSlugs: [webmcp] --- -The [WebMCP page](/spec/agent-readiness/webmcp/) named `destructiveHint`, which does not exist in the WebMCP annotation set — the annotation is `consequentialHint`. It also recommended `mode: 'summarize'`, which is not part of the spec, and its verification step described an MCP-shaped `{ content: [...] }` result, while WebMCP tools resolve plain strings. The annotations guidance now covers the full current set (`readOnlyHint`, `consequentialHint`, `untrustedContentHint`, plus `debugging` on newer implementations), the opt-in-only rule, and structured error payloads. The page's sources now cite the [`webmcp-types`](https://github.com/webmachinelearning/webmcp-types) definitions. +The [WebMCP page](/spec/agent-readiness/webmcp/) now names `consequentialHint` instead of the unsupported `destructiveHint` and removes the unsupported `mode: 'summarize'` option. Its verification guidance explains that tools can resolve any JSON-serialisable value and do not require an MCP-shaped `{ content: [...] }` envelope. The annotations guidance covers the full current set (`readOnlyHint`, `consequentialHint`, `untrustedContentHint`, plus `debugging` on newer implementations), the opt-in-only rule, and structured error payloads, with sources now citing the [`webmcp-types`](https://github.com/webmachinelearning/webmcp-types) definitions. diff --git a/src/content/spec/agent-readiness/webmcp.md b/src/content/spec/agent-readiness/webmcp.md index 24ccf634..c2a2f8da 100644 --- a/src/content/spec/agent-readiness/webmcp.md +++ b/src/content/spec/agent-readiness/webmcp.md @@ -80,5 +80,5 @@ The API is early — implementations are shipping behind flags and via polyfill. - `typeof navigator.modelContext?.registerTool === 'function'` in a console on a supporting browser. - On a browser *without* the API, the network panel shows no request for the tool bundle at all — not a request whose script returns early. - The browser's agent UI lists your registered tools by name and description. -- Calling a registered tool from a test agent returns the string the tool's `execute()` resolved with — not an MCP-shaped `{ content: [...] }` payload, which belongs to server-side MCP, not WebMCP. +- Calling a registered tool from a test agent returns a JSON-serialisable result. The tool's `execute()` can resolve a string, object, array, or another JSON-serialisable value; `document.modelContext.executeTool()` returns that value serialised as JSON. An MCP-shaped `{ content: [...] }` envelope is not required. - If you ship a parallel HTTP MCP server, the tool names match across both surfaces. diff --git a/src/lib/webmcp.ts b/src/lib/webmcp.ts index af814cf8..fe91712e 100644 --- a/src/lib/webmcp.ts +++ b/src/lib/webmcp.ts @@ -68,10 +68,11 @@ let manifestPromise: any = null; // Fetched once, on the first tool call that needs it. Tools that only drive // the UI (open_search, open_checklist) never trigger it. function wmcFetchManifest(manifestUrl: string, signal: any): Promise { + if (signal && signal.aborted) return Promise.reject(signal.reason); if (!manifestPromise) { + // This request serves every caller; cancelling one must not abort it. manifestPromise = fetch(manifestUrl, { headers: { Accept: "application/json" }, - signal: signal, }) .then(function (res: any) { if (!res.ok) throw new Error("HTTP " + res.status); @@ -82,7 +83,26 @@ function wmcFetchManifest(manifestUrl: string, signal: any): Promise { throw err; }); } - return manifestPromise; + if (!signal) return manifestPromise; + // Cancel only this caller's wait, and detach its listener when settled. + return new Promise(function (resolve, reject) { + function aborted() { + signal.removeEventListener("abort", aborted); + reject(signal.reason); + } + signal.addEventListener("abort", aborted, { once: true }); + manifestPromise.then( + function (manifest: any) { + signal.removeEventListener("abort", aborted); + if (signal.aborted) reject(signal.reason); + else resolve(manifest); + }, + function (err: any) { + signal.removeEventListener("abort", aborted); + reject(err); + }, + ); + }); } function wmcToolError(