Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

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 9 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:

RequestAnswered 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 /quickstartBlume's wrapper Worker: the HTML page, or the page's Markdown when the request prefers text/markdown
A URL no page backsThe Worker: the 404 page, or /404.md for a Markdown request, with a 404 status either way
POST /mcpThe MCP endpoint in the Astro Worker
A redirect from blume.config.tsThe 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.0

Blume 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: true

A 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"]
  }
}
  • name is the Worker's name and its workers.dev hostname. Without it, Blume names the Worker after your package.json name.
  • compatibility_date pins the runtime behavior your Worker gets. Set it to the date you create the file.
  • binding must stay ASSETS: 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 as quickstart/index.html and 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 /quickstart with a 307 to /quickstart/, and drop-trailing-slash serves it at /quickstart instead.
  • not_found_handling answers unknown URLs with Blume's 404 page and a 404 status, which the wrapper swaps for /404.md when 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 build

In 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 .md copy, llms.txt, _headers, and _redirects.
  • dist/server: the Worker. Astro's entry.mjs, the blume-worker.mjs wrapper in front of it, and wrangler.json, the config that actually deploys, with your wrangler.jsonc merged in.
  • .wrangler/deploy/config.json: a pointer that sends Wrangler commands run from the project root to dist/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-run

It 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"}}}'
RequestWhat to look for
/index.mdcontent-type: text/markdown; charset=utf-8 and no vary: Accept
/.well-known/mcp.jsonaccess-control-allow-origin: *, which comes from _headers, a file Cloudflare applies only on the static layer
/content-type: text/html and vary: Accept
/ as Markdowncontent-type: text/markdown; charset=utf-8, vary: Accept, and an x-markdown-tokens estimate
The missing page404 text/markdown; charset=utf-8
/mcpA 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 deploy

wrangler 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 deploy

Deploy 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_KEY

Wrangler 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 list

acme-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 guide

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init