Hosting
Deploy docs and an MCP server to Cloudflare Workers
One Cloudflare Worker that serves your docs as static assets, answers Markdown requests at the same URLs, and hosts an MCP endpoint coding agents connect to.
By Hayden Bleasel9 min read

Yes. One Cloudflare Worker can serve both: your docs pages as static assets, and a live MCP endpoint that coding agents connect to. Name Blume's cloudflare() adapter and turn on the MCP server, and blume build produces a Worker with your prerendered site beside it. wrangler deploy ships both in one step.
By the end of this guide, the Acme docs run on Workers with the MCP server at /mcp, every page answers as HTML or Markdown at the same URL, and you've tested static and Worker requests separately, locally and live. Blume's own site, useblume.dev, runs on this setup, so you can try the checks against it before yours is up.
If you don't need MCP or the assistant, skip the adapter: a static build serves every page without running a Worker, and agents can still read the .md URLs and llms.txt (see Static or server-rendered). Blume's MCP server is also read-only. Tools that change data or check who's calling need a server of your own.
How one Worker serves the site
A Worker with static assets has two layers. The static layer serves files from dist/client without running any code. The Worker script runs for the paths its assets.run_worker_first rules claim. Blume writes those rules at build time, so each request lands here:
| Request | Answered by |
|---|---|
/_astro/* build assets, .md and .txt files, /.well-known/* | The static layer, with the headers from Blume's _headers file |
A page URL, like / or /quickstart | Blume's wrapper Worker: the HTML page, or the page's Markdown when the request prefers text/markdown |
| A URL no page backs | The Worker: the 404 page, or /404.md for a Markdown request, with a 404 status either way |
POST /mcp | The MCP endpoint in the Astro Worker |
A redirect from blume.config.ts | The wrapper Worker, with the status you configured |
Because page requests run the Worker, each page view counts as a Worker request on your Cloudflare plan. On the free plan, requests over the limit get a 429 instead of falling back to the static file. Cloudflare's static assets billing page has the details, and Blume's pricing page estimates a server build on Cloudflare.
Install the adapter and Wrangler
Start from a Blume project. If you don't have one, run npx blume init acme-docs and pick the docs template. Blume runs on Astro 7, whose Cloudflare adapter is @astrojs/cloudflare 14. The adapter lists Wrangler 4 as a peer, and you deploy with Wrangler anyway, so install both as dev dependencies:
npm install -D @astrojs/cloudflare@^14.3.3 wrangler@^4.142.0Blume ships the Vercel and Node adapters, but not this one. Without it, blume build stops with BLUME_DEPENDENCY_MISSING and prints the install command for your package manager.
Approve build scripts on pnpm
Wrangler brings two packages with install scripts: esbuild, and workerd, the Workers runtime Wrangler runs locally. npm and Bun run both without extra setup. pnpm runs a dependency's build script only once you approve it, and fails the install with ERR_PNPM_IGNORED_BUILDS until you do. Approve both, then run pnpm install again:
allowBuilds:
esbuild: true
workerd: trueA new pnpm project from blume init already approves esbuild, so you only add workerd. allowBuilds needs pnpm 10.26 or later.
Configure Blume
import { defineConfig } from "blume";
import { cloudflare } from "blume/deploy";
export default defineConfig({
title: "Acme Docs",
agents: {
mcp: { enabled: true },
},
deployment: cloudflare({ site: "https://docs.acme.example" }),
});Naming the adapter switches the build to server output, which the MCP server needs. The endpoint mounts at /mcp; set agents.mcp.route to move it.
Set site yourself. Blume detects the site URL on Cloudflare Pages, but Workers doesn't expose one. Canonical links, the sitemap, and the page URLs the MCP tools return all use it, and it turns on the Connect to MCP menu on every page. Use the domain you'll serve from, even before it's attached.
Add a Wrangler config
The adapter can generate a Worker config on its own, but a wrangler.jsonc at the project root, beside blume.config.ts, is where you name the Worker and set how its assets behave. The build merges it into the config it deploys.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "acme-docs",
"compatibility_date": "2026-09-01",
// Node.js APIs for the Astro runtime and the MCP server.
"compatibility_flags": ["nodejs_compat"],
"assets": {
"binding": "ASSETS",
"html_handling": "drop-trailing-slash",
"not_found_handling": "404-page",
// Blume adds its own rules to this list at build time.
"run_worker_first": ["/mcp"]
}
}nameis the Worker's name and itsworkers.devhostname. Without it, Blume names the Worker after yourpackage.jsonname.compatibility_datepins the runtime behavior your Worker gets. Set it to the date you create the file.bindingmust stayASSETS: the Astro Worker reads the static files under that name, and declaring it keeps the adapter from swapping in an assets block of its own.html_handling: Blume builds each page asquickstart/index.htmland links to it without a slash. In this build, page requests run through the Worker, which serves them at the slashless URL either way. Pages the static layer serves by itself follow this setting, which happens when the build couldn't write its Worker rules (see Troubleshooting). There, Cloudflare's default answers/quickstartwith a307to/quickstart/, anddrop-trailing-slashserves it at/quickstartinstead.not_found_handlinganswers unknown URLs with Blume's 404 page and a 404 status, which the wrapper swaps for/404.mdwhen a client asks for Markdown.run_worker_first: the build merges its own rules into this list. Listing the endpoint keeps it on the Worker even if that merge is skipped.
Build and inspect the Worker
npx blume buildIn the summary at the end, Output should read server, Adapter cloudflare, and Server features MCP server. Above it, the log says Wired Accept: text/markdown negotiation into the Cloudflare Worker. The build writes three things Wrangler uses:
dist/client: the static files, including each page's.mdcopy,llms.txt,_headers, and_redirects.dist/server: the Worker. Astro'sentry.mjs, theblume-worker.mjswrapper in front of it, andwrangler.json, the config that actually deploys, with yourwrangler.jsoncmerged in..wrangler/deploy/config.json: a pointer that sends Wrangler commands run from the project root todist/server/wrangler.json. Blume adds.wrangler/to your.gitignore.
Print the parts of the deploy config that matter here:
node -e 'const c = require("./dist/server/wrangler.json"); console.log(JSON.stringify({ name: c.name, main: c.main, assets: c.assets }, null, 2))'main should be blume-worker.mjs, and run_worker_first should start with /* followed by exemptions like !/_astro/*, !/*.md, and !/.well-known/*. Your /mcp rule is gone because /* covers it, and Wrangler rejects a rule another one makes redundant. Then let Wrangler check the bundle without uploading it:
npx wrangler deploy --dry-runIt starts by confirming which config it read:
Using redirected Wrangler configuration.
- Configuration being used: "dist/server/wrangler.json"
- Original user's configuration: "wrangler.jsonc"
- Deploy configuration file: ".wrangler/deploy/config.json"It also lists the Worker's modules with their sizes. The MCP server reads a snapshot of every page built into the Worker, so that module grows with your docs. Compare the total against Cloudflare's Workers limits, which also cap how many static files one version can hold.
Test it locally
npx wrangler dev follows the same pointer and runs the built Worker in workerd at http://localhost:8787. In a second terminal, send one request of each kind:
DOCS=http://localhost:8787
# Static layer
curl -s -o /dev/null -D - "$DOCS/index.md"
curl -s -o /dev/null -D - "$DOCS/.well-known/mcp.json"
# Worker: the same page as HTML, then as Markdown
curl -s -o /dev/null -D - "$DOCS/"
curl -s -o /dev/null -D - -H "Accept: text/markdown" "$DOCS/"
# Worker: a missing page, asked for as Markdown
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
-H "Accept: text/markdown" "$DOCS/no-such-page"
# Worker: the MCP endpoint
curl -s "$DOCS/mcp" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_docs","arguments":{"query":"quickstart"}}}'| Request | What to look for |
|---|---|
/index.md | content-type: text/markdown; charset=utf-8 and no vary: Accept |
/.well-known/mcp.json | access-control-allow-origin: *, which comes from _headers, a file Cloudflare applies only on the static layer |
/ | content-type: text/html and vary: Accept |
/ as Markdown | content-type: text/markdown; charset=utf-8, vary: Accept, and an x-markdown-tokens estimate |
| The missing page | 404 text/markdown; charset=utf-8 |
/mcp | A JSON-RPC result listing matching pages, each with a url on your site |
The vary: Accept header is the tell: only the wrapper Worker adds it. Run the same commands with DOCS=https://useblume.dev to see a live site answer them.
Deploy
npx wrangler login
npx wrangler deploywrangler login opens a browser to authorize Wrangler with your Cloudflare account. wrangler deploy uploads the static files and the Worker, then prints the Worker's workers.dev URL. Set DOCS to that URL and run the checks again.
Add your domain
To serve the docs at docs.acme.example, add a Custom Domain to wrangler.jsonc. The domain's zone must be active on your Cloudflare account, and the hostname can't already have a CNAME record; Cloudflare creates the DNS record and certificate for you.
"routes": [{ "pattern": "docs.acme.example", "custom_domain": true }],The deploy reads the config the build wrote, so rebuild after any change to wrangler.jsonc:
npx blume build && npx wrangler deployDeploy on every push
To build from Git, connect the repository under your Worker's Settings > Build in the Cloudflare dashboard. Set the build command to npx blume build and keep the default deploy command, npx wrangler deploy. Workers Builds uses the Wrangler version in your package.json. Commit wrangler.jsonc, since the build reads it; the pointer the build writes for Wrangler stays out of Git.
Two things differ from your machine. If you install with pnpm, the build image's default pnpm can predate allowBuilds, so set the PNPM_VERSION build variable to the version you use. And Workers Builds doesn't keep node_modules/.cache between builds, so every deploy renders every Open Graph card again.
Add secrets
The MCP server needs none: it answers from the snapshot built into the Worker. The assistant does. With ai.assistant.enabled and no provider set, it uses the Vercel AI Gateway and reads AI_GATEWAY_API_KEY on each request. Store the key on the Worker:
npx wrangler secret put AI_GATEWAY_API_KEYWrangler asks for the value, then creates and deploys a new version of the Worker right away. It finds the Worker by the name in your wrangler.jsonc, since secret commands don't follow the build's pointer. blume build still warns BLUME_MISSING_SECRET when the key isn't in the build's own environment. That's expected: the Worker reads it at request time. For blume dev, put it in .env.local.
Build-time tokens, like a Notion source's NOTION_TOKEN, are read while the site builds, so in Workers Builds they go under Build variables and secrets, which the running Worker never sees. To cap how often one reader can call the assistant, see rate-limiting the assistant.
Connect an agent
With the site live on your domain, add the server to Claude Code:
claude mcp add --transport http acme-docs https://docs.acme.example/mcp
claude mcp listacme-docs should show as connected. For Cursor, VS Code, and what an agent does with the tools, see Add an MCP server to your docs. To keep the docs internal, put Cloudflare Access in front of the Worker, and remember that agents outside it can't reach /mcp either.
Troubleshooting
The build stops with BLUME_DEPENDENCY_MISSING
@astrojs/cloudflare isn't installed where the project can resolve it. Run the install command the error prints, from the directory that holds blume.config.ts. npx blume doctor reports the same thing without building.
pnpm fails with ERR_PNPM_IGNORED_BUILDS
Add the packages it names to allowBuilds in pnpm-workspace.yaml, as in Install the adapter and Wrangler, and install again. Recent pnpm releases may write placeholder entries for them into that file; replace each with true.
Markdown requests get HTML
Look for this warning in the build log: Could not wire Accept: text/markdown negotiation into dist/server/wrangler.json. The build skips the wrapper when your own run_worker_first rules would push the list past Wrangler's limit of 100 rules, or a rule past 100 characters, or when the config has no assets binding. Trim your rules, keep "binding": "ASSETS", and rebuild. The .md URLs keep working either way. Until then, the static layer serves the pages itself, so keep "html_handling": "drop-trailing-slash", or every page redirects to a URL ending in a slash.
The MCP endpoint answers 406 or 405
A 406 Not Acceptable means the request's Accept header didn't include both application/json and text/event-stream, which the transport requires. A 405 Method Not Allowed for a GET, such as opening /mcp in a browser, is expected: the endpoint only answers POST.
The build fails with BLUME_SERVER_FEATURE_REQUIRED
The MCP server is on, but the build is static. Remove output: "static" from cloudflare(), or turn the server off.
Wrangler can't find the Worker or its files
.wrangler/deploy/config.json is written by the build and ignored by Git, so a fresh checkout has none. Run npx blume build before npx wrangler deploy, from the same directory.
The build warns about Node.js imports
A warning that starts Unexpected Node.js imports for environment means the Worker config has no nodejs_compat flag. Add it to compatibility_flags in wrangler.jsonc and rebuild.
Next step
Add the assistant to the same Worker
Turn it on in blume.config.ts and store its model key as a Worker secret; it answers readers from the same pages the MCP server reads.
Read the assistant guideA step here not working for you? Report a broken step.