Duvlify
GitHubGet started
The surfacesManifest and WebMCP
The surfaces

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:

  1. Streamable HTTP requires a client to accept both application/json and text/event-stream. The SDK enforces this with a 406 response. The bridge sent no Accept header, so every tool call was refused.
  2. 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.

Use these docs with your AI tools

An AI agent can read this documentation directly. You do not need an account or an API key. Everything here is public and read-only.

Query these docs via MCP

Recommended

Add this server to Claude, Claude Code, Cursor, Mistral, or any tool that supports MCP. Your agent can then search Duvlify documentation and read it in full, instead of answering from memory.

https://duvlify.dev/mcp
  • searchFind the passages that answer a question.
  • fetchRead one page in full, as Markdown.
  • list_pagesSee every page in this documentation.

Query these docs over HTTP

The same tools also work as plain web requests. Use this for scripts, or for any tool that does not support MCP. There is one endpoint per tool. Arguments go in the query string, and the answer comes back as JSON.

https://duvlify.dev/api/docs/search?query=custom+domain

Read the OpenAPI description. It is built from the same definitions as the tools, so it always matches what the endpoints do.

Read these docs as Markdown

Add .md to any page URL to get its Markdown source. You can also send the headerAccept: text/markdown to the page URL itself.

To read the whole documentation in one file, open llms-full.txt. For a short index of every page, open llms.txt.