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", + }, + });