Command Palette

Search for a command to run...

UnylyUnyly
Browse all

Cloudflare Worker MCP Server

FreeNot checked

MCP server on Cloudflare Workers with Durable Objects exposing web search, web fetch, GitHub search, and Google search via Streamable HTTP.

GitHubEmbed

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; pass 0 for 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, default true) — drops nav/header/footer/sidebar/forms and prefers the page's <main>/<article> body. Set false for the whole document.
  • Errors set isError: true so clients can distinguish a tool failure from a successful call that happened to return an error-shaped string.
  • web_fetch_json also returns structuredContent, 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 onlyfile:, ftp:, javascript: and friends are refused.
  • Internal addresses blocked — loopback, private, link-local, and CGNAT ranges in both IPv4 and IPv6, plus localhost, *.internal and the cloud metadata host. This is why fetching http://localhost:3000 from a local wrangler dev session 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

Both API keys are per-tool, not server-wide — the worker boots and serves without them:

  • An Exa API key — needed by web_search and web_fetch_json; web_fetch_markdown degrades 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 deploy replaces the Worker's bindings with whatever the config declares, and this config declares only GEMINI_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 when web_search starts answering Exa API error 401.

npx wrangler secret list                    # [] means nothing is set

If 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 full

Treat 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 either Authorization: Bearer <token> or x-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

from github.com/hypnguyen1209/cf-worker-mcp

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-mcp

FAQ

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

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