Skip to main content
Glama

fastmcp4-cf

A Python MCP server on CloudflareFastMCP 4 speaking the MCP 2026-07-28 stateless protocol, on Cloudflare Containers behind a Worker.

Clone it, add tools, deploy.

Why this exists

MCP 2026-07-28 is the largest revision since the protocol launched. It removes protocol-level sessions: no initialize handshake, no Mcp-Session-Id. Every request carries its own protocol version and client identity in a _meta envelope, so any instance can answer any request — which is what makes round-robin load balancing and scale-to-zero possible at all.

This is a working place to watch that happen:

  • whoami reports which container answered, and who the server thinks you are. Call it repeatedly — the instance changes mid-conversation, nothing pinned.

  • delete_notes is a guard tool: the multi-round-trip replacement for ctx.elicit(), which now raises on modern connections. It also demonstrates per-caller authorization.

  • probe.sh speaks the raw protocol over curl, so you can watch a tool call succeed with no handshake — impossible under 2025-11-25.

  • CI boots the real container behind the real Worker and asserts all of it.

Tracks a beta. fastmcp==4.0.0b1 is the newest FastMCP 4 (no RC yet; stable is still 3.4.x) and pulls pydantic 2.14.0a1. Pin your versions.

Related MCP server: Remote MCP Server on Cloudflare

Why Containers, not Workers

Workers run JS/TS on V8 isolates; Python there goes through Pyodide, which takes only pure-Python and PyEmscripten wheels — and FastMCP's tree includes pydantic-core, which is Rust. So Python MCP on Cloudflare means Containers, which run an ordinary linux/amd64 image with FastMCP unmodified.

The tradeoff: containers are regional, started on demand in the nearest region — not per-PoP like Workers. Closer to Cloud Run than to edge. If you want true edge, you want TypeScript and Cloudflare's Agents SDK, not this.

Shape

request → src/index.ts (Worker)  →  Dockerfile → server.py (FastMCP 4)
             routes + passes secrets in

Containers are not standalone: a Worker receives every request and picks which instance handles it.

file

role

server.py

the MCP server — put your tools here

Dockerfile

linux/amd64 image, required by Containers

src/index.ts

the Worker: routing, secrets, protocol-era handling

wrangler.jsonc

container, Durable Object binding, migration

probe.sh

smoke tool — raw 2026-07-28 over curl

AUTH.md

read before exposing this — authn/authz

NOTES.md

gotchas that cost real time to find

Prerequisites

  • Docker running — wrangler builds the image locally

  • Workers Paid plan — Containers are not on the free tier

  • Node 20+

Run locally

No Cloudflare account needed — the container runs in local Docker.

npm install
cp .dev.vars.example .dev.vars
python -c "import secrets; print(secrets.token_hex(32))"   # paste into .dev.vars
npm run dev                                                # ready on :8787

Then, in another shell:

./probe.sh http://localhost:8787 server/discover
./probe.sh http://localhost:8787 tools/call whoami

.dev.vars is local-dev config and deliberately holds no auth settings — you cannot mint a Cloudflare Access token on localhost, so adding them makes every local request 401. Deploy-time values live in .deploy.vars instead.

server/discover replaced the initialize handshake — it returns supportedVersions, resultType, instructions, and cache hints. Call whoami a few times and watch instance change: several containers answering one client, no session between them.

Faster loop: no Docker, no Worker

wrangler dev rebuilds the image on every start, which is slow when you are just iterating on tool code. To run the MCP server on its own — no container, no Worker, no load balancing, but the same protocol:

python -m venv .venv && . .venv/bin/activate
pip install --pre -r requirements.txt

REQUEST_STATE_KEY=$(python -c "import secrets; print(secrets.token_hex(32))") \
  PORT=9000 python server.py

./probe.sh http://localhost:9000 tools/call whoami

Use wrangler dev when you care about the Worker, the routing, or anything Cloudflare-shaped; use this when you are writing tools.

The server asking a question back

delete_notes returns a question instead of blocking on one:

# Round 1 — the server asks. Note resultType: "input_required".
./probe.sh http://localhost:8787 tools/call delete_notes '{"folder":"notes"}'

# Round 2 — retry the SAME call with the answer and the state.
STATE=$(./probe.sh http://localhost:8787 tools/call delete_notes '{"folder":"notes"}' \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['result']['requestState'])")

./probe.sh http://localhost:8787 tools/call delete_notes '{"folder":"notes"}' \
  '{"confirm":{"action":"accept","content":{"value":true}}}' "$STATE"

The tool body runs from the top on both rounds — locals don't survive. ctx.input_responses is None the first time, populated the second; anything else that must carry over goes in request_state.

Deploy

npx wrangler login
npx wrangler secret put REQUEST_STATE_KEY    # 32+ bytes of randomness
npm run deploy

First provisioning takes a few minutes. Then:

./probe.sh https://fastmcp4-cf.<your-subdomain>.workers.dev tools/call whoami

REQUEST_STATE_KEY seals the multi-round-trip request_state. Every instance needs the same value — see NOTES.md for why the default breaks under a load balancer. The server refuses to boot without it.

Adding your own tools

@mcp.tool
def my_tool(arg: str) -> str:
    """Docstring becomes the tool description the model reads."""
    return f"got {arg}"

Two constants are duplicated by necessity — keep them in sync:

  • port 8080: Dockerfile (EXPOSE), src/index.ts (defaultPort), server.py

  • instance count: src/index.ts (INSTANCES) and wrangler.jsonc (max_instances)

server.py runs with stateless_http=True, which is what lets both protocol eras be load-balanced with no affinity. Turn it off and clients on the old protocol break intermittently — see NOTES.md.

Authentication

Off by default — as cloned, anyone with the URL can call every tool, and the server says so on boot.

Cloudflare Access is wired up and needs no code: Access runs the OAuth flow at the edge and forwards a signed JWT, which server.py verifies.

npx wrangler secret put ACCESS_TEAM_DOMAIN   # yourteam.cloudflareaccess.com
npx wrangler secret put ACCESS_AUD           # the application's AUD tag
npx wrangler secret put ADMIN_EMAILS         # who may call gated tools
npm run deploy

Verified end to end on a plain workers.dev hostname — no custom domain needed. Two dashboard settings will block you if missed; both are in AUTH.md, along with per-caller tool visibility, service tokens for CI, and the rules worth treating as non-negotiable.

Any other provider — Google, Auth0, WorkOS, Keycloak — swaps into build_auth().

Teardown

wrangler delete removes the Worker but leaves the container application and the pushed image, which keeps costing storage:

npx wrangler delete
npx wrangler containers list && npx wrangler containers delete <ID>
npx wrangler containers images list && npx wrangler containers images delete <image>

Delete the Access application separately in the Zero Trust dashboard.

probe.sh

A smoke tool, not a client library. It sends the required headers and _meta envelope and supports multi-round-trip retries. Its requestState escaping handles quotes only. For real client work use the MCP Inspector.

License

MIT — see LICENSE.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/tameernoor/fastmcp4-cf'

If you have feedback or need assistance with the MCP directory API, please join our Discord server