The manifest, WebMCP and limits
The page outline agents read for line offsets, the in-browser WebMCP bridge and when to run it yourself, the rate limits, and how the surfaces are tested.
The manifest
src/lib/agent-manifest.ts emits
dist/agent-manifest.json at build time. For each page it records the
page’s identity and length. For each heading it records the full path (for
example, "Custom domains › DNS records"), the anchor, and the start and
end lines.
One rule makes the whole thing work: line numbers are counted against the
exact bytes the site serves. The same pageMarkdown() output is what
<page>.md returns, what fetch returns, and what the indexer uploads. If
these ever diverge, every line range silently starts pointing at the wrong
paragraph.
This is also how a passage gets a heading. AI Search returns chunk text but not its position. So the Worker finds the text back inside the page and maps the resulting line to the heading whose range contains it. It does this at chunk granularity, which no per-page metadata field could ever express.
WebMCP
Cloudflare’s hosted bridge is the recommended route when the domain is a Cloudflare zone. In the dashboard, go to Agent Readiness → Labs → Enable WebMCP, then under Tool packs check Site MCP server only.
The interface says “Leave every pack unchecked to use the default set”. The default set is actually all packs, including a C2PA image-provenance scanner. That scanner is pure noise on a documentation site, and it costs context in every agent that loads the page. Check the one pack explicitly instead.
That pack proxies this site’s own /mcp. Cloudflare does not supply a
generic documentation MCP. It finds this site’s server at <origin>/mcp,
which is why the MCP endpoint lives on the site’s Worker rather than on one
of Cloudflare’s own.
Where there is no zone, such as a workers.dev deployment or a fork without
a Cloudflare domain, that dashboard screen does not exist. In that case, set
agents.webmcpBridge to 'on', and this repository registers the tools
itself from
src/components/WebMcpBridge.astro.
Never run both methods together, or the tools would be registered twice.
The browser API was renamed from navigator.modelContext to
document.modelContext between Chrome 149 and 150. The bridge reads both
names.
The bridge is two files, and that split is deliberate
The relay logic, which loads the descriptors, sends a call by POST, and reads
the reply, lives in
src/lib/webmcp.ts. Only the part that genuinely needs a
browser stays in the component: reading modelContext and calling
registerTool.
The reason for this split is that the component’s contents could not be
verified by a test. The bridge runs only inside a browser that ships the
WebMCP API, and no test here has one. So it broke the day /mcp moved to
the official MCP SDK, and nothing noticed. Two defects caused this, both
from assuming the transport was ordinary JSON-RPC over POST:
- Streamable HTTP requires a client to accept both
application/jsonandtext/event-stream. The SDK enforces this with a 406 response. The bridge sent noAcceptheader, so every tool call was refused. - Once that header is sent, the SDK replies in SSE.
response.json()throws an error on an SSE reply. Fixing only the first defect would have moved the failure, not removed it.
Both facts were already written down at the top of test/agents.test.mjs.
The test suite knew about them; the bridge did not, because the suite built
its own request instead of using the shipped one. This is the lesson worth
keeping: a test that reimplements the thing it is testing stays green
through the bug.
The handful of lines left in the component are still uncovered by tests.
There is no modelContext object in Node, and stubbing one would only prove
that the stub was called.
Rate limiting
There are three bindings, one per tool family, because the tools do not cost
the same to run. wrangler.jsonc configures them, and agents.rateLimit
tunes them.
Know what this protection actually does before you trust it. The Workers rate limiting binding accepts a window of 10 or 60 seconds and nothing else, so it cannot express an hourly cap. Its counters are also per Cloudflare datacenter, not global, so a distributed client sees a multiple of the configured number. Cloudflare documents this behavior as permissive and eventually consistent by design.
This binding only dampens abuse. It is the protection that works before a domain exists, which is exactly when nobody is watching.
The real ceiling belongs in a WAF rate limiting rule on the zone. A WAF
rule counts over long windows, counts globally, and blocks a request before
the Worker starts, so a flood costs nothing. Configure one on /api/docs/*
and /mcp once the domain is on Cloudflare, plus a stricter one on
report_issue. This rule belongs to operating the domain, not to this
repository, which is why it is documented here rather than committed as
code.
Testing
test/agents.test.mjs boots a real Worker and talks
to it over HTTP, rather than importing the handlers directly. The parts most
likely to break are not the pure functions. They are the ASSETS binding, the
manifest fetch, and the routing that has to keep /mcp away from the
Markdown content negotiation. A unit test of the grouping logic would pass
even while the endpoint returned a 404.
Among other things, the suite asserts that MCP and HTTP return byte-identical responses for the same call, that the generated OpenAPI description matches the live tool set exactly, that no score leaks into a response, and that every reported line range really contains the passage it claims.
The WebMCP suite imports src/lib/webmcp.ts and points it at the running
Worker. This means it exercises the code the browser actually runs, rather
than a copy of it; see the bridge section above for why that distinction is
the whole point. It checks that the descriptors are shaped the way
registerTool demands, that a relayed call comes back as parseable text,
that every advertised tool is reachable, that a failure arrives as a
readable message instead of a thrown exception, and that the suite reads
the reply correctly whether it arrives as JSON or as SSE.
To confirm that a test actually protects something, break the thing on
purpose and watch the test fail. Removing the Accept header from
callTool turns two of these tests red. Repeat that check whenever the
transport changes.