From 0634ded69784612f91409d4c9b7d9a93ddf37276 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 28 Sep 2026 22:55:41 +0100 Subject: [PATCH] feat(mcp): let clients cache tools/list for an hour and share it across users MCP 2026-07-28 requires ttlMs and cacheScope on tools/list. We sent the SDK default, ttlMs: 0 and private, so no client ever reused a list. Since #235 every caller gets the same tool metadata, which is what public means, so tools/list now returns ttlMs: 3600000 and cacheScope: 'public' on both tool surfaces. An hour matches how long a warm isolate keeps the spec artifacts; the lists change only on deploy or the daily spec refresh. Clients key cached lists by server name@version, as the TypeScript SDK client does, and both surfaces reported cloudflare-api. With the hint alone, a client sharing one cache store across /mcp and /mcp?codemode=false served the Code Mode tools to the endpoint surface. The endpoint surface now reports cloudflare-api-endpoints. Metrics still report cloudflare-api for both. --- AGENTS.md | 2 + src/constants.ts | 18 +++++-- src/server.ts | 16 +++++- tests/mcp-client.test.ts | 102 ++++++++++++++++++++++++++++++++------- tests/mcp-modern.test.ts | 11 +++++ 5 files changed, 126 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1c001ee6..8694d529 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/src/constants.ts b/src/constants.ts index cdc79cc9..6ea53f1c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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' diff --git a/src/server.ts b/src/server.ts index 47329b63..1146185c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { /** @@ -33,7 +40,12 @@ export async function createServer( props: AuthProps, { codemode = true, truncateToolResult = true }: ServerOptions = {} ): Promise { - 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) { diff --git a/tests/mcp-client.test.ts b/tests/mcp-client.test.ts index 67901d8b..5b8c1519 100644 --- a/tests/mcp-client.test.ts +++ b/tests/mcp-client.test.ts @@ -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' @@ -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]: { @@ -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 { @@ -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())) + } + }) +}) diff --git a/tests/mcp-modern.test.ts b/tests/mcp-modern.test.ts index 583af8d8..cf4cf6ae 100644 --- a/tests/mcp-modern.test.ts +++ b/tests/mcp-modern.test.ts @@ -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`, () =>