cloudflare-mcp is a token-efficient Model Context Protocol (MCP) server that exposes the entire Cloudflare API (~2,500 endpoints) using Cloudflare's Code Mode pattern. Instead of registering thousands of MCP tools, it uses just two tools (search and execute) that let agents write JavaScript to query the OpenAPI spec and call APIs — fitting all 2,500 endpoints into ~1,000 tokens.
Production URL: mcp.cloudflare.com
When modifying MCP or OAuth functionality, always check the latest published MCP specification:
- Specification: https://modelcontextprotocol.io/specification/2026-07-28
- Authorization section: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
cloudflare-mcp/
├── src/
│ ├── index.ts # Worker entry point & OAuth routing
│ ├── mcp-handler.ts # Stateless MCP HTTP handler & deployment guards
│ ├── server.ts # MCP server setup & tool registration
│ ├── events/ # Stateless MCP Events mapping to ANS policies and Vega automations
│ ├── executor.ts # Code executor (Worker Loader API)
│ ├── spec-processor.ts # OpenAPI spec $ref resolution & splitting merged paths
│ ├── spec-release.ts # The pinned spec release and its artifact names
│ ├── truncate.ts # Response truncation (~6K token limit)
│ ├── lib/isolate.ts # Isolate-lifetime caches: value, lazy, map, derived
│ ├── lib/isolate-json-schema.ts # Tool schemas converted to JSON Schema once per isolate
│ ├── isolate-cache.ts # Spec artifacts, forge tool list and skills, cached per isolate
│ ├── response-schema-check.ts # Validates a body against generated response schemas; value-free violations
│ ├── response-validation.ts # checkResponse(): sampling, trimming, waitUntil, mismatch events
│ ├── metrics.ts # Analytics Engine metrics (auth_user/tool_call)
│ ├── skills/
│ │ ├── types.ts # Skills extension wire + R2 manifest schemas (Zod)
│ │ ├── tar.ts # Minimal tar reader for the GitHub archive
│ │ ├── bundle.ts # Skill validation, frontmatter, digests
│ │ ├── sync.ts # Six-hourly incremental cloudflare/skills → R2 sync
│ │ └── handlers.ts # skills/list, skills/get, resources/* handlers
│ ├── api-permissions.ts # Connection kinds; authorize(): may a connection use an endpoint
│ ├── api-refusals.ts # Decides which Cloudflare answers are refusals; reviews execute responses
│ ├── scope-challenge.ts # Per-request insufficient_scope step-up challenge
│ ├── clients.ts # Known MCP clients that deviate from the spec, and their behavior
│ ├── generated/spec/ # The pinned spec's artifacts as text modules, written with the tools; never edit by hand
│ ├── generated/forge-mcp/ # Direct tools generated by forge (?codemode=false&experimentalForge=true); never edit by hand
│ ├── forge-tools/ # Forge tools service (worker.ts), call planning, Cloudflare policy
│ ├── auth/
│ │ ├── types.ts # Auth props schemas (Zod discriminated union)
│ │ ├── api-token-mode.ts # Prefix classification & external resolver
│ │ ├── cloudflare-identity.ts # Owner-aware Cloudflare API identity probes
│ │ ├── cloudflare-auth.ts # PKCE & OAuth utilities
│ │ ├── oauth-handler.ts # OAuth authorization flow
│ │ ├── refresh-admission-gate.ts # Best-effort per-grant KV refresh admission
│ │ ├── derived-oauth-scopes.ts # Canonical production OAuth scope API metadata
│ │ ├── scopes.ts # Consent templates and OAuth bootstrap scopes
│ │ └── workers-oauth-utils.ts # OAuth provider helpers
├── tests/ # Vitest suite (top-level, mirrors src/)
│ ├── setup/msw.ts # Global MSW server, the only outbound fetch mock
│ ├── helpers/ # KV/R2/spec fixtures, MCP and OAuth helpers
│ ├── auth/
│ ├── skills/
│ ├── worker.test.ts # Real worker end to end via exports.default.fetch
│ └── *.test.ts
├── scripts/
│ ├── cloudflare-account.ts # The account scripts run cf against
│ ├── seed-r2.ts # Seed skills to R2 (cf r2 objects put)
│ ├── generate-tools.ts # Regenerate src/generated/forge-mcp with forge
│ ├── schema-mismatch-report.ts # Response schema mismatches by product, for teams
│ └── bench-response-validation.ts # Cost of response schema checks on the generated schemas
├── forge.json # The forge commit that generates src/generated/forge-mcp
├── .github/workflows/
│ ├── ci.yml # PR validation
│ ├── forge-tools.yml # Six-hourly regeneration PR for src/generated/forge-mcp
│ └── bonk.yml # AI code review
├── wrangler.jsonc # Workers config (dev/staging/prod)
├── wrangler.forge-tools.jsonc # Forge tools service config (dev/staging/prod)
├── .oxfmtrc.json # oxfmt formatter config
└── README.md
npm install # Install dependenciesNode 22+ required.
- Wrangler builds, runs, tests and deploys the two Workers from
wrangler.jsoncandwrangler.forge-tools.jsonc:npm run dev,deploy*,types, and the Vitest pool. Workers Builds deploys withnpm run deploy:prodanddeploy:forge-tools:prod. Keep these on Wrangler, and don't runcf dev,cf build,cf deployorcf migratehere: in a project with a Wrangler configuration they write their own configuration or fail. - Everything else goes through the
cfCLI or the Cloudflare MCP server, not Wrangler: R2 objects, KV, Analytics Engine queries, Workers Builds triggers, looking up resources and any other Cloudflare API call.npm run seed:*uploads withcf r2 objects put, andnpm run report:schema-mismatchesqueries withcf analytics_engine sql query. Both select the account inscripts/cloudflare-account.ts. - Find a command with
cf cli search "<task>", check it withcf <command> --helporcf schema <command>, and preview it with--dry-run.cfsigns in withcf auth loginand keeps its own credentials, or usesCLOUDFLARE_API_TOKEN. Docs: index, sign in and accounts, Wrangler tocf,cfwith coding agents. cfcan't stream logs yet: usenpx wrangler tail <worker-name>, e.g.npx wrangler tail cloudflare-api-mcpfor production. Wrangler's OAuth sign-in has no R2 scope, so don't usewrangler r2either.
- Strict mode enabled
- Target: ES2022, Module: ESNext
- Runtime validation with Zod for auth props and external data
- oxfmt for formatting: single quotes, no semicolons, no trailing commas.
formatandformat:checkcoversrc/,tests/,scripts/andvitest.config.ts - oxlint for linting
- Run
npm run formatbefore committing
PascalCasefor classes, interfaces, types, enumscamelCasefor functions, methods, variablesSCREAMING_SNAKE_CASEfor constants
The core innovation: instead of 2,500 MCP tools (~244K tokens), two tools handle everything:
searchtool — Agents write JavaScript to query the pre-resolved OpenAPI spec (all$refs inlined). Runs in the forge tools service, next to the spec it ships:search(code)loads a sandbox with no network access and the spec as a text module (src/forge-tools/search.ts), so the main Worker never holds the 27 MB spec.executetool — Agents write JavaScript usingcloudflare.request()to call discovered endpoints. Runs in an isolated worker with outbound restricted to Cloudflare API URLs only.
docs (documentation search) and whoami are registered in both tool modes. whoami (src/tools/whoami.ts) returns the credential's identity as user:<id> or account:<id> and carries _meta['openai/profile'] so ChatGPT can tell connected accounts apart. That ID format is a permanent contract: clients store it, so never change it. Its definition is static like every other tool's; only tools/call reads the credential.
src/mcp-handler.tsusescreateMcpHandler(factory)directly from@modelcontextprotocol/server; this repository does not depend on the Agents SDK.- Each authenticated request creates an upstream handler whose factory closes over validated
AuthProps, matching the repository's pre-migration explicit data flow. - The handler serves MCP
2026-07-28and keeps the upstream default stateless 2025 compatibility path. Its factory creates a freshMcpServerfor every request. - No MCP session ID, protocol transport state, replay store, Durable Object, or Node async-context bridge is used. This server publishes no change notifications, so both tool modes advertise
tools.listChanged: false. Forsubscriptions/listen, the SDK acknowledges with an empty honored filter and, since it honours none of the requested types, ends the stream straight away with acompleteresult (SDK 2.2.0+). No SSE stream stays open. - Every 2025-era POST is answered as one JSON response (
serveLegacyAsJson), not the SDK fallback's SSE stream. JSON keeps the status open for a step-up403, is compressed by Cloudflare's edge (which never compressestext/event-stream), and costs less CPU. The trade-off is that a long call sends no bytes until its result: Python SDK clients time out a silent response after 300 s, which an SSE keep-alive would have reset. 2026-07-28 requests keep the SDK's automatic shaping: JSON unless a notification streams first, andsubscriptions/listenis always SSE. - Deployment-static Host and browser Origin allowlists cover localhost, staging, and production. Do not derive either trust list from the incoming request URL or headers.
src/events/methods.tsregisters the event catalogue and subscription methods on the authenticated MCP endpoint.- ANS policies/destinations and Vega automations own subscription state. No token or signing secret is stored: the client's
whsec_secret and callback URL are sealed into the ANS destination secret (ANS presents it on every call),OAUTH_KVmcp-events:subscription:<id>holds plain routing/expiry and a hash of the signing secret, and the policy description holds encrypted metadata with that hash. A new signing secret gets a new destination. Access is checked on subscribe/refresh, not per delivery. Every token for an account shares one principal, so the KV record is written or deleted only after Cloudflare accepts the caller's change to the policy. Keep KV to hashes, never raw tokens or secrets. src/events/callback.tsopens the sealed destination secret ANS presents, checks the KV subscription record and synchronously signs the MCP callback, with no Cloudflare API call. ANS owns retries.npm run test:eventsruns local workerd coverage with simulated external HTTP endpoints. Seedocs/mcp-events.mdfor live tests and lifecycle constraints.
Code execution uses Cloudflare's Worker Loader API to dynamically create isolated worker instances. The API token is passed via props (never enters user code isolate). A globalOutbound service restricts network access.
Two credential paths produce the same Zod-validated AuthProps union:
- OAuth mode (default):
@cloudflare/workers-oauth-providervalidates its own access tokens and restores encrypted Cloudflare OAuth props. The upstream authorization-code and refresh flows use PKCE and remain separate from direct credential resolution. - Direct Cloudflare credential mode: after the provider's internal lookup misses,
resolveExternalTokenvalidates the bearer against the Cloudflare API and returns request-local props. Prefixes are owner hints:cfat_account tokens query only/accounts;cfut_user API tokens andcfoat_Cloudflare OAuth credentials query/userand/accounts; unprefixed legacy tokens retain response-based inference. Expected failures use the provider'sExternalTokenError, which generates RFC 6750/9728401/403challenges and preserves retry guidance. Cloudflare refuses an expired or unknown credential with403 9109: Invalid access token, the same status and code as a missing permission, so the identity probe reads the message: a rejected credential becomes401 invalid_token, as the MCP authorization spec requires, and only a valid token that lacks access gets403 insufficient_scope.
Validated direct-credential identity is cached by token hash in OAUTH_KV. A valid provider-issued MCP token never reaches the external resolver. One that has expired or been revoked does, after the provider's lookup misses, and the resolver refuses it by shape (userId:grantId:secret, which no Cloudflare credential has) with 401 invalid_token, without probing Cloudflare. Direct-credential identity probes don't retry a 429: the client gets 429 temporarily_unavailable with Cloudflare's Retry-After at once, since retrying spent up to four calls per probe on a token Cloudflare had already rate-limited. The OAuth callback probe, which a person waits on while signing in, still retries.
Downstream refreshes pass through a best-effort per-grant admission gate. An isolate-local block deterministically rejects same-isolate competitors; an owner-verified KV claim reduces cross-isolate races. A successful callback retains a short 10-second tombstone so the concurrent request burst receives structured 429 temporarily_unavailable responses instead of independently rotating downstream refresh tokens, while preserving most of Cloudflare OAuth's 90-second upstream retry grace for provider persistence failures. Callback errors release admission. KV is eventually consistent, so this is load shedding and race reduction rather than a linearizable mutex.
The consent page offers minimal, read-only and full-access templates. Minimal is only the required identity, account and offline-access scopes: clients that step up on insufficient_scope then ask for each further scope when a refused call needs it (see Step-up). Minimal is the default, including when a client requests only the required scopes; a request for more preselects those scopes instead. Read-only and full access are built from the production catalog returned by GET /oauth/scopes in every deployment. The page has no per-scope picker. A collapsed "Advanced" section shows the selected template's scopes as editable text, one per line; editing them replaces the template (the cards grey out) so users can request a short list, e.g. when a corporate proxy rejects the long Cloudflare authorization URL that full access produces. Scopes outside the catalog block Continue because the provider only approves scopesSupported. Cloudflare's authorization screen can only narrow the requested scopes, so the template or edited list is the most a user can grant there. Templates saved in the browser by the old picker still appear and can be removed, but new ones can't be created. Staging may register additional scopes, but the templates include them only after they reach production. Only the user, account, and offline-access OAuth bootstrap scopes sit outside the API catalog. Terraform registration must land before deploying template additions, and removals go the other way round. Merging to main deploys, so follow the scope runbook for the order. The app does not impose a scope-count cap.
- One pinned
cloudflare/forgerelease (openapi@<sha>,openapi.forge.json) serves the whole deployment:src/generated/forge-mcp/source.jsonnames it, andsrc/spec-release.tsreads it (SPEC_RELEASE). Neverreleases/latest: an upstream release once dropped 90% ofx-api-token-group, which refusal explanations and step-up need. The forge tools workflow proposes a newer release in a pull request with the regenerated tools; reviewing and merging that is how the spec changes - The spec ships with the deploy:
scripts/generate-tools.tsbuilds its artifacts (buildSpecArtifacts) from the same release as the forge tools and writes them tosrc/generated/spec/as text modules, which the forge tools service serves (specArtifact). Nothing fetches or syncs the spec at runtime, and the merge that bumps the pin changes Code Mode, the endpoint tools and the forge tools together. The six-hourly cron (0 */6 * * *) syncs only the skills - Forge merges operations that exist under both accounts and zones into
/{account_or_zone}/{account_or_zone_id}/…;processSpecsplits each back into its/accounts/{account_id}/…and/zones/{zone_id}/…paths, so Code Mode sees paths it can call as written and the endpoint tools fill inaccount_id. Since cloudflare/forge#59 (openapi@fdbc7d9), the document givesaccount_or_zoneitsaccounts/zonesenum;cffills it from--zone - All
$refreferences resolved inline before storage - Each 2xx JSON response schema describes
resultof whatcloudflare.request()returns (resultResponses()).openapi.forge.jsonleaves Cloudflare's envelope out of success schemas: a paginated list is{ result, result_info }, anything else is the result itself, or the whole body of an API without the envelope, whichcloudflare.request()also returns asresult. So a list's schema becomes itsresult(every declaration of it combined), and itsresult_infoschema moves to the response'sx-result-info. A schema with root fields other than the envelope's is left as it is. The direct tools read the same payload (see Envelope under Response schema checks) - Products and minimal operation metadata extracted
- Three artifacts:
spec.json,products.json, andmcp-tools.json, the direct-tool catalogue for?codemode=false - A lone 2025-era
tools/listfor the direct tools (both?codemode=falsemodes) is answered bysrc/tools/tool-list-response.tsfrom the listing encoded once per isolate, before the SDK sees it: the SDK's per-requestJSON.stringifyof the ~3.7 MB listing was ~23% of the Worker's production CPU. Cursors, 2026-07-28 requests and anything unusual still go to the SDK, andtests/tool-list-response.test.tschecks both answers are identical mcp-tools.jsonholds the final wire JSON Schemas (including the session-independent optionalaccount_id) plus minimal request-routing metadata. Each isolate builds thetools/listpayload from it once and serves it unchanged;tools/calllazily validates/dispatches only the requested operation with Zod. No per-endpoint SDK tools are registeredsrc/isolate-cache.tsreadsproducts.jsonandmcp-tools.jsonfrom the forge tools service and caches them for one hour in warm isolates. Concurrent requests on a cold isolate share one load, and a failed load is not cached. Tests serve fixtures throughsetSpecArtifactSource(tests/helpers/spec.ts)
?codemode=false asks for direct tools; ?experimentalForge=true changes how they are built: from code forge generates instead of mcp-tools.json. The flag is opt-in while the forge tools soak, and is meant to become how ?codemode=false works. Code Mode ignores it, since it already reads the Forge spec. The earlier ?forge=true preview param is gone; it now serves Code Mode.
src/generated/forge-mcp/is TypeScript thatforge openapi.forge.json --target mcpwrites, committed and never edited by hand. Each group'stools.tshas its tools: schema, hints, the method's extensions, and arequest(args)function that builds the API request. Each tool also hasresponses: the JSON Schema of every response body the method declares, by status, from the sharedoutputs.tsmodels;tools/listnever sends them (see Response schema checks). Each tool with a JSON body on every 2xx response also hasoutputSchema, that body's schema (oranyOfthe distinct ones) with the models it reaches in$defs;listToolssends it only when asked (see the output schema bullet below).schemas.tsfiles hold the shared argument models,source.jsontheopenapi.forge.jsonrelease they came from (written bygenerate-tools.ts),index.tsexports every tool,README.mdlists the methods without a tool and why, and_/tool.tsis forge's runtime:listTools,toolsByName, andbuildRequest, which returns a call's request as plain data ({ method, url, headers, body? }) thatplan()hands over RPC as it is. Forge generates no server. oxfmt and oxlint skip it- The MCP target is proposed for
cloudflare/forge's main in cloudflare/forge#71; until it merges,forge.jsonpins a commit of that PR'smcpbranch.FORGE_BINARY=… npm run generate:toolsregenerates locally..github/workflows/forge-tools.ymlbuilds that forge, regenerates from the latestopenapi.forge.jsonevery six hours, runs the type check and tests, and opens or updates thebot/forge-toolspull request when the code changes. That pull request also movessource.json, the spec pin Code Mode follows, and its summary counts the tools with permissions before and after, warning when a release drops many. The pull request is opened withGITHUB_TOKEN, so CI doesn't run on it; the workflow's own type check and tests are the check. Merging it deploys both Workers through Workers Builds. To change the generator, bumpforge.json'sref - The generated tools run in their own Worker, the forge tools service (
src/forge-tools/worker.ts,wrangler.forge-tools.jsonc,cloudflare-api-mcp-forge-tools[-staging|-dev]), which the main Worker reaches through itsFORGE_TOOLSservice binding. Code Mode isolates never compile the ~10 MB of generated code: workerd compiles every module a Worker uploads at startup, so in the main Worker a lazy import still cost ~27 ms per isolate, and a separate uploaded module didn't help. The service holds no credentials, makes no requests, and has no route. Its RPC methods aresearch(code), which runs Code Mode's search code in aLOADERsandbox against the spec,listJson({ outputSchema }), the tools astools/listsends them as one JSON string, with or without their output schemas, which the main Worker parses once per isolate (getForgeToolList()insrc/isolate-cache.ts; decoding ~3,000 nested objects over RPC cost ~200–300 ms of CPU pertools/list),plan(name, args, { baseUrl, sessionAccountId }), the request a call makes (src/forge-tools/plan.ts),checkResponse(...), which checks a trimmed response against the generated response schemas, andspecArtifact(name), which streams one of the pinned release's spec artifacts (spec.json,products.json,mcp-tools.json).scripts/generate-tools.tswrites those tosrc/generated/spec/as text modules from the same release as the tools (src/forge-tools/spec.tsimports them), so a pin bump carries both. It returns a stream becausespec.json(~27 MB) is close to RPC's 32 MiB message limit. The service bundle is ~51 MB of Workers' 64 MiB limit, about 31 MB of it the spec src/forge-tools/cloudflare.tsapplies the Cloudflare policy once per service isolate:account_idis optional with one description, and so is a merged tool'saccount_or_zone_id(the document givesaccount_or_zoneits values). Permissions come from each tool'sx-api-token-groupextension, which clients never seesrc/tools/forge-tools.ts, in the main Worker, serves?codemode=false&experimentalForge=true:docsandwhoamiitself, and the generated tools from the service. It sends each planned request with the session's token, which never leaves the main Worker, and explains refused calls and steps up OAuth scopes like the other direct tools&experimentalOutputSchema=true(only with?codemode=false&experimentalForge=true) turns on output schemas for 2026-07-28 requests:tools/listsends each tool's generatedoutputSchema, and a successful call returns the payload its output schema describes asstructuredContent(see Envelope under Response schema checks;plan()says which kind), never truncated, next to the text, which stays the whole response. A request is 2026-07-28 when its_metaenvelope names a protocol version (ctx.mcpReq.envelope); 2025-era requests are served as without the flag, since their clients expect object output schemas and most generated ones are a$ref,allOfor scalar. The output schemas come straight from the generator, so the Worker only picks a listing. They are the soak's schemas, so a strict client will reject results the response schema checks flag (Hyperdrive's write-onlyorigin.password, cloudflare/forge#63). The listing grows from ~6 MB to ~12 MB (0.64 to 0.98 MB gzipped), loaded only when asked for- Workers Builds deploys the service and the main Worker on every push to
main. Ship a new RPC method in the service before the main Worker calls it, and in a new environment deploy the service first (npm run deploy:forge-tools/deploy:forge-tools:prod), since the main Worker binds to it.npm run devruns both, andnpm run typesreads both configs to type the binding. Vitest builds the service withwrangler deploy --dry-runand runs it as an auxiliary Worker, so tests call it over the real binding - Tool names are forge's (
d1_list,zones_list), notsrc/openapi.ts's, so switching?codemode=falseto forge renames every tool for its clients; that is why the flag soaks first
The server implements the MCP Skills extension (io.modelcontextprotocol/skills, SEP-2640) in both tool modes and serves the skills from cloudflare/skills.
- Sync: on its own six-hourly cron (
SKILLS_CRON), the scheduled handler downloads the repository tarball from codeload (one request, not subject to the GitHub API rate limit), reads it withsrc/skills/tar.ts, and turns eachskills/<name>/directory into one skill. A skill is left out, with a logged reason, if its directory name isn't a valid skill name, it has noSKILL.mdfrontmatter with stringnameanddescription, thenamedoesn't match the directory, or it exceeds the spec's 512-file / 16 MiB limits. Frontmatter is parsed withyaml(core schema) and passed through verbatim as JSON. - Storage: files are content-addressed at
skills/files/<sha256 hex>, andskills/manifest.jsonholds every entry plus each file's MIME type, encoding and digest. A sync writes only bytes R2 doesn't already hold, writes the manifest after them and only when a skill entry changed, then deletes files that neither the new nor the previous manifest names. Keeping the previous manifest's files covers isolates that still cache it, which relies on the one-hour isolate TTL being shorter than the six-hour sync interval. The served bytes for a digest therefore always match it. A failed sync, or one that finds no valid skills, changes nothing. The manifest records the source commit from the tarball's pax header. - Serving:
skills/listandskills/getreturn complete manifests (sha256digest and size for every file).resources/readserves each file atskill://<name>/<path>;resources/listlists only each skill'sSKILL.md. Results are the same for every user, so they carryttlMs: 1handcacheScope: "public".directoryReadis not declared because every manifest is complete. Before the first sync the listing is empty, which the spec allows. - Unknown skill URIs, unknown files and unissued cursors answer
-32602.
Cloudflare answers a missing OAuth scope, an endpoint OAuth can't reach (billing, API tokens), a missing role and a rejected credential all with 10000: Authentication error or 9109: Unauthorized, and with either 401 or 403 (Workers KV and D1 writes get 401 for a missing scope). Agents read that as a signed-out connection and ask users to reconnect, which usually changes nothing. So every refused call's tool error names the method, path, status and Cloudflare's errors, and adds an explanation from src/api-permissions.ts. A 401 is always a refusal. A 403 is one unless every error Cloudflare sent has a code other than 10000 or 9109: those answers are about the resource or the account (Zaraz's 403: Forbidden: workflow is not preview, Images' 5403 without the subscription), so they pass through as Cloudflare's own error, with no explanation or step-up. Measured for #364: with a token lacking the permission, KV, D1, R2, Queues, Vectorize, Workers AI, Images, Pages and DNS answer 10000, a zone 9109, and Workers an error with no code. Every tool surface decides refusals with refusalFor() in src/api-refusals.ts, so they can't drift apart:
- The connection kind comes from the provider's
ctx.auth. A token the provider issued carriesclientIdand its grantedscope(oauth). A directly resolved credential carries neither (direct). - The endpoint's accepted permissions come from the OpenAPI spec's
x-api-token-group, which spec processing copies into eachspec.jsonoperation (sosearchshows it) and eachmcp-tools.jsonentry (permissions). OAuth scopes share those names (DERIVED_OAUTH_SCOPES), except a few that drifted (/zonesuses the dashboard'sZone Zone Read,Zone Zone EditandZone DNS Edit;Browser Rendering *is the catalog'sBrowser Run *), whichPERMISSION_ALIASESmaps. Any one permission is enough. When one permission name is both an account and a zone scope (access.readandzone-access.read), only the variant matching the request path counts. The request path decides, not the template. Forge merges endpoints that exist for both into one/{account_or_zone}/{account_or_zone_id}/...template;src/spec-processor.tsandsrc/forge-tools/plan.tssplit them back, and reading the request keeps the decision right without depending on that. authorize()turns the endpoint's permissions and the connection's scopes into one decision:granted,missing(with the options and the one scope to ask for),unreachable(no OAuth scope covers it),directorunlisted. The decision picks the explanation, for 401 and 403 alike. The connection lacks every scope the endpoint accepts: grant one. No OAuth scope exists for the endpoint: use an API token. For a direct credential: these are the permissions the token needs. The status matters only where the scopes can't decide: the spec lists no permissions, or the connection already holds an accepted scope (403: usually the user's role, reconnecting won't help; 401: ambiguous, since the edge API gateway, which serves KV, D1, Workers AI, Browser Rendering and Workflows, answers every authorization failure with 40110000, so the agent is told to reconnect only if other requests fail with 401 too).execute:GlobalOutboundpasses every response throughreviewResponse(), which sets the explanation in anX-Cloudflare-MCP-Refusal-Hintresponse header. The sandbox puts it in the error it throws, so it survives code that catches the error.executematches the path against the cachedmcp-tools.jsontemplates, looked up only for refusals and successful JSON GETs from OAuth connections; parsing the ~25 MBspec.jsonthere could exhaust the isolate's memory. Endpoint tools and forge tools build their result withapiCallResult()(src/tools/api-call-result.ts), using their own entry's permissions.
The GraphQL Analytics API isn't in the spec and answers a missing permission with 200 and data: null: a zone dataset names com.cloudflare.api.account.zone.analytics.read, an account dataset says only authorization denied (measured for #364). The execute sandbox marks those errors, and the host explains them as a refusal of Analytics Read (analytics.read) or Account Analytics Read (account-analytics.read) and steps up for a missing scope. The sandbox counts GraphQL POSTs as reads for replay safety, since the API has no mutations. When execute code puts an account the session can't use in a refused request's path, the error names the session's accounts (unknownPathAccountHint), as the account_id argument already did.
Cloudflare refuses some endpoints without the permission (D1, KV, R2) but filters the lists of others to what the credential may see: with only user:read account:read, GET /zones, /accounts/{account_id}/workers/scripts, /workers/scripts-search and /access/identity_providers all answer 200 with an empty result and no error or header that says so (measured for #302). Agents then report an empty account, and since Minimal is the default most first reads go this way. So refusalFor() refuses such a list as 403, with the same explanation and step-up as a real refusal, when emptyListProvesNothing() holds:
- A GET from an OAuth connection whose decision is
missing. A direct credential's permissions are unknown here. - An empty list, in the envelope's
resultor bare. A list with rows is never doubted. - None of the endpoint's permissions is Account Settings Read, Memberships Read or User Details Read. The baseline scopes sit outside the catalog and grant those for some endpoints (
/accounts/{account_id}/members,/memberships,/user/invitesanswer with data) but not others (audit_logs, alerting refuse), so an empty list there could be genuine and is left alone. They are never counted as held for a real refusal.
The refusal's body (filteredListBody()) says Cloudflare answered 200 with an empty list, so the error doesn't pretend Cloudflare sent the 403. An account that really has none is asked for the scope once; after that the empty list is trusted. execute reads the body only once the decision is missing, and not when Content-Length exceeds 4 KB.
When authorize() decides missing for a refused request, and names one scope, the tool asks the client to re-authorize. The challenge comes from insufficientScope() in workers-oauth-provider and names the baseline (user:read account:read), every scope the connection already holds, and the missing one. The spec makes accumulating scopes the client's job, but Codex re-authorizes with exactly the challenged scopes, Claude's connector docs ask servers to list everything the user still needs, and a new grant replaces the old one, so naming only the missing scope would drop the rest.
How the challenge reaches the client depends on the client (src/clients.ts):
-
http(the spec, and every client not listed): the403withWWW-Authenticate: Bearer error="insufficient_scope"replaces the response. Claude and Claude Code step up on it and retry the call; an error tool result never prompts them. -
tool(Codex and ChatGPT): the error tool result is sent with the challenge in_meta["mcp/www_authenticate"]. They step up only on this; an HTTP403fails their tool call (openai/codex#20518). -
?scopeChallenge=http|tooloverrides the client's delivery, for testing a client. -
The challenge is raised only after Cloudflare answers, never beforehand from the spec, because the permission-name mapping isn't reliable enough to block calls on:
/accounts/{account_id}/membersanswers without the Account Settings Read scope the spec lists. A list emptied by permission counts as a refusal (above). -
Retrying has to be safe. An endpoint tool makes one request. In
execute, the refusal must be what made the code fail, and every request the code sent must have been a GET/HEAD or refused (the sandbox wrapsfetchbefore the code runs). Otherwise the tool error says why the code wasn't retried. -
The sandbox reports the scope (
X-Cloudflare-MCP-Missing-ScopefromGlobalOutbound), so it is untrusted. The host accepts only catalog scopes the connection lacks. Code that lies can only trigger a challenge it could also get by calling the endpoint. -
A connection that already holds an accepted scope is never challenged, so a wrong pick can't loop.
-
2025-era POSTs are served as JSON (
enableJsonResponse) rather than the SDK fallback's SSE, so the status is still open when the response becomes the403.
src/clients.ts lists the clients that need behavior other than the spec's, and how to recognise them: an exact Client ID Metadata Document URL (no I/O), or a registered client's redirect URI host (one KV read, through OAUTH_PROVIDER.lookupClient). Add an entry only for a client that deviates, with only the ClientBehavior fields that differ; every other client gets SPEC_BEHAVIOR. Identify the client lazily, only when the behavior is needed.
A warm isolate serves many requests, so anything that doesn't depend on the request is built once and reused. Use isolate from src/lib/isolate.ts rather than a module-level let or Map:
isolate.value(load, { ttlMs }): an async load shared by concurrent requests (one read from the forge tools service on a cold isolate, not one per request), reloaded afterttlMs. A failed load is not cached.isolate.lazy(build): a synchronous value built on first use.isolate.map({ maxEntries, maxSize, sizeOf }): values by key, cleared when a cap would be passed, so a long-lived isolate can't grow without bound.isolate.derived(compute): a value computed from another cached object (an index, serialized bytes), keyed on that object's identity, so it is recomputed when the source reloads.
Tool and method schemas are module-level constants, not built per request; wrap a tool's zod schema in withCachedJsonSchema (src/lib/isolate-json-schema.ts) so the SDK's per-tools/list JSON Schema conversion runs once per isolate.
Every cache registers itself; isolate.resetAll() returns a test to a cold isolate. One isolate serves every user, so never cache anything per request, per user or per credential this way.
Responses capped at ~6,000 tokens (~24KB). src/truncate.ts shrinks oversized JSON structurally so it stays valid JSON: arrays keep whole items from the start and end with a --- TRUNCATED --- N more items element, long strings are clipped, and objects drop their largest values first, naming them in a --- TRUNCATED --- entry. Plain text is cut at the cap and followed by a notice with the original size.
The cap is on by default in Code Mode and off for the direct tools (?codemode=false, with or without experimentalForge): createServer defaults truncateToolResult to codemode. Clients without code mode of their own use our Code Mode, where results go straight to the model. Clients with their own code mode use the direct tools and parse each result in their own code, so a capped result would lose data.
In Code Mode, search and execute take an optional truncate boolean. truncate: false returns that call's result whole, for an agent writing it to a file or pipe rather than reading it; truncate: true caps it. An omitted truncate uses the connection's default. Its description doesn't state the default, because the tool metadata is the same for every connection. The direct tools take no such argument.
?truncateToolResult= still sets the connection's default in either mode (on unless the value is exactly false), but it is no longer documented in the README. It stays because clients use it: ~60,000 /mcp?truncateToolResult=false requests in a sampled week of Workers Logs, and removing it would start truncating their results. src/mcp-handler.ts reads it next to ?codemode=false.
The direct tools (?codemode=false, with or without experimentalForge) check every JSON response, success or error, against the response schemas forge generates and record each mismatch, so product teams can fix their schemas before tools advertise an outputSchema (#320). Code Mode is not checked.
- Schemas: forge's MCP target generates each tool's
responsesfrom forge's model of the spec, not the raw OpenAPI: read-only fields are kept, descriptions dropped, andnullableis a union withnull. They ship with the generated tools in the forge tools service, so nothing is fetched or parsed to check a response. The main Worker never loads them. - Flow (
src/response-validation.ts):checkResponse()samples (RESPONSE_VALIDATION_SAMPLE_RATE, 0-1, unset means 1), skips bodies over 1M characters, and inwaitUntiltrims every array to its first 25 items and calls the service'scheckResponseRPC. The service finds the generated tool byMETHOD template: a forge tool's own template, or for an endpoint tool the account or zone route a merged/{account_or_zone}/…tool splits into. Everything is caught, so nothing can change or fail a tool result. - Envelope (
src/forge-tools/envelope.ts):openapi.forge.jsondescribes success bodies without Cloudflare's envelope. Most 2xx schemas dropsuccess,errorsandmessages; paginated lists keep{ result, result_info }, a few operations keep the whole envelope, and every other operation gives only itsresult(cloudflare/api-schemas, which the release is built from, describes the envelope). So for a 2xx body withsuccessandresult, the service checks the payload:resultwhen the schema doesn't declareresult, else the whole body (a few schemas keep the envelope and requiresuccess). Which kind each tool's schema is gets decided once per isolate. Reported instance paths keep their place in the response (/result/subdomain). Error schemas describe the whole envelope, and APIs that don't wrap their responses never match, so both are checked whole. Mismatches recorded before this fix include every unwrapped operation's envelope as violations at its root. - Validation (
src/response-schema-check.ts, in the service):@cfworker/json-schema2020-12, against the status's schema, then its class (4XX), thendefault; validators are compiled per tool and cached per isolate. A short-circuit pass finds the first violation; invalid bodies are validated again to count them. A check costs at most ~10 ms.npx tsx scripts/bench-response-validation.ts [tool ...]measures it. - No customer data: events and logs hold keywords, schema locations and instance paths only. Array indices and keys the schema doesn't declare become
*; the validator's messages are never recorded. - Recording: one
response_schema_mismatchAnalytics Engine event per mismatching response (layout below), naming the generated tool and the spec release (source.json), and oneconsole.warnline with up to five violations. - Report:
npm run report:schema-mismatches -- [--env production] [--days 7] [--product dns_records]groups mismatches by product → operation → violation, each row linked to its spec release. It runs the query withcf, which needs Account Analytics Read;--format sqlprints the query and--from-jsonrenders a result run elsewhere, such as through the Cloudflare MCP server.
Tool usage is tracked via the MCP_METRICS Analytics Engine binding into the shared mcp-metrics-{dev,staging,production} dataset — the same dataset used by the per-product Cloudflare MCP servers (cloudflare/mcp-server-cloudflare), so this server shows up alongside them under server name cloudflare-api.
src/metrics.tsmirrors the upstream@repo/mcp-observabilityschema. The blob/double layout is positional and must not change:index1= event type,blob1/blob2= server name/version (reserved),blob3= userId,blob4= toolName/errorMessage,double1= errorCode.response_schema_mismatch(see Response schema checks) keeps those positions (blob3userId,blob4toolName,double1= 0) and adds:blob5method,blob6path template,blob7product,blob8the generated tool whose schema was used,blob9response key (200/4XX/default),blob10first violation's keyword,blob11its schema location (#/components/schemas/<name>/…or#/responses/<key>/schema/…),blob12its instance path (/result/*/id),blob13the spec release the generated schemas came from (openapi@<sha>),double2HTTP status,double3violation count,double4sample interval (1 /RESPONSE_VALIDATION_SAMPLE_RATE),double51 ifdouble3is exact, 0 if a lower bound.attachMetrics()insrc/server.tswraps Code-ModeregisterToolcalls; the lazy non-Code-Mode dispatcher records the sametool_callevents directly.auth_userevents are emitted from the OAuth handler.- No
session_start: MCP2026-07-28has no protocol sessions orinitializehandshake. The 2025 compatibility path also creates a fresh server for each request and retains no initialization state. Client identity remains visible at the HTTP layer throughUser-Agent(including zone HTTP analytics). - The tracker is tolerant of a missing binding (no-op in tests/local dev) and swallows write errors so metrics can never break a tool call.
- Query via the Analytics Engine SQL API:
SELECT ... FROM 'mcp-metrics-production' WHERE blob1='cloudflare-api' AND index1='tool_call'.
- API tokens never enter user code isolates — passed via worker props
globalOutboundservice restricts execute tool to Cloudflare API URLs only- Search tool runs with no network access
- Skill content comes from
cloudflare/skillsonly. The tar reader keeps regular files and drops symlinks, links and paths with./..segments, andresources/readserves only URIs in the synced manifest - OAuth uses PKCE (RFC 7636) for secure authorization
- Cookie encryption for OAuth sessions (
MCP_COOKIE_ENCRYPTION_KEY) - The
/mcproute validates Host and present browser Origin headers against deployment-static allowlists before authentication
Tests live in the top-level tests/ directory (mirroring src/) and use vitest with @cloudflare/vitest-plugin.
npm run test # Single run
npm run test:watch # Watch modeUnit/integration coverage areas:
- Scheduled handler (spec fetching & processing, skills sync)
- Skills: tar reading, frontmatter, manifests and digests, and the skills extension end to end
- Auth token detection and parsing
- Auth props building and validation
- Spec processor ($ref resolution, product extraction)
- Response truncation
- Response schema checks and mismatch events (
tests/response-*.test.ts) - Metrics event mapping & path normalization
End to end (tests/worker.test.ts and the other worker-level files):
These drive the real worker via exports.default.fetch() (from cloudflare:workers), the
pattern from the Cloudflare vitest recipes.
A full JSON-RPC tools/call for execute runs real code inside a Worker Loader
isolate and is forwarded through the real GlobalOutbound proxy. MSW intercepts both the
auth-guard /user+/accounts probes and the GlobalOutbound-forwarded API call.
Auth, MCP transport, tool dispatch and Worker Loader all run for real.
Mocking outbound fetch: MSW is the only outbound mock. tests/setup/msw.ts is a
setupFiles entry that starts one msw/node server for every file with
onUnhandledRequest: 'error' and resets handlers after each test. Import { server }
from it and register responses with server.use(http.get(...)). Assert on the request the
handler received, and use HttpResponse.error() for a network failure. Never assign to or
spy on globalThis.fetch. It is read-only in the test runtime, and replacing it skips the
real request path.
The test stack is vitest 4 + @cloudflare/vitest-plugin 1.x (the renamed
@cloudflare/vitest-pool-workers) using the cloudflareTest() Vite plugin, which
MSW's msw/node needs to load under workerd. The plugin uses the same Wrangler,
miniflare and workerd versions as deploys. Storage isolation is per test file
(not per test), so tests sharing real bindings (e.g. OAUTH_KV) must clear state in
afterEach. testTimeout is 15s because a file's first request to the worker loads
it into a fresh isolate, which takes 4-5s when every file runs in parallel.
CI runs on every PR:
npm ci— Clean installnpm run format:check— oxfmt formatting checknpm run lint— oxlintnpm run typecheck— TypeScript type checkingnpm run test— Vitest test suite
All checks must pass before merge.
Merging to main deploys, so a merged PR goes live straight away. Land what it depends on first. A new OAuth scope must be registered in Terraform and applied an hour before (see the scope runbook). A change to the skills' R2 keys or shape needs npm run seed:prod before the merge.
Mention /bonk or @ask-bonk in PR comments to get AI-powered code review and suggestions. Bonk can analyze code, suggest fixes, and even auto-commit improvements.
Always:
- Run
npm run checkbefore considering work done - Add tests for new functionality
- Consider security implications — this handles API tokens and OAuth flows
- Use Zod for runtime validation of external data
Ask first:
- Adding new dependencies
- Changing authentication flows or token handling
- Modifying the OpenAPI spec processing pipeline
- Changing deployment configuration or bindings
Never:
- Hardcode secrets or API keys
- Allow user code to access API tokens directly
- Bypass
globalOutboundnetwork restrictions - Force push to main
Update this file when:
- Adding new modules or significant features
- Changing project structure
- Modifying build/test tooling
- Adding new code patterns or conventions
- Changing contribution workflows
