Skip to content

perf(webmcp): load the tool bundle only when the browser has modelContext#122

Open
jdevalk wants to merge 1 commit into
mainfrom
fix/webmcp-lazy-load
Open

perf(webmcp): load the tool bundle only when the browser has modelContext#122
jdevalk wants to merge 1 commit into
mainfrom
fix/webmcp-lazy-load

Conversation

@jdevalk

@jdevalk jdevalk commented Jul 23, 2026

Copy link
Copy Markdown
Owner

The problem

/webmcp.js was 86,064 bytes, deferred but unconditional on every page. Its 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.

The guard was inside the thing it was meant to guard.

Two changes

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 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 by hand.

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. 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, 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 must be complete at registration time. get_topic already fetched its Markdown lazily — 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 an in-browser agent actually invoked the tools, so it lands in the agent log. That gives real usage data on WebMCP, which is what a future keep-or-drop call should rest on.

Net effect per pageview

Case Before After
No modelContext (today, ~everyone) 86,064 B 0 B
modelContext, no tool called 86,064 B 10,718 B
modelContext, tool called 86,064 B 10,718 B + 19,527 B gzipped

Verification

Tested 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. Worth knowing before anyone debugs this locally.

  • Served HTML parsed with DOMParser: no script[src="/webmcp.js"], exactly 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.

npm run build ✓ · npm run check ✓ (0 errors) · npm run lint ✓ · npm run format:check ✓ · check-integrity

Spec page

webmcp.md gains the generalisable point it was missing — detect before you download, not just before you register, and keep the corpus out of the bundle. Its old "Forgetting feature detection… guard every call" mistake was right but described exactly the too-late pattern this PR removes, so it's been sharpened rather than kept. updated bumped.

🤖 Generated with Claude Code

…text

/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) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 23, 2026

Copy link
Copy Markdown

Deploying specification-website with  Cloudflare Pages  Cloudflare Pages

Latest commit: f56c5bd
Status: ✅  Deploy successful!
Preview URL: https://023eeff6.specification-website.pages.dev
Branch Preview URL: https://fix-webmcp-lazy-load.specification-website.pages.dev

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant