From f56c5bd8facdb1e38ed40254bf1737f7e93b0e92 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Thu, 23 Jul 2026 16:56:50 +0200 Subject: [PATCH] perf(webmcp): load the tool bundle only when the browser has modelContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /webmcp.js was 86,064 bytes, deferred but unconditional on every page. Its very first act was to feature-detect modelContext and return if absent — a correct early exit that runs far too late, since the bytes are already downloaded and parsed. Today essentially every visitor takes that path, so the whole payload was dead weight for real people on a site that has a Performance category. Two changes, which compose. 1. Hoist the guard out of the payload. BaseLayout now emits ~380 bytes inline that check modelContext and only then inject /webmcp.js. The guard's text is a constant, so its CSP sha256 is fixed for good; /webmcp.js's per-build SRI travels in a data- attribute, which CSP does not hash. That avoids the churn the other three inline hashes have, where editing the source means recomputing the hash. check-integrity.mjs now extracts the guard from the built HTML, hashes it, and fails the build with the value to paste if _headers disagrees — so the one thing that could silently break CSP is caught at build time rather than by a report from production. 2. Stop embedding the spec manifest. Of the 86 kB, ~76 kB was the 164-entry manifest. It now lives at /webmcp-manifest.json and is fetched on the first tool call that needs it, memoised, with the promise cleared on failure so a later call can retry. The enums stay inline because inputSchema has to be complete at registration time. get_topic already fetched its Markdown lazily, so this just extends a pattern the file had. /webmcp-manifest.json is deliberately NOT in _routes.json's exclude list: a request for it is the clearest signal that an in-browser agent actually invoked the tools, so it lands in the agent log. That gives us real usage data on WebMCP, which is what a future keep-or-drop call should rest on. Net effect per pageview: no modelContext (today, ~everyone) 86,064 → 0 bytes modelContext, no tool called 86,064 → 10,718 modelContext, tool called 86,064 → 10,718 + 19,527 gzipped Verified end-to-end against the built output via astro preview, not the dev server (dev serves the endpoint through Vite, so its bytes differ from the build-time hash and SRI legitimately fails there): - served HTML parsed with DOMParser: no script[src="/webmcp.js"], one guard - sha384 of the served /webmcp.js equals the pinned data-webmcp-sri exactly - with modelContext stubbed, the guard's own logic injects, SRI is accepted, and all five tools register - 0 manifest fetches before the first tool call, 1 after, still 1 after the second — memoisation holds; search_spec and list_topics return correct results from the fetched data The spec page gains the generalisable point, which it was missing: detect before you download, not just before you register, and keep the corpus out of the bundle. Its old "guard every call" advice was right but described exactly the too-late pattern this commit removes. Co-Authored-By: Claude Opus 4.8 (1M context) --- public/_headers | 17 +- scripts/check-integrity.mjs | 21 ++- src/content/spec/agent-readiness/webmcp.md | 10 +- src/layouts/BaseLayout.astro | 18 +- src/lib/webmcp.ts | 208 +++++++++++++-------- src/pages/webmcp-manifest.json.ts | 14 ++ 6 files changed, 199 insertions(+), 89 deletions(-) create mode 100644 src/pages/webmcp-manifest.json.ts diff --git a/public/_headers b/public/_headers index fcd33625..686bdb33 100644 --- a/public/_headers +++ b/public/_headers @@ -12,6 +12,13 @@ # sha256-8wqC… — the inline + { + /* 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-. */ + } + is:inline + data-webmcp-src="/webmcp.js" + data-webmcp-sri={webmcpSri} + set:html={WEBMCP_GUARD} + /> diff --git a/src/lib/webmcp.ts b/src/lib/webmcp.ts index 94589987..c015a6ee 100644 --- a/src/lib/webmcp.ts +++ b/src/lib/webmcp.ts @@ -1,13 +1,34 @@ -// Browser-side WebMCP registration script, generated at build time from spec -// content. Extracted here (rather than inline in src/pages/webmcp.js.ts) so the -// endpoint that serves it and BaseLayout that pins it with SRI share one source -// of truth: webmcpBody() is memoised, so the exact bytes that are served are the -// exact bytes that are hashed. Because the body embeds the spec manifest it -// changes whenever content changes — hence a build-time hash, not a constant. +// Browser-side WebMCP registration, in two pieces. +// +// 1. WEBMCP_GUARD — a tiny inline script BaseLayout emits on every page. It +// feature-detects `modelContext` and only then injects /webmcp.js. Browsers +// with no WebMCP implementation (i.e. nearly all of them today) fetch +// nothing at all. The guard's *text* is a constant so its CSP sha256 never +// 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. +// +// 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"; +/** Path of the lazily-fetched spec manifest. */ +export const WEBMCP_MANIFEST_PATH = "/webmcp-manifest.json"; + +// Kept on one line and byte-stable: BaseLayout renders it with set:html, so +// these exact bytes are what CSP hashes. Changing this string means recomputing +// the sha256 in public/_headers — `npm run build` fails loudly if you forget. +export const WEBMCP_GUARD = + '(function(){var d=document.currentScript.dataset;if(!document.modelContext&&!navigator.modelContext)return;var s=document.createElement("script");s.src=d.webmcpSrc;s.integrity=d.webmcpSri;s.crossOrigin="anonymous";document.head.appendChild(s)})();'; + let bodyPromise: Promise | null = null; let integrityPromise: Promise | null = null; @@ -23,10 +44,11 @@ export function webmcpIntegrity(): Promise { return integrityPromise; } -async function build(): Promise { +/** The spec manifest served at /webmcp-manifest.json. */ +export async function webmcpManifest() { const entries = await getCollection("spec", ({ data }) => !data.draft); - const manifest = entries + const pages = entries .map((e) => { const slug = e.data.slug ?? e.id.split("/").pop()!; return { @@ -47,24 +69,27 @@ async function build(): Promise { a.title.localeCompare(b.title), ); - const categoriesList = categories.map((c) => ({ - slug: c.slug, - title: c.title, - summary: c.summary, - order: c.order, - })); + return { + site: { name: site.name, url: site.url }, + categories: categories.map((c) => ({ + slug: c.slug, + title: c.title, + summary: c.summary, + order: c.order, + })), + pages, + }; +} +async function build(): Promise { const CATEGORY_ENUM = categories.map((c) => c.slug); const STATUS_ENUM = ["required", "recommended", "optional", "avoid"]; - const data = JSON.stringify({ - site: { name: site.name, url: site.url }, - categories: categoriesList, - pages: manifest, - }); - return `/* The Website Specification — WebMCP browser-side tools. * Exposes spec lookup + navigation as tools an in-browser AI agent can call. + * Loaded only when the browser exposes modelContext (see the guard in + * BaseLayout.astro). The spec manifest is fetched from + * ${WEBMCP_MANIFEST_PATH} on first use, not embedded here. * Generated at build time from src/content/spec/. Do not hand-edit. */ (function () { @@ -75,10 +100,33 @@ async function build(): Promise { else if (typeof navigator !== 'undefined' && navigator.modelContext) mc = navigator.modelContext; if (!mc) return; - var DATA = ${data}; + 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() @@ -87,13 +135,13 @@ async function build(): Promise { .filter(function (t) { return t.length >= 2; }); } - function rank(query, limit) { + 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 < DATA.pages.length; i++) { - var p = DATA.pages[i]; + 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(); @@ -113,8 +161,8 @@ async function build(): Promise { return scored.slice(0, limit); } - function filterPages(args) { - return DATA.pages.filter(function (p) { + 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; @@ -147,20 +195,22 @@ async function build(): Promise { var limit = (input && input.limit) || 5; if (limit < 1) limit = 1; if (limit > 25) limit = 25; - var hits = rank(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'); + 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); }, }, { @@ -177,21 +227,23 @@ async function build(): Promise { }, }, execute: function (input) { - var pages = filterPages(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'); + 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); }, }, { @@ -213,30 +265,32 @@ async function build(): Promise { execute: function (input) { var slug = input && input.slug; if (!slug) return 'ERROR: slug is required.'; - var page = null; - for (var i = 0; i < DATA.pages.length; i++) { - if (DATA.pages[i].slug === slug || DATA.pages[i].slug.toLowerCase() === String(slug).toLowerCase()) { - page = DATA.pages[i]; - break; + 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 < DATA.pages.length && close.length < 5; j++) { - var s = DATA.pages[j].slug; - if (s.indexOf(slug) >= 0 || String(slug).indexOf(s) >= 0) close.push(s); + 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; } - 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)); - }); + 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); }, }, { diff --git a/src/pages/webmcp-manifest.json.ts b/src/pages/webmcp-manifest.json.ts new file mode 100644 index 00000000..49ce8807 --- /dev/null +++ b/src/pages/webmcp-manifest.json.ts @@ -0,0 +1,14 @@ +import type { APIRoute } from "astro"; +import { webmcpManifest } from "~/lib/webmcp"; + +// The spec manifest the browser-side WebMCP tools search over. Split out of +// /webmcp.js so the script stays small: every visitor used to download the whole +// manifest inline, but only an agent that actually calls search_spec / +// list_topics / get_topic needs it. Fetched once, on first such call. +export const GET: APIRoute = async () => + new Response(JSON.stringify(await webmcpManifest()), { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "public, max-age=3600, stale-if-error=86400", + }, + });