Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## 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.
`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 two API 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.

The small public surface also includes documentation search (`docs`) and authenticated profile discovery (`get_profile`).

**Production URL:** `mcp.cloudflare.com`

Expand All @@ -24,6 +26,7 @@ cloudflare-mcp/
│ ├── executor.ts # Code executor (Worker Loader API)
│ ├── spec-processor.ts # OpenAPI spec fetching & $ref resolution
│ ├── truncate.ts # Response truncation (~6K token limit)
│ ├── tools/profile.ts # Stable authenticated profile discovery in both tool modes
│ ├── metrics.ts # Analytics Engine metrics (auth_user/tool_call)
│ ├── auth/
│ │ ├── types.ts # Auth props schemas (Zod discriminated union)
Expand Down Expand Up @@ -108,13 +111,18 @@ The core innovation: instead of 2,500 MCP tools (~244K tokens), two tools handle
1. **`search` tool** — Agents write JavaScript to query the pre-resolved OpenAPI spec (all `$ref`s inlined). Runs in an isolated worker with no network access.
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.

`get_profile` follows [OpenAI's profile-tool guidance](https://developers.openai.com/plugins/build/auth#implement-and-declare-your-profile-tool). It accepts no arguments and resolves exactly one user/account identity from validated request credentials. Both modes publish `_meta["openai/profile"]: true` and `_meta.securitySchemes` (the OpenAI compatibility field supported by SDK v2), declaring the existing identity scopes without `offline_access`. Clients must not cache profile results or reuse them across connections; static tool metadata remains independent of the returned identity.

Profile ID derivation is a permanent compatibility contract: SHA-256 of the UTF-8, compact JSON tuple `["cloudflare-profile-v1", "user" | "account", cloudflareSubjectId]`, returned as lowercase hex. Never bump the prefix or change the namespace/encoding/hash for existing profiles. Golden fixtures in `tests/profile-worker.test.ts` pin both namespaces. The contract relies on Cloudflare subject IDs being immutable and never reassigned; recreated subjects with new IDs must remain distinct even when labels match. Future provider identity changes must preserve existing profile IDs.

### 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 handler lets the SDK send the acknowledgment with an empty honored filter and then closes the per-request handler. That ends the subscription gracefully with a `complete` result rather than an error, and no SSE stream stays open.
- 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.
- Authenticated MCP responses include `Cache-Control: no-store, no-transform`, including JSON/SSE results and errors in both tool modes. This protects credential-specific profile and API data without parsing request bodies to select a cache policy.

### Worker Loader API

Expand Down
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Create a [Cloudflare API token](https://dash.cloudflare.com/profile/api-tokens)

### Disable Code Mode

If your MCP client already uses code mode, or you're composing this server with another server that uses code mode, you can disable it with the `?codemode=false` query parameter. This registers an individual tool for each of the ~2,500 Cloudflare API endpoints instead of the code mode API tools. The `docs` tool remains available in both modes.
If your MCP client already uses code mode, or you're composing this server with another server that uses code mode, you can disable it with the `?codemode=false` query parameter. This registers an individual tool for each of the ~2,500 Cloudflare API endpoints instead of the code mode API tools. The `docs` and authenticated `get_profile` tools remain available in both modes.

```
https://mcp.cloudflare.com/mcp?codemode=false
Expand Down Expand Up @@ -86,6 +86,14 @@ https://mcp.cloudflare.com/mcp?codemode=false&truncateToolResult=false

> **Note:** Without the cap, a broad query can return megabytes. Only turn it off when your client bounds what reaches the model.

### Authenticated Profile

`get_profile` is an authenticated, read-only discovery tool that follows [OpenAI's profile-tool guidance](https://developers.openai.com/plugins/build/auth#implement-and-declare-your-profile-tool). It takes no arguments and returns the user or account represented by the current request's credentials, with a stable opaque `id` and available display metadata. User-owned credentials identify the Cloudflare user; account-owned credentials identify the Cloudflare account. Both tool modes publish `openai/profile` and OAuth `securitySchemes` in `_meta`, using OpenAI's documented compatibility field supported by the MCP SDK.

Profile results are credential-specific: **do not cache responses** or reuse them for another connection. Call `get_profile` again when resolving the current connection's profile. Authenticated MCP responses include `Cache-Control: no-store, no-transform`. The tool definition contains no caller identity and may still be cached independently of its results. This cache policy is an additional server protection, not a requirement of OpenAI's profile-tool contract.

The profile ID stays the same across token refresh, reconnection, scope upgrades, and display changes. Its derivation is permanent: SHA-256 of the UTF-8 JSON tuple `["cloudflare-profile-v1", "user" | "account", cloudflareSubjectId]`, serialized without spaces and returned as lowercase hexadecimal. It relies on immutable Cloudflare subject IDs that are never reassigned. A recreated user or account with a new subject ID gets a different profile ID, even when its display label matches. Future integration changes must preserve existing IDs; changing the prefix, namespace, serialization, or hash would break connection recognition.

## The Problem

The Cloudflare OpenAPI spec is **2 million tokens**. Even with native MCP tools using minimal schemas, it's still **~244k tokens**. Traditional MCP servers that expose every endpoint as a tool leak this entire context to the main agent.
Expand All @@ -96,11 +104,12 @@ This server solves the problem by using **code execution** in a [Code Mode](http

Agent writes code to search the spec and execute API calls. It can also search Cloudflare's developer documentation directly.

| Tool | Description |
| --------- | ----------------------------------------------------------------------------- |
| `docs` | Search Cloudflare developer documentation |
| `search` | Write JavaScript to query `spec.paths` and find endpoints |
| `execute` | Write JavaScript to call `cloudflare.request()` with the discovered endpoints |
| Tool | Description |
| ------------- | ----------------------------------------------------------------------------- |
| `docs` | Search Cloudflare developer documentation |
| `search` | Write JavaScript to query `spec.paths` and find endpoints |
| `execute` | Write JavaScript to call `cloudflare.request()` with the discovered endpoints |
| `get_profile` | Return the stable identity represented by the authenticated credentials |

```
Agent MCP Server
Expand Down
14 changes: 9 additions & 5 deletions src/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,16 @@ function corsHeaders(request: Request): Headers | undefined {
return headers
}

function withCors(response: Response, request: Request): Response {
function withMcpResponseHeaders(response: Response, request: Request): Response {
const cors = corsHeaders(request)
if (!cors) return response

const headers = new Headers(response.headers)
for (const [name, value] of cors) headers.set(name, value)
// Tool results can contain authenticated identity and API data. Protect all
// responses without parsing request bodies or trusting routing headers.
// Retain the SDK's no-transform protection for SSE streams.
headers.set('Cache-Control', 'no-store, no-transform')
if (cors) {
for (const [name, value] of cors) headers.set(name, value)
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
Expand Down Expand Up @@ -121,7 +125,7 @@ export async function handleAuthenticatedMcpRequest(
await handler.close()
}

return withCors(response, request)
return withMcpResponseHeaders(response, request)
}

/** ExportedHandler adapter required by workers-oauth-provider 0.8.x. */
Expand Down
3 changes: 3 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { registerDocsTool } from './tools/docs-search'
import { registerNonCodemodeTools } from './tools/non-codemode'
import { registerSearchTool } from './tools/search'
import { registerExecuteTool } from './tools/execute'
import { registerProfileTool } from './tools/profile'
import { attachMetrics } from './metrics'
import { SERVER_INFO } from './constants'
import { stringifyResponse, truncateResponse } from './truncate'
Expand All @@ -13,6 +14,7 @@ export interface ServerOptions {
/**
* Register the Code Mode tools (`docs`, `search`, `execute`). When `false`,
* register one tool per API endpoint instead. Defaults to `true`.
* Both modes include authenticated `get_profile` discovery.
*/
readonly codemode?: boolean
/**
Expand Down Expand Up @@ -51,6 +53,7 @@ export async function createServer(
registerDocsTool(server)
await registerSearchTool(server, formatResult)
registerExecuteTool(server, props, formatResult)
registerProfileTool(server, props)

return server
}
6 changes: 5 additions & 1 deletion src/tools/non-codemode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../auth/account-access'
import { recordToolCall } from '../metrics'
import { DOCS_TOOL, runDocsTool } from './docs-search'
import { PROFILE_TOOL, ProfileInputSchema, runProfileTool } from './profile'
import { zodInputSchemaFromJson, type NonCodemodeTool } from '../openapi'
import type { AuthProps } from '../auth/types'

Expand All @@ -32,7 +33,7 @@ export async function registerNonCodemodeTools(
const toolsByName = await getNonCodemodeToolMap()

server.server.setRequestHandler('tools/list', () => ({
tools: [DOCS_TOOL, ...tools.map((tool) => toWireTool(toolForAccountAccess(tool)))]
tools: [DOCS_TOOL, PROFILE_TOOL, ...tools.map((tool) => toWireTool(toolForAccountAccess(tool)))]
}))

server.server.setRequestHandler('tools/call', async (request) => {
Expand All @@ -45,6 +46,9 @@ export async function registerNonCodemodeTools(
result = parsed.success
? await runDocsTool(parsed.data.query)
: validationError(name, parsed.error)
} else if (name === PROFILE_TOOL.name) {
const parsed = ProfileInputSchema.safeParse(request.params.arguments ?? {})
result = parsed.success ? await runProfileTool(props) : validationError(name, parsed.error)
} else {
const baseTool = toolsByName.get(name)
if (!baseTool) {
Expand Down
99 changes: 99 additions & 0 deletions src/tools/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { z } from 'zod'
import type { CallToolResult, McpServer, Tool } from '@modelcontextprotocol/server'
import type { AuthProps } from '../auth/types'
import { REQUIRED_SCOPES } from '../auth/scopes'

// These are the resource's existing identity scopes. offline_access enables
// refresh tokens; it is not a permission needed to call the profile tool.
const PROFILE_SECURITY_SCHEMES = [
{ type: 'oauth2', scopes: REQUIRED_SCOPES.filter((scope) => scope !== 'offline_access') }
]

export const ProfileInputSchema = z.strictObject({})
const ProfileSchema = z.strictObject({
id: z
.string()
.min(1)
.regex(/\S/)
.describe(
'Opaque profile identifier, unique within this app, stable across refresh, reconnection, scope upgrades and display changes, and never reassigned to another profile.'
),
name: z.string().max(256).optional().describe('Display name for the authenticated profile.'),
email: z.string().max(320).optional().describe('Email address for display, not profile identity.')
})

/** Public metadata is identical for all credentials and both tool modes. */
export const PROFILE_TOOL: Tool = {
name: 'get_profile',
title: 'Cloudflare Profile',
description:
"Return the profile represented by this request's authenticated credentials. Its opaque ID is stable across token refresh, reconnection, scope upgrades and display changes. Results are credential-specific: do not cache responses; call this tool again to resolve the current connection's profile.",
inputSchema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {},
additionalProperties: false
},
outputSchema: z.toJSONSchema(ProfileSchema),
annotations: {
title: 'Cloudflare Profile',
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false
},
// SDK v2 does not expose OpenAI's top-level securitySchemes extension.
// Use OpenAI's documented compatibility field, preserved in both tool modes.
_meta: { 'openai/profile': true, securitySchemes: PROFILE_SECURITY_SCHEMES }
}

/** Use validated identity, never a caller-supplied selector or a changing grant/token. */
export async function runProfileTool(
props: AuthProps
): Promise<CallToolResult & { structuredContent?: z.infer<typeof ProfileSchema> }> {
const subject = props.type === 'user_token' ? props.user.id : props.account.id
if (!subject.trim()) {
return { content: [{ type: 'text', text: 'Profile identity unavailable.' }], isError: true }
}

// User OAuth and direct user credentials represent one profile. Account-owned
// credentials use a disjoint namespace, even if a provider ID happens to match.
// Hashing the immutable namespace/ID keeps internal relationships out of the ID.
// This encoding is a permanent identity contract, not a version to bump:
// preserve the namespace, tuple serialization, SHA-256 and lowercase hex.
// It relies on Cloudflare subject IDs remaining immutable and never reused;
// a recreated user/account must have a new subject ID, even with the same label.
const namespace = props.type === 'user_token' ? 'user' : 'account'
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(JSON.stringify(['cloudflare-profile-v1', namespace, subject]))
)
const id = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(
''
)
const profile = ProfileSchema.parse({
id,
...(props.type === 'user_token'
? { email: props.user.email.slice(0, 320) }
: { name: props.account.name.slice(0, 256) })
})
return {
content: [{ type: 'text', text: JSON.stringify(profile) }],
structuredContent: profile,
isError: false
}
}

export function registerProfileTool(server: McpServer, props: AuthProps): void {
server.registerTool(
PROFILE_TOOL.name,
{
title: PROFILE_TOOL.title,
description: PROFILE_TOOL.description,
inputSchema: ProfileInputSchema,
outputSchema: ProfileSchema,
annotations: PROFILE_TOOL.annotations,
_meta: PROFILE_TOOL._meta
},
() => runProfileTool(props)
)
}
2 changes: 1 addition & 1 deletion tests/auth/cimd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ describe('Client ID Metadata Documents', () => {
)
const mcpBody = await parseMcpResult(mcpResponse)
expect(mcpResponse.status).toBe(200)
expect(mcpBody.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute'])
expect(mcpBody.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute', 'get_profile'])

expect(metadataFetches).toBeGreaterThan(0)
expect((await env.OAUTH_KV.list({ prefix: 'client:' })).keys).toHaveLength(0)
Expand Down
Loading
Loading