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/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..97a813a9 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "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", + "test:webmcp": "node --test scripts/test-webmcp.mjs" }, "dependencies": { "@astrojs/mdx": "^8.0.3", @@ -60,7 +62,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/public/og/spec/agent-readiness/webmcp.png b/public/og/spec/agent-readiness/webmcp.png index 27532bcb..a7724dd8 100644 Binary files a/public/og/spec/agent-readiness/webmcp.png and b/public/og/spec/agent-readiness/webmcp.png differ 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/scripts/test-webmcp.mjs b/scripts/test-webmcp.mjs new file mode 100644 index 00000000..37d5747c --- /dev/null +++ b/scripts/test-webmcp.mjs @@ -0,0 +1,148 @@ +// Run after building: exercise the actual Function.toString()-emitted bundle. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import vm from "node:vm"; + +const bundle = readFileSync( + new URL("../dist/webmcp.js", import.meta.url), + "utf8", +); +const manifest = JSON.parse( + readFileSync( + new URL("../dist/webmcp-manifest.json", import.meta.url), + "utf8", + ), +); +const response = () => ({ 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 new file mode 100644 index 00000000..7add9ba1 --- /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/) 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 d6477b3c..c2a2f8da 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 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 a9c8a29c..fe91712e 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,506 @@ 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 (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" }, + }) + .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; + }); + } + 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( + 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 +593,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 +633,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." + } + } + } + } + ] +}