Skip to content
Open
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ The core innovation: instead of 2,500 MCP tools (~244K tokens), two tools handle
- 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. The handler sets the SDK's `maxSubscriptions: 0` because this server publishes no change notifications; `subscriptions/listen` is rejected immediately instead of opening a long-lived SSE stream.
- 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.
- On MCP `2026-07-28`, `tools/list` results carry `cacheScope: 'public'` and a one-hour `ttlMs`, so clients and shared gateways may serve one caller's tool list to every user. Tool metadata must never depend on the caller: no account IDs or names, and no branching on token shape. The "tool metadata is identical for every user" tests in `tests/non-codemode.test.ts` enforce this. ChatGPT goes further and freezes the tool list it scanned for a published app until a new version is reviewed.
- Clients key cached tool lists by server `name@version`, so the `?codemode=false` surface reports its own name, `cloudflare-api-endpoints`. Metrics report `cloudflare-api` for both surfaces.

### Worker Loader API

Expand Down
18 changes: 15 additions & 3 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,25 @@
export type ServerInfo = { name: string; version: string }

/**
* Shared MCP server identity (name + version), consumed across layers: the MCP
* server handshake (`new McpServer(SERVER_INFO)`) and the metrics tracker
* (reported as blob1/blob2 on every datapoint, in both the request path and the
* Shared MCP server identity (name + version), consumed across layers: the Code
* Mode server handshake and the metrics tracker (reported as blob1/blob2 on
* every datapoint, for both tool surfaces, in both the request path and the
* OAuth handler).
*/
export const SERVER_INFO: ServerInfo = { name: 'cloudflare-api', version: '0.1.0' }

/**
* MCP server identity of the `?codemode=false` surface, which lists one tool per
* API endpoint instead of the Code Mode tools. Clients key cached tool lists by
* server `name@version`, as the MCP TypeScript SDK client does, so this surface
* needs its own name or a shared cache could serve it the Code Mode list.
* Metrics keep reporting `SERVER_INFO`.
*/
export const ENDPOINT_TOOLS_SERVER_INFO: ServerInfo = {
name: 'cloudflare-api-endpoints',
version: SERVER_INFO.version
}

/** User-Agent header sent on all outbound requests to Cloudflare APIs. */
export const USER_AGENT = 'cloudflare-mcp'

Expand Down
16 changes: 14 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ import { registerNonCodemodeTools } from './tools/non-codemode'
import { registerSearchTool } from './tools/search'
import { registerExecuteTool } from './tools/execute'
import { attachMetrics } from './metrics'
import { SERVER_INFO } from './constants'
import { ENDPOINT_TOOLS_SERVER_INFO, SERVER_INFO } from './constants'
import { stringifyResponse, truncateResponse } from './truncate'
import type { AuthProps } from './auth/types'

/**
* How long a client may reuse a `tools/list` result. Tool lists change only on
* deploy or the daily spec refresh, and a warm isolate already serves the spec
* artifacts for up to an hour.
*/
const TOOL_LIST_TTL_MS = 60 * 60 * 1000

/** Per-request options the client picks through the MCP URL query string. */
export interface ServerOptions {
/**
Expand All @@ -33,7 +40,12 @@ export async function createServer(
props: AuthProps,
{ codemode = true, truncateToolResult = true }: ServerOptions = {}
): Promise<McpServer> {
const server = new McpServer(SERVER_INFO)
// Tool metadata is the same for every caller, and the "tool metadata is
// identical for every user" tests keep it that way. So clients and shared
// gateways may serve one caller's tool list to everyone until the TTL runs out.
const server = new McpServer(codemode ? SERVER_INFO : ENDPOINT_TOOLS_SERVER_INFO, {
cacheHints: { 'tools/list': { ttlMs: TOOL_LIST_TTL_MS, cacheScope: 'public' } }
})
const formatResult = truncateToolResult ? truncateResponse : stringifyResponse

if (!codemode) {
Expand Down
102 changes: 84 additions & 18 deletions tests/mcp-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { env, exports } from 'cloudflare:workers'
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
import {
Client,
InMemoryResponseCacheStore,
StreamableHTTPClientTransport
} from '@modelcontextprotocol/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mockIdentityProbe } from './helpers/cloudflare-api'
import { clearKv } from './helpers/kv'
Expand All @@ -10,6 +14,25 @@ const API_TOKEN = 'modern-client-token'
const ACCOUNT_ID = '00000000000000000000000000000001'
const SPEC_PATH = '/accounts/{account_id}/workers/scripts'

type RecordedRequest = { method: string; rpcMethod?: string }

/** A client `fetch` that sends each request to the worker as `token` and records it. */
function workerFetchAs(token: string, requests: RecordedRequest[]) {
return async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const body =
request.method === 'POST'
? ((await request.clone().json()) as { method?: string })
: undefined
const headers = new Headers(request.headers)
headers.set('Host', MCP_HOST)
headers.set('Authorization', `Bearer ${token}`)
const response = await exports.default.fetch(new Request(request, { headers }))
requests.push({ method: request.method, rpcMethod: body?.method })
return response
}
}

beforeEach(async () => {
await seedSpec({
[SPEC_PATH]: {
Expand All @@ -31,28 +54,13 @@ afterEach(async () => {

describe('automatic protocol negotiation', () => {
it('selects modern MCP, then lists and calls tools without a session', async () => {
const requests: Array<{ method: string; rpcMethod?: string }> = []
const workerFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const body =
request.method === 'POST'
? ((await request.clone().json()) as { method?: string })
: undefined
const headers = new Headers(request.headers)
headers.set('Host', MCP_HOST)
headers.set('Authorization', `Bearer ${API_TOKEN}`)
const authenticated = new Request(request, { headers })
const response = await exports.default.fetch(authenticated)
requests.push({ method: request.method, rpcMethod: body?.method })
return response
}

const requests: RecordedRequest[] = []
const client = new Client(
{ name: 'cloudflare-mcp-modern-client-test', version: '1.0.0' },
{ versionNegotiation: { mode: 'auto' } }
)
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
fetch: workerFetch
fetch: workerFetchAs(API_TOKEN, requests)
})

try {
Expand Down Expand Up @@ -84,3 +92,61 @@ describe('automatic protocol negotiation', () => {
}
})
})

describe('tool list caching', () => {
it('shares one tool list across users without mixing the two tool surfaces', async () => {
// One response cache backing several principals, as a gateway would run it.
const store = new InMemoryResponseCacheStore()
const clients: Client[] = []

async function connect(user: string, url: string, requests: RecordedRequest[]) {
const client = new Client(
{ name: 'cloudflare-mcp-cache-test', version: '1.0.0' },
{ versionNegotiation: { mode: 'auto' }, responseCacheStore: store, cachePartition: user }
)
clients.push(client)
await client.connect(
new StreamableHTTPClientTransport(new URL(url), {
fetch: workerFetchAs(`${user}-token`, requests)
})
)
return client
}

const aliceRequests: RecordedRequest[] = []
const bobRequests: RecordedRequest[] = []
const endpointRequests: RecordedRequest[] = []

try {
const alice = await connect('alice', MCP_URL, aliceRequests)
const bob = await connect('bob', MCP_URL, bobRequests)
const endpoints = await connect('bob', `${MCP_URL}?codemode=false`, endpointRequests)

const codeModeTools = ['docs', 'search', 'execute']
expect((await alice.listTools()).tools.map((tool) => tool.name)).toEqual(codeModeTools)
expect((await bob.listTools()).tools.map((tool) => tool.name)).toEqual(codeModeTools)
expect((await endpoints.listTools()).tools.map((tool) => tool.name)).toEqual([
'docs',
'get_accounts_workers_scripts'
])

// Bob reused Alice's list. The endpoint tools are a different server, so
// that client fetched its own.
expect(aliceRequests.map((request) => request.rpcMethod)).toEqual([
'server/discover',
'tools/list'
])
expect(bobRequests.map((request) => request.rpcMethod)).toEqual(['server/discover'])
expect(endpointRequests.map((request) => request.rpcMethod)).toEqual([
'server/discover',
'tools/list'
])
expect(endpoints.getServerVersion()).toEqual({
name: 'cloudflare-api-endpoints',
version: '0.1.0'
})
} finally {
await Promise.all(clients.map((client) => client.close()))
}
})
})
11 changes: 11 additions & 0 deletions tests/mcp-modern.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ describe('MCP 2026-07-28 stateless handler', () => {
expect(body.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute'])
})

it.each([
['Code Mode', MCP_URL],
['non-Code-Mode', `${MCP_URL}?codemode=false`]
])('marks the %s tool list public for an hour', async (_surface, url) => {
const body = await parseMcpResult(
await exports.default.fetch(modernMcpRequest(API_TOKEN, 'tools/list', {}, { url }))
)

expect(body.result).toMatchObject({ ttlMs: 60 * 60 * 1000, cacheScope: 'public' })
})

it('serves a modern Code Mode tools/call', async () => {
server.use(
http.get(`${API_BASE}/accounts/${ACCOUNT_ID}/tokens/verify`, () =>
Expand Down
Loading