perf(webmcp): load the tool bundle only when the browser has modelContext#122
Open
jdevalk wants to merge 1 commit into
Open
perf(webmcp): load the tool bundle only when the browser has modelContext#122jdevalk wants to merge 1 commit into
jdevalk wants to merge 1 commit into
Conversation
…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>
Deploying specification-website with
|
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
/webmcp.jswas 86,064 bytes, deferred but unconditional on every page. Its first act was to feature-detectmodelContextand 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
BaseLayoutnow emits ~380 bytes inline that checkmodelContextand 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 adata-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.mjsnow extracts the guard from the built HTML, hashes it, and fails the build with the value to paste if_headersdisagrees. 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 becauseinputSchemamust be complete at registration time.get_topicalready fetched its Markdown lazily — this just extends a pattern the file had./webmcp-manifest.jsonis 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
modelContext(today, ~everyone)modelContext, no tool calledmodelContext, tool calledVerification
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.DOMParser: noscript[src="/webmcp.js"], exactly one guard./webmcp.jsequals the pinneddata-webmcp-sriexactly.modelContextstubbed, the guard's own logic injects, SRI is accepted, and all five tools register.search_specandlist_topicsreturn 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.
updatedbumped.🤖 Generated with Claude Code