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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@
# sha256-8wqC… — the inline <script type="importmap"> in BaseLayout.astro
# that pins pagefind.js (src/lib/integrity.ts). Recompute if the import
# map serialisation or the pagefind.js hash changes.
# sha256-QJ6d… — the inline WebMCP guard (WEBMCP_GUARD in src/lib/webmcp.ts).
# It feature-detects modelContext and only then injects /webmcp.js, so a
# browser with no WebMCP implementation fetches none of it. The guard text
# is invariant — /webmcp.js's per-build SRI travels in a data- attribute,
# which CSP does not hash — so this hash should never need recomputing.
# check-integrity.mjs verifies it against the built HTML and fails the
# build with the correct value if the guard is ever edited.
#
# Plausible is self-hosted at /js/plausible.js (a frozen copy of
# plausible.io/js/script.js, refreshed daily by
Expand All @@ -32,7 +39,7 @@
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' 'sha256-N74AzU+1FxvXAWIxrP2zNCBUxV949ZHOXXqjTvbusx0=' 'sha256-UU9xsfeOKmx3D7Lk33alkWn1rIjk46pD684u4pupy4o=' 'sha256-8wqCJSSabgg10EbyE8Gnnwc8zsNpWH4v1wi4+NcyfRI='; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://plausible.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; report-to csp-endpoint; upgrade-insecure-requests
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' 'sha256-N74AzU+1FxvXAWIxrP2zNCBUxV949ZHOXXqjTvbusx0=' 'sha256-UU9xsfeOKmx3D7Lk33alkWn1rIjk46pD684u4pupy4o=' 'sha256-8wqCJSSabgg10EbyE8Gnnwc8zsNpWH4v1wi4+NcyfRI=' 'sha256-QJ6dY3o48Nii8P8MjvF7lHKOwTps0NWPrEzA2RCm83s='; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://plausible.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; report-to csp-endpoint; upgrade-insecure-requests
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; trusted-types default dompurify; report-to csp-endpoint
Integrity-Policy-Report-Only: blocked-destinations=(script), endpoints=(integrity-endpoint)
Reporting-Endpoints: csp-endpoint="/reports", default="/reports", integrity-endpoint="/reports"
Expand Down Expand Up @@ -95,6 +102,14 @@
Cache-Control: public, max-age=3600, stale-if-error=86400
Access-Control-Allow-Origin: *

# WebMCP spec manifest — fetched lazily by /webmcp.js on the first tool call
# that needs it. Deliberately left in the Functions route (not _routes.json's
# exclude list): a request for it is the clearest signal that an in-browser
# agent actually used the tools, so it lands in the agent log.
/webmcp-manifest.json
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=3600, stale-if-error=86400

# Per-page JSON-LD graph served from /spec/<category>/<slug>.jsonld
/spec/*.jsonld
Content-Type: application/ld+json; charset=utf-8
Expand Down
21 changes: 20 additions & 1 deletion scripts/check-integrity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,29 @@ if (!headers.includes(`'${csp256}'`)) {
fail(`public/_headers CSP is missing the import map hash '${csp256}'.`);
}

// The inline WebMCP guard is hashed by CSP over its exact emitted bytes, so
// check the built HTML rather than the source constant. Its text is meant to be
// invariant across builds (the per-build SRI travels in a data- attribute, which
// CSP does not hash) — if this fires, the guard itself was edited.
const homepage = readFileSync(join(root, "dist/index.html"), "utf8");
const guard = (homepage.match(
/<script data-webmcp-src=[^>]*>([\s\S]*?)<\/script>/,
) || [])[1];
if (!guard) {
fail("dist/index.html has no inline WebMCP guard — BaseLayout changed?");
} else {
const guardHash = sri(Buffer.from(guard), "sha256");
if (!headers.includes(`'${guardHash}'`)) {
fail(
`public/_headers CSP is missing the WebMCP guard hash.\n Set it to: ${guardHash}`,
);
}
}

if (failed) {
console.error("\ncheck-integrity: FAILED");
process.exit(1);
}
console.log(
"✓ check-integrity: pagefind.js + component-ui pinned and CSP-allowed.",
"✓ check-integrity: pagefind.js + component-ui + WebMCP guard pinned and CSP-allowed.",
);
10 changes: 6 additions & 4 deletions src/content/spec/agent-readiness/webmcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ status: optional
order: 88
appliesTo: [all]
relatedSlugs: [mcp-and-tool-discovery, agent-skills-discovery, agent-readiness-overview, structured-data-for-agents]
updated: "2026-07-16T00:00:00.000Z"
updated: "2026-07-23T00:00:00.000Z"
sources:
- title: "WebMCP — W3C Web Machine Learning Community Group"
url: "https://webmachinelearning.github.io/webmcp/"
Expand Down Expand Up @@ -47,7 +47,7 @@ mc?.registerTool({
});
```

**This site ships it.** Every page on `specification.website` loads [`/webmcp.js`](/webmcp.js), which registers `search_spec`, `list_topics`, `get_topic`, `open_search`, and `open_checklist` tools — generated at build time from the same content collection that powers the rest of the site. An in-browser agent can search and read the spec without going through the remote [MCP server](/spec/agent-readiness/mcp-and-tool-discovery/).
**This site ships it.** [`/webmcp.js`](/webmcp.js) registers `search_spec`, `list_topics`, `get_topic`, `open_search`, and `open_checklist` — generated at build time from the same content collection that powers the rest of the site. An in-browser agent can search and read the spec without going through the remote [MCP server](/spec/agent-readiness/mcp-and-tool-discovery/). A visitor whose browser has no WebMCP implementation downloads none of it: a small inline guard checks for `modelContext` and only then injects the script.

## Why it matters

Expand All @@ -60,7 +60,8 @@ The API is early — implementations are shipping behind flags and via polyfill.

## How to implement

- **Register tools at page load**, after `navigator.modelContext` is feature-detected. If the API is absent, do nothing — never throw.
- **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.
Expand All @@ -70,13 +71,14 @@ The API is early — implementations are shipping behind flags and via polyfill.
## Common mistakes

- 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.
- Forgetting feature detection. `navigator.modelContext` does not exist in most browsers yet. Guard every call.
- 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.

## 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: [...] }`).
- If you ship a parallel HTTP MCP server, the tool names match across both surfaces.
18 changes: 12 additions & 6 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import "~/styles/global.css";
import { site } from "~/lib/site";
import { sri, pagefindImportMap } from "~/lib/integrity";
import { webmcpIntegrity } from "~/lib/webmcp";
import { WEBMCP_GUARD, webmcpIntegrity } from "~/lib/webmcp";
import HeadMeta from "~/components/HeadMeta.astro";
import SiteHeader from "~/components/SiteHeader.astro";
import SiteFooter from "~/components/SiteFooter.astro";
Expand Down Expand Up @@ -205,11 +205,17 @@ const webmcpSri = await webmcpIntegrity();
defer
integrity={sri("/theme-toggle.js")}
crossorigin="anonymous"></script>
{
/* WebMCP: feature-detect before fetching. The payload is only useful to a
browser that exposes modelContext, so the guard injects it rather than
every visitor downloading it to run the same check and bail. Guard text
is constant (one stable CSP hash); the per-build SRI rides in data-. */
}
<script
src="/webmcp.js"
type="text/javascript"
defer
integrity={webmcpSri}
crossorigin="anonymous"></script>
is:inline
data-webmcp-src="/webmcp.js"
data-webmcp-sri={webmcpSri}
set:html={WEBMCP_GUARD}
/>
</body>
</html>
Loading
Loading