Cloudflare Worker MCP Server
FreeNot checkedMCP server on Cloudflare Workers with Durable Objects exposing web search, web fetch, GitHub search, and Google search via Streamable HTTP.
About
MCP server on Cloudflare Workers with Durable Objects exposing web search, web fetch, GitHub search, and Google search via Streamable HTTP.
README
A Model Context Protocol (MCP) server deployed on Cloudflare Workers with Durable Objects, exposing a suite of web-search and web-fetch tools over the Streamable HTTP transport.
Live endpoint: https://your-worker.workers.dev/mcp
Tools
| Tool | Description |
|---|---|
web_search |
Neural web search via Exa AI — returns titles, URLs, dates, and highlights |
web_fetch_html |
Fetch a URL and return raw HTML |
web_fetch_markdown |
Fetch a URL via Exa /contents and return clean extracted text; falls back to a direct fetch + local conversion if Exa is unavailable |
web_fetch_json |
Fetch one or more URLs via Exa /contents and return structured JSON |
web_fetch_txt |
Fetch a URL and return plain text (strips HTML tags) |
web_github_search |
Search code across public GitHub repos via grep.app |
web_github_readme |
Fetch any file (default: README.md) from a public GitHub repo |
web_google_search |
Google search grounded answer via Gemini API with inline citations |
Shared conventions
maxLength— every fetch tool takes one. Omit it for the tool's default; pass0for no limit. Truncated output ends with an explicit…[truncated: showing N of M chars]marker so the model knows it is looking at a fragment and can re-request with a larger bound.onlyMainContent(web_fetch_txt, defaulttrue) — drops nav/header/footer/sidebar/forms and prefers the page's<main>/<article>body. Setfalsefor the whole document.- Errors set
isError: trueso clients can distinguish a tool failure from a successful call that happened to return an error-shaped string. web_fetch_jsonalso returnsstructuredContent, so clients that understand it get the parsed object instead of re-parsing the text block.
Fetch safety
Tools that fetch a user-supplied URL (web_fetch_html, web_fetch_txt, and the
web_fetch_markdown fallback) go through a guard first:
- http/https only —
file:,ftp:,javascript:and friends are refused. - Internal addresses blocked — loopback, private, link-local, and CGNAT
ranges in both IPv4 and IPv6, plus
localhost,*.internaland the cloud metadata host. This is why fetchinghttp://localhost:3000from a localwrangler devsession is refused; point the tool at a public URL instead. - Every redirect hop is re-checked — redirects are followed manually (max 5),
so a public URL cannot bounce the Worker to
169.254.169.254. - 15-second timeout on every outbound request, including the Exa, Gemini and grep.app calls.
Requests to the fixed API endpoints keep the timeout but skip the URL guard, since their hosts are not user-controlled.
Requirements
- Node.js 18+
- Wrangler CLI (
npm i -g wrangler) - A Cloudflare account (free tier works)
Both API keys are per-tool, not server-wide — the worker boots and serves without them:
- An Exa API key — needed by
web_searchandweb_fetch_json;web_fetch_markdowndegrades to local extraction instead - A Gemini API key — needed by
web_google_search
The remaining six tools need no credentials at all.
Setup
1. Clone and install
git clone https://github.com/hypnguyen1209/cf-worker-mcp.git
cd cf-worker-mcp
npm install
2. Configure
Copy the example config:
cp wrangler.example.jsonc wrangler.jsonc
Set "name" in wrangler.jsonc (it becomes <name>.workers.dev). vars holds
only non-secret values such as GEMINI_MODEL.
API keys are secrets, not vars — values in vars are committed to the repo
and visible in the Cloudflare dashboard:
npx wrangler secret put EXA_API_KEY
npx wrangler secret put GEMINI_API_KEY
If you set your keys as plain-text variables in the dashboard, move them before your next deploy.
wrangler deployreplaces the Worker's bindings with whatever the config declares, and this config declares onlyGEMINI_MODEL. Dashboard-set variables are wiped by that; secrets survive it. The failure is quiet — the deploy succeeds and reports no problem, and you find out whenweb_searchstarts answeringExa API error 401.npx wrangler secret list # [] means nothing is setIf a deploy already removed them, the old values are still readable from the previous version, so you can put them back as secrets:
npx wrangler versions list npx wrangler versions view <old-version-id> # prints var values in fullTreat any key recovered this way as compromised and rotate it. It sat unencrypted in the version history, which anyone with dashboard read access can inspect, and deleting the old versions is not an option.
For wrangler dev, put the same names in an untracked .dev.vars (both it and
wrangler.jsonc are already in .gitignore):
EXA_API_KEY=your-exa-key
GEMINI_API_KEY=your-gemini-key
Either key may be left empty — see Tests for what still works without them.
Optional: require an auth token
Without a token the endpoint is public — anyone who finds the URL can spend your
Exa and Gemini quota. Set MCP_AUTH_TOKEN to gate /mcp and /sse:
node -e "console.log(crypto.randomUUID())" # generate one
npx wrangler secret put MCP_AUTH_TOKEN
The token is accepted in either header, whichever your client can send:
Authorization: Bearer <token>
x-api-key: <token>
Authorization is the MCP-standard form and the one to prefer. x-api-key is
there for clients that only let you attach a flat custom header. Sending both is
fine — each is checked, so a stale Authorization doesn't veto a correct
x-api-key. The Bearer prefix is required on Authorization; a bare token
in that header is rejected.
Add the same name to .dev.vars to exercise the gate under wrangler dev. The
comparison is constant-time and doesn't short-circuit across the two headers, so
a rejected request gets a 401 with WWW-Authenticate: Bearer realm="mcp" and
no hint about how far the token matched or which header came closer.
Leaving MCP_AUTH_TOKEN unset keeps the server open, so existing deployments
keep working unchanged. GET / stays public either way and reports
"authRequired": true when the gate is on.
Usage with MCP clients covers passing the token from Claude Code and Codex.
3. Generate types
npm run cf-typegen
4. Run locally
npm run dev
# MCP endpoint: http://localhost:8787/mcp
5. Deploy
npx wrangler secret list # check this first — see the warning in step 2
npm run deploy
# MCP endpoint: https://<name>.workers.dev/mcp
If your Cloudflare login has more than one account, pin the target rather than letting Wrangler pick — it can't prompt you from a non-interactive shell:
CLOUDFLARE_ACCOUNT_ID=<account-id> npm run deploy
Then confirm the running build is the one you just pushed:
curl -s https://<name>.workers.dev/ | jq '.version, .authRequired'
Usage with MCP clients
The endpoint speaks Streamable HTTP at /mcp, which is what every current
client wants. The legacy SSE transport at /sse stays available for older
ones, but reach for it only when a client cannot do HTTP.
Two things to have ready:
- the URL —
https://<name>.workers.dev/mcp - the token, if you set
MCP_AUTH_TOKEN.GET /tells you whether the gate is on:"authRequired": true. It goes in eitherAuthorization: Bearer <token>orx-api-key: <token>— see Optional: require an auth token.
Claude Code
# Public worker
claude mcp add --transport http web https://your-worker.workers.dev/mcp
# Gated worker
claude mcp add --transport http web https://your-worker.workers.dev/mcp \
--header "Authorization: Bearer your-token"
Options go before the server name. Add --scope user to make it available
in every project instead of just the current one. Verify with claude mcp list
or /mcp inside a session.
To share the server with a repo without committing the token, put it in
.mcp.json and let Claude Code expand the variable at load time — ${VAR}
expansion works in both url and headers:
{
"mcpServers": {
"web": {
"type": "http",
"url": "https://your-worker.workers.dev/mcp",
"headers": { "Authorization": "Bearer ${MCP_AUTH_TOKEN}" }
}
}
}
Keep MCP_AUTH_TOKEN in your shell environment. If the variable is unset the
entry still loads, but the literal ${MCP_AUTH_TOKEN} text goes out as the
token and the worker answers 401; claude mcp list flags the missing
variable, so check there before suspecting the deploy. The type field is not
optional either — an entry with a url and no type is read as a stdio server
and skipped.
--header "x-api-key: your-token" and "headers": { "x-api-key": "${MCP_AUTH_TOKEN}" }
work identically if you prefer that header.
One gotcha specific to a gated worker: when an Authorization header is present
and rejected, Claude Code reports a hard connection failure rather than falling
back to OAuth. A wrong token therefore looks exactly like a broken server.
Codex CLI
# Public worker
codex mcp add web --url https://your-worker.workers.dev/mcp
# Gated worker — pass the variable NAME, not the token
codex mcp add web --url https://your-worker.workers.dev/mcp \
--bearer-token-env-var MCP_AUTH_TOKEN
--bearer-token-env-var takes the name of an environment variable. Codex
reads it at connect time and sends Authorization: Bearer <value>, so the
variable has to be exported in the shell that launches Codex — a token pasted
there directly will be looked up as a variable name and resolve to nothing.
codex mcp add writes to ~/.codex/config.toml. The equivalent hand-written
entry, with url and no command selecting the HTTP transport:
[mcp_servers.web]
url = "https://your-worker.workers.dev/mcp"
bearer_token_env_var = "MCP_AUTH_TOKEN"
For the x-api-key form, swap bearer_token_env_var for env_http_headers,
which also maps a header to a variable name rather than a value:
[mcp_servers.web]
url = "https://your-worker.workers.dev/mcp"
env_http_headers = { "x-api-key" = "MCP_AUTH_TOKEN" }
Codex's tool_timeout_sec default of 60 is already well clear of this worker,
which caps every outbound request at 15s, so leave it alone unless you raise
that cap too.
A project-scoped .codex/config.toml works too, but only in a trusted
directory, and user config wins on conflict. Verify with codex mcp list
(it prints a Status column) or /mcp in a session.
Anything else
Most other clients take the same shape as the .mcp.json block above — a
type/url pair plus optional headers. Some spell the transport
streamable-http instead of http; both name the same thing.
Health check
GET / returns a JSON discovery document:
{
"name": "mcp-worker",
"version": "1.3.0",
"endpoint": "https://your-worker.workers.dev/mcp",
"authRequired": false,
"tools": ["web_search", "web_fetch_html", "..."]
}
Tests
npm test # typecheck + tools/list token budget
scripts/tools-budget.mjs bundles the tool registrars, serialises the real
tools/list payload and fails if it exceeds the token ceiling. Clients pay for
that manifest in every conversation before the model does anything, so
description growth is caught here rather than in review. It prints a per-tool
breakdown showing where to trim when a new tool pushes the total over.
test-tools.mjs is a separate end-to-end smoke test that calls every tool on a
live worker. It spends real Exa and Gemini quota, so it is not part of
npm test:
MCP_URL=https://<name>.workers.dev/mcp node test-tools.mjs
MCP_AUTH_TOKEN=<token> MCP_URL=... node test-tools.mjs # if the worker is gated
MCP_AUTH_HEADER=x-api-key MCP_AUTH_TOKEN=<token> MCP_URL=... node test-tools.mjs
MCP_AUTH_HEADER picks which of the two accepted headers carries the token, so
the same suite covers both auth paths; it defaults to authorization.
It reports three states, and exits non-zero only on ❌:
| Meaning | |
|---|---|
| ✅ | tool returned usable content |
| ⚠️ | tool succeeded but returned nothing usable — an empty-result string |
| ❌ | transport error, or a result with isError: true |
An upstream that refuses the request throws, so it lands in ❌ rather than ⚠️ —
the run is red even though the worker itself is healthy. Read the reason before
treating a ❌ as a regression: 429 Too Many Requests from grep.app is an
upstream condition, not a worker bug.
Running it against wrangler dev
The suite works against a local worker too, which is the cheapest way to check that a dependency bump still boots:
# terminal 1
npm run dev
# terminal 2
MCP_URL=http://localhost:8787/mcp node test-tools.mjs
MCP_AUTH_TOKEN=<token> MCP_URL=http://localhost:8787/mcp node test-tools.mjs
MCP_AUTH_TOKEN in .dev.vars gates the local worker exactly like the deployed
one, so running without the header is a quick way to confirm the gate still
returns 401 rather than serving the tools.
Leaving EXA_API_KEY and GEMINI_API_KEY empty in .dev.vars is a useful run
in itself — it isolates the three credential-dependent tools from everything
else:
| Empty-key result | Tools |
|---|---|
| ✅ unaffected | web_fetch_html, web_fetch_txt, web_github_search, web_github_readme, plus the SSRF-guard case |
| ✅ via fallback | web_fetch_markdown — Exa 401s and the output carries the Extracted locally note, which is what proves the fallback is wired up |
| ❌ needs a key | web_search, web_fetch_json (Exa), web_google_search (Gemini) |
Project structure
src/
index.ts # Worker fetch handler + health endpoint
server.ts # MyMCP Durable Object (registers tools)
types.ts # Env, ToolContext
lib/ # Shared helpers
auth.ts # Optional Bearer gate (constant-time compare)
fetch.ts # Timeouts, redirect re-validation, UA constants
html.ts # HTML → text / Markdown, boilerplate stripping
mcp.ts # Tool response, error, truncation helpers
url.ts # SSRF guard (scheme + private-IP blocklist)
services/ # External API clients
exa.ts # Exa AI search + contents
gemini.ts # Gemini w/ Google Search grounding
grep-app.ts # grep.app code search + snippet parsing
tools/ # One file per MCP tool
index.ts # registerAllTools() + TOOL_NAMES
web-search.ts web-fetch-html.ts web-fetch-markdown.ts
web-fetch-json.ts web-fetch-txt.ts web-github-search.ts
web-github-readme.ts web-google-search.ts
scripts/
tools-budget.mjs # tools/list token-budget regression test
tsconfig.json # Workers types + strict mode
wrangler.example.jsonc # Template to commit
wrangler.jsonc # Your config (gitignored)
.dev.vars # Local secrets for wrangler dev (gitignored)
test-tools.mjs # End-to-end smoke test (node test-tools.mjs)
License
MIT
Installing Cloudflare Worker MCP Server
This server has no published package — it is built from source. Open the repository and follow its README.
▸ github.com/hypnguyen1209/cf-worker-mcpFAQ
Is Cloudflare Worker MCP Server MCP free?
Yes, Cloudflare Worker MCP Server MCP is free — one-click install via Unyly at no cost.
Does Cloudflare Worker MCP Server need an API key?
No, Cloudflare Worker MCP Server runs without API keys or environment variables.
Is Cloudflare Worker MCP Server hosted or self-hosted?
Self-hosted: the server runs locally on your machine via the install command above.
How do I install Cloudflare Worker MCP Server in Claude Desktop, Claude Code or Cursor?
Open Cloudflare Worker MCP Server on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.
Related MCPs
GitHub
PRs, issues, code search, CI status
by GitHubFilesystem
Secure file operations with configurable access controls.
Memory
Knowledge graph-based persistent memory system.
Template MCP Server
A CLI tool to create a new Model Context Protocol server project with TypeScript support, dual transport options, and an extensible structure
by mcpdotdirectAmap Maps Mcp Server
MCP server for using the AMap Maps API
by duxiaohuiSupabase
Database, auth and storage
by SupabaseEverything
Reference / test server with prompts, resources, and tools.
Git
Tools to read, search, and manipulate Git repositories.
Sequential Thinking
Dynamic and reflective problem-solving through thought sequences.
Time
Time and timezone conversion capabilities.
Compare Cloudflare Worker MCP Server with
Not sure what to pick?
Find your stack in 60 seconds
Author?
Embed badge for your README
Browse similar
All development MCPs
