mcp/AGENTS.md at main · cloudflare/mcp · GitHub
Skip to content

Latest commit

 

History

History
411 lines (304 loc) · 51.5 KB

AGENTS.md

File metadata and controls

411 lines (304 loc) · 51.5 KB

AGENTS.md

Project overview

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

MCP specification compliance

When modifying MCP or OAuth functionality, always check the latest published MCP specification:

Repository structure

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

Setup

npm install    # Install dependencies

Node 22+ required.

Cloudflare tooling: Wrangler for the Workers, cf for everything else

  • Wrangler builds, runs, tests and deploys the two Workers from wrangler.jsonc and wrangler.forge-tools.jsonc: npm run dev, deploy*, types, and the Vitest pool. Workers Builds deploys with npm run deploy:prod and deploy:forge-tools:prod. Keep these on Wrangler, and don't run cf dev, cf build, cf deploy or cf migrate here: in a project with a Wrangler configuration they write their own configuration or fail.
  • Everything else goes through the cf CLI 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 with cf r2 objects put, and npm run report:schema-mismatches queries with cf analytics_engine sql query. Both select the account in scripts/cloudflare-account.ts.
  • Find a command with cf cli search "<task>", check it with cf <command> --help or cf schema <command>, and preview it with --dry-run. cf signs in with cf auth login and keeps its own credentials, or uses CLOUDFLARE_API_TOKEN. Docs: index, sign in and accounts, Wrangler to cf, cf with coding agents.
  • cf can't stream logs yet: use npx wrangler tail <worker-name>, e.g. npx wrangler tail cloudflare-api-mcp for production. Wrangler's OAuth sign-in has no R2 scope, so don't use wrangler r2 either.

Commands

Command What it does
npm run dev Start local dev server (wrangler dev)
npm run deploy Deploy to staging
npm run deploy:prod Deploy to production by hand (merging to main already deploys)
npm run deploy:forge-tools / deploy:forge-tools:prod Deploy the forge tools service to staging / production
npm run generate:tools Regenerate src/generated/forge-mcp (needs FORGE_BINARY)
npm run types Generate worker type definitions
npm run typecheck TypeScript type checking (no emit)
npm run lint Lint with oxlint
npm run format Format src/, tests/, scripts/ with oxfmt
npm run format:check Check formatting without modifying
npm run test Run vitest test suite
npm run test:watch Run vitest in watch mode
npm run check Run all checks (format, lint, typecheck, test)
npm run seed:staging Seed skills to staging R2 (with cf)
npm run seed:prod Seed skills to production R2 (with cf)
npm run report:schema-mismatches Response schema mismatch report (see below)

Code standards

TypeScript

  • Strict mode enabled
  • Target: ES2022, Module: ESNext
  • Runtime validation with Zod for auth props and external data

Formatting & linting

  • oxfmt for formatting: single quotes, no semicolons, no trailing commas. format and format:check cover src/, tests/, scripts/ and vitest.config.ts
  • oxlint for linting
  • Run npm run format before committing

Naming conventions

  • PascalCase for classes, interfaces, types, enums
  • camelCase for functions, methods, variables
  • SCREAMING_SNAKE_CASE for constants

Architecture

Two-tool Code Mode pattern

The core innovation: instead of 2,500 MCP tools (~244K tokens), two tools handle everything:

  1. search tool — 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.
  2. execute tool — Agents write JavaScript using cloudflare.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.

MCP HTTP serving

  • src/mcp-handler.ts uses createMcpHandler(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-28 and keeps the upstream default stateless 2025 compatibility path. Its factory creates a fresh McpServer for 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. For subscriptions/listen, the SDK acknowledges with an empty honored filter and, since it honours none of the requested types, ends the stream straight away with a complete result (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-up 403, is compressed by Cloudflare's edge (which never compresses text/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, and subscriptions/listen is 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.

MCP Events

  • src/events/methods.ts registers 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_KV mcp-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.ts opens 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:events runs local workerd coverage with simulated external HTTP endpoints. See docs/mcp-events.md for live tests and lifecycle constraints.

Worker Loader API

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.

Authentication

Two credential paths produce the same Zod-validated AuthProps union:

  • OAuth mode (default): @cloudflare/workers-oauth-provider validates 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, resolveExternalToken validates 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 and cfoat_ Cloudflare OAuth credentials query /user and /accounts; unprefixed legacy tokens retain response-based inference. Expected failures use the provider's ExternalTokenError, which generates RFC 6750/9728 401/403 challenges and preserves retry guidance. Cloudflare refuses an expired or unknown credential with 403 9109: Invalid access token, the same status and code as a missing permission, so the identity probe reads the message: a rejected credential becomes 401 invalid_token, as the MCP authorization spec requires, and only a valid token that lacks access gets 403 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.

OpenAPI spec processing

  • One pinned cloudflare/forge release (openapi@<sha>, openapi.forge.json) serves the whole deployment: src/generated/forge-mcp/source.json names it, and src/spec-release.ts reads it (SPEC_RELEASE). Never releases/latest: an upstream release once dropped 90% of x-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.ts builds its artifacts (buildSpecArtifacts) from the same release as the forge tools and writes them to src/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}/…; processSpec splits 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 in account_id. Since cloudflare/forge#59 (openapi@fdbc7d9), the document gives account_or_zone its accounts/zones enum; cf fills it from --zone
  • All $ref references resolved inline before storage
  • Each 2xx JSON response schema describes result of what cloudflare.request() returns (resultResponses()). openapi.forge.json leaves 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, which cloudflare.request() also returns as result. So a list's schema becomes its result (every declaration of it combined), and its result_info schema moves to the response's x-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, and mcp-tools.json, the direct-tool catalogue for ?codemode=false
  • A lone 2025-era tools/list for the direct tools (both ?codemode=false modes) is answered by src/tools/tool-list-response.ts from the listing encoded once per isolate, before the SDK sees it: the SDK's per-request JSON.stringify of 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, and tests/tool-list-response.test.ts checks both answers are identical
  • mcp-tools.json holds the final wire JSON Schemas (including the session-independent optional account_id) plus minimal request-routing metadata. Each isolate builds the tools/list payload from it once and serves it unchanged; tools/call lazily validates/dispatches only the requested operation with Zod. No per-endpoint SDK tools are registered
  • src/isolate-cache.ts reads products.json and mcp-tools.json from 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 through setSpecArtifactSource (tests/helpers/spec.ts)

Forge-generated direct tools (?codemode=false&experimentalForge=true, opt-in)

?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 that forge openapi.forge.json --target mcp writes, committed and never edited by hand. Each group's tools.ts has its tools: schema, hints, the method's extensions, and a request(args) function that builds the API request. Each tool also has responses: the JSON Schema of every response body the method declares, by status, from the shared outputs.ts models; tools/list never sends them (see Response schema checks). Each tool with a JSON body on every 2xx response also has outputSchema, that body's schema (or anyOf the distinct ones) with the models it reaches in $defs; listTools sends it only when asked (see the output schema bullet below). schemas.ts files hold the shared argument models, source.json the openapi.forge.json release they came from (written by generate-tools.ts), index.ts exports every tool, README.md lists the methods without a tool and why, and _/tool.ts is forge's runtime: listTools, toolsByName, and buildRequest, which returns a call's request as plain data ({ method, url, headers, body? }) that plan() 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.json pins a commit of that PR's mcp branch. FORGE_BINARY=… npm run generate:tools regenerates locally. .github/workflows/forge-tools.yml builds that forge, regenerates from the latest openapi.forge.json every six hours, runs the type check and tests, and opens or updates the bot/forge-tools pull request when the code changes. That pull request also moves source.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 with GITHUB_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, bump forge.json's ref
  • 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 its FORGE_TOOLS service 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 are search(code), which runs Code Mode's search code in a LOADER sandbox against the spec, listJson({ outputSchema }), the tools as tools/list sends them as one JSON string, with or without their output schemas, which the main Worker parses once per isolate (getForgeToolList() in src/isolate-cache.ts; decoding ~3,000 nested objects over RPC cost ~200–300 ms of CPU per tools/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, and specArtifact(name), which streams one of the pinned release's spec artifacts (spec.json, products.json, mcp-tools.json). scripts/generate-tools.ts writes those to src/generated/spec/ as text modules from the same release as the tools (src/forge-tools/spec.ts imports them), so a pin bump carries both. It returns a stream because spec.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.ts applies the Cloudflare policy once per service isolate: account_id is optional with one description, and so is a merged tool's account_or_zone_id (the document gives account_or_zone its values). Permissions come from each tool's x-api-token-group extension, which clients never see
  • src/tools/forge-tools.ts, in the main Worker, serves ?codemode=false&experimentalForge=true: docs and whoami itself, 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/list sends each tool's generated outputSchema, and a successful call returns the payload its output schema describes as structuredContent (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 _meta envelope 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, allOf or 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-only origin.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 dev runs both, and npm run types reads both configs to type the binding. Vitest builds the service with wrangler deploy --dry-run and runs it as an auxiliary Worker, so tests call it over the real binding
  • Tool names are forge's (d1_list, zones_list), not src/openapi.ts's, so switching ?codemode=false to forge renames every tool for its clients; that is why the flag soaks first

Skills over MCP

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 with src/skills/tar.ts, and turns each skills/<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 no SKILL.md frontmatter with string name and description, the name doesn't match the directory, or it exceeds the spec's 512-file / 16 MiB limits. Frontmatter is parsed with yaml (core schema) and passed through verbatim as JSON.
  • Storage: files are content-addressed at skills/files/<sha256 hex>, and skills/manifest.json holds 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/list and skills/get return complete manifests (sha256 digest and size for every file). resources/read serves each file at skill://<name>/<path>; resources/list lists only each skill's SKILL.md. Results are the same for every user, so they carry ttlMs: 1h and cacheScope: "public". directoryRead is 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.

Refused API calls

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 carries clientId and its granted scope (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 each spec.json operation (so search shows it) and each mcp-tools.json entry (permissions). OAuth scopes share those names (DERIVED_OAUTH_SCOPES), except a few that drifted (/zones uses the dashboard's Zone Zone Read, Zone Zone Edit and Zone DNS Edit; Browser Rendering * is the catalog's Browser Run *), which PERMISSION_ALIASES maps. Any one permission is enough. When one permission name is both an account and a zone scope (access.read and zone-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.ts and src/forge-tools/plan.ts split 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), direct or unlisted. 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 401 10000, so the agent is told to reconnect only if other requests fail with 401 too).
  • execute: GlobalOutbound passes every response through reviewResponse(), which sets the explanation in an X-Cloudflare-MCP-Refusal-Hint response header. The sandbox puts it in the error it throws, so it survives code that catches the error. execute matches the path against the cached mcp-tools.json templates, looked up only for refusals and successful JSON GETs from OAuth connections; parsing the ~25 MB spec.json there could exhaust the isolate's memory. Endpoint tools and forge tools build their result with apiCallResult() (src/tools/api-call-result.ts), using their own entry's permissions.

GraphQL refusals

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.

Lists emptied by permission

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 result or 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/invites answer 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.

Step-up (insufficient_scope)

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): the 403 with WWW-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 HTTP 403 fails their tool call (openai/codex#20518).

  • ?scopeChallenge=http|tool overrides 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}/members answers 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 wraps fetch before the code runs). Otherwise the tool error says why the code wasn't retried.

  • The sandbox reports the scope (X-Cloudflare-MCP-Missing-Scope from GlobalOutbound), 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 the 403.

Known MCP clients

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.

Isolate caching

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 after ttlMs. 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.

Response truncation

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.

Response schema checks

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 responses from forge's model of the spec, not the raw OpenAPI: read-only fields are kept, descriptions dropped, and nullable is a union with null. 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 in waitUntil trims every array to its first 25 items and calls the service's checkResponse RPC. The service finds the generated tool by METHOD 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.json describes success bodies without Cloudflare's envelope. Most 2xx schemas drop success, errors and messages; paginated lists keep { result, result_info }, a few operations keep the whole envelope, and every other operation gives only its result (cloudflare/api-schemas, which the release is built from, describes the envelope). So for a 2xx body with success and result, the service checks the payload: result when the schema doesn't declare result, else the whole body (a few schemas keep the envelope and require success). 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-schema 2020-12, against the status's schema, then its class (4XX), then default; 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_mismatch Analytics Engine event per mismatching response (layout below), naming the generated tool and the spec release (source.json), and one console.warn line 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 with cf, which needs Account Analytics Read; --format sql prints the query and --from-json renders a result run elsewhere, such as through the Cloudflare MCP server.

Usage metrics (Analytics Engine)

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.ts mirrors the upstream @repo/mcp-observability schema. 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 (blob3 userId, blob4 toolName, double1 = 0) and adds: blob5 method, blob6 path template, blob7 product, blob8 the generated tool whose schema was used, blob9 response key (200/4XX/default), blob10 first violation's keyword, blob11 its schema location (#/components/schemas/<name>/… or #/responses/<key>/schema/…), blob12 its instance path (/result/*/id), blob13 the spec release the generated schemas came from (openapi@<sha>), double2 HTTP status, double3 violation count, double4 sample interval (1 / RESPONSE_VALIDATION_SAMPLE_RATE), double5 1 if double3 is exact, 0 if a lower bound.
  • attachMetrics() in src/server.ts wraps Code-Mode registerTool calls; the lazy non-Code-Mode dispatcher records the same tool_call events directly. auth_user events are emitted from the OAuth handler.
  • No session_start: MCP 2026-07-28 has no protocol sessions or initialize handshake. 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 through User-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'.

Security considerations

  • API tokens never enter user code isolates — passed via worker props
  • globalOutbound service restricts execute tool to Cloudflare API URLs only
  • Search tool runs with no network access
  • Skill content comes from cloudflare/skills only. The tar reader keeps regular files and drops symlinks, links and paths with ./.. segments, and resources/read serves only URIs in the synced manifest
  • OAuth uses PKCE (RFC 7636) for secure authorization
  • Cookie encryption for OAuth sessions (MCP_COOKIE_ENCRYPTION_KEY)
  • The /mcp route validates Host and present browser Origin headers against deployment-static allowlists before authentication

Testing

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 mode

Unit/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.

Contributing

Pull request process

CI runs on every PR:

  1. npm ci — Clean install
  2. npm run format:check — oxfmt formatting check
  3. npm run lint — oxlint
  4. npm run typecheck — TypeScript type checking
  5. npm 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.

Bonk (AI code review)

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.

Boundaries

Always:

  • Run npm run check before 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 globalOutbound network restrictions
  • Force push to main

Keeping AGENTS.md updated

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