diff --git a/AGENTS.md b/AGENTS.md index 7c351b4..47d227c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` @@ -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) @@ -108,6 +111,10 @@ 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. @@ -115,6 +122,7 @@ 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. 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 diff --git a/README.md b/README.md index 1d24f6e..644b091 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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 diff --git a/src/mcp-handler.ts b/src/mcp-handler.ts index 82cb76b..7ec4f8b 100644 --- a/src/mcp-handler.ts +++ b/src/mcp-handler.ts @@ -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, @@ -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. */ diff --git a/src/server.ts b/src/server.ts index 14d2959..6c0b7d0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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' @@ -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 /** @@ -51,6 +53,7 @@ export async function createServer( registerDocsTool(server) await registerSearchTool(server, formatResult) registerExecuteTool(server, props, formatResult) + registerProfileTool(server, props) return server } diff --git a/src/tools/non-codemode.ts b/src/tools/non-codemode.ts index 20875c3..66c4e18 100644 --- a/src/tools/non-codemode.ts +++ b/src/tools/non-codemode.ts @@ -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' @@ -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) => { @@ -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) { diff --git a/src/tools/profile.ts b/src/tools/profile.ts new file mode 100644 index 0000000..9a2605c --- /dev/null +++ b/src/tools/profile.ts @@ -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 }> { + 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) + ) +} diff --git a/tests/auth/cimd.test.ts b/tests/auth/cimd.test.ts index a794105..e6c3902 100644 --- a/tests/auth/cimd.test.ts +++ b/tests/auth/cimd.test.ts @@ -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) diff --git a/tests/auth/oauth-routes.test.ts b/tests/auth/oauth-routes.test.ts index 996a2c4..a6db1ca 100644 --- a/tests/auth/oauth-routes.test.ts +++ b/tests/auth/oauth-routes.test.ts @@ -70,13 +70,15 @@ function consentHandle(html: string): string { return handle! } -async function beginAuthorization(options: { state?: string; scopes?: string } = {}): Promise<{ +async function beginAuthorization( + options: { state?: string; scopes?: string; clientId?: string } = {} +): Promise<{ clientId: string state: string sessionCookie: string location: string }> { - const clientId = await registerClient() + const clientId = options.clientId ?? (await registerClient()) const authRes = await exports.default.fetch( new Request( authorizeUrl({ @@ -94,15 +96,14 @@ async function beginAuthorization(options: { state?: string; scopes?: string } = const html = await authRes.text() const consentCookie = cookiesFrom(authRes) const handle = consentHandle(html) + const consent = new URLSearchParams({ handle }) + for (const scope of (options.scopes ?? 'user:read').split(/\s+/)) consent.append('scopes', scope) const postRes = await exports.default.fetch( new Request(`${MCP_ORIGIN}/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: consentCookie }, - body: new URLSearchParams({ - handle, - scopes: options.scopes ?? 'user:read' - }).toString(), + body: consent.toString(), redirect: 'manual' }) ) @@ -877,7 +878,12 @@ describe('GET /oauth/callback', () => { expect(probes.userCalls()).toBe(1) expect(probes.accountCalls()).toBe(1) expect(mcpBody.result?.resultType).toBe('complete') - 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((await env.OAUTH_KV.list({ prefix: 'client:' })).keys).toHaveLength(1) }) @@ -1025,3 +1031,141 @@ describe('GET /oauth/callback', () => { expect(writtenEvents(metricsSpy)).toContain('auth_user') }) }) + +/** Grant IDs rotate independently of the stable profile and the client's saved row. */ +describe('profile and grant lifecycle across reconnection', () => { + async function authorize(clientId?: string, scopes = 'user:read account:read offline_access') { + const transaction = await beginAuthorization({ clientId, scopes }) + expect(new URL(transaction.location).searchParams.get('scope')?.split(' ')).toEqual( + expect.arrayContaining(scopes.split(' ')) + ) + useCloudflareAuthSuccess(scopes) + const callback = await exports.default.fetch( + new Request( + `${MCP_ORIGIN}/oauth/callback?code=authcode&state=${encodeURIComponent(transaction.state)}`, + { headers: { Cookie: transaction.sessionCookie }, redirect: 'manual' } + ) + ) + expect(callback.status).toBe(302) + return { + clientId: transaction.clientId, + code: new URL(callback.headers.get('location')!).searchParams.get('code')! + } + } + async function exchange( + transaction: { clientId: string; code: string }, + verifier = DOWNSTREAM_CODE_VERIFIER + ) { + return exports.default.fetch( + new Request(`${MCP_ORIGIN}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: transaction.code, + client_id: transaction.clientId, + redirect_uri: REDIRECT_URI, + code_verifier: verifier + }).toString() + }) + ) + } + async function profile(accessToken: string) { + const response = await exports.default.fetch( + modernMcpRequest(accessToken, 'tools/call', { name: 'get_profile', arguments: {} }) + ) + expect(response.status).toBe(200) + const body = await parseMcpResult(response) + expect(body.result?.isError).toBe(false) + return JSON.parse(body.result!.content![0].text) as { id: string; email?: string } + } + async function grantNames() { + return (await env.OAUTH_KV.list({ prefix: 'grant:' })).keys.map((key) => key.name) + } + + it('replaces a same-client grant before exchange, preserves profile after an upgrade and rejects code replay', async () => { + const firstAuth = await authorize() + const firstResponse = await exchange(firstAuth) + expect(firstResponse.status).toBe(200) + const first = (await firstResponse.json()) as { access_token: string } + const firstProfile = await profile(first.access_token) + const oldGrants = await grantNames() + expect(oldGrants).toHaveLength(1) + + const upgradedAuth = await authorize( + firstAuth.clientId, + 'user:read account:read offline_access access.write' + ) + const pendingGrants = await grantNames() + expect(await env.OAUTH_KV.get(pendingGrants[0], 'json')).toMatchObject({ + scope: expect.arrayContaining(['access.write']) + }) + expect(pendingGrants).toHaveLength(1) + expect(pendingGrants[0]).not.toBe(oldGrants[0]) + const upgradedResponse = await exchange(upgradedAuth) + expect(upgradedResponse.status).toBe(200) + const upgraded = (await upgradedResponse.json()) as { + access_token: string + refresh_token: string + scope: string + } + expect(upgraded.scope.split(' ')).toContain('access.write') + expect(await profile(upgraded.access_token)).toEqual(firstProfile) + + const refreshedResponse = await exports.default.fetch( + new Request(`${MCP_ORIGIN}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: upgraded.refresh_token, + client_id: upgradedAuth.clientId + }).toString() + }) + ) + expect(refreshedResponse.status).toBe(200) + const refreshed = (await refreshedResponse.json()) as { access_token: string } + expect(await profile(refreshed.access_token)).toEqual(firstProfile) + + // The same validated user from a direct Cloudflare credential has the same profile. + expect(await profile('cfut_direct-profile-token')).toEqual(firstProfile) + + // The provider treats a consumed-code replay as compromise and revokes the new grant. + // Client callback idempotency must prevent a second exchange, not merely a second row. + expect((await exchange(upgradedAuth)).status).toBe(400) + expect(await grantNames()).toEqual([]) + }) + + it('retains distinct grants for newly registered clients even when profiles match', async () => { + const firstAuth = await authorize() + const first = (await (await exchange(firstAuth)).json()) as { access_token: string } + const secondAuth = await authorize() + const second = (await (await exchange(secondAuth)).json()) as { access_token: string } + expect(secondAuth.clientId).not.toBe(firstAuth.clientId) + expect(await grantNames()).toHaveLength(2) + expect(await profile(second.access_token)).toEqual(await profile(first.access_token)) + }) + + it('exposes the stale-grant risk when a replacement authorization fails token exchange', async () => { + const originalAuth = await authorize() + const original = (await (await exchange(originalAuth)).json()) as { refresh_token: string } + const oldGrants = await grantNames() + const replacement = await authorize(originalAuth.clientId) + expect(await grantNames()).not.toEqual(oldGrants) + expect((await exchange(replacement, 'wrong-verifier')).status).toBe(400) + const staleRefresh = await exports.default.fetch( + new Request(`${MCP_ORIGIN}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: original.refresh_token, + client_id: originalAuth.clientId + }).toString() + }) + ) + expect(staleRefresh.status).toBe(400) + expect(await staleRefresh.json()).toMatchObject({ error: 'invalid_grant' }) + expect(await grantNames()).toHaveLength(1) + }) +}) diff --git a/tests/auth/token-compatibility.test.ts b/tests/auth/token-compatibility.test.ts index 9fb1f57..b2bbbfc 100644 --- a/tests/auth/token-compatibility.test.ts +++ b/tests/auth/token-compatibility.test.ts @@ -197,7 +197,7 @@ describe.each(LEGACY_DEPLOYMENTS)('grants issued by workers-oauth-provider $vers const upstreamRefreshes = useUpstreamRefresh() // The access token issued before the upgrade is accepted as it is. - expect(await listTools(legacy.access_token)).toEqual({ status: 200, tools: ['docs', 'search', 'execute'] }) + expect(await listTools(legacy.access_token)).toEqual({ status: 200, tools: ['docs', 'search', 'execute', 'get_profile'] }) // The refresh token is too, and the upstream Cloudflare grant rotates with it. const refreshed = await refresh(legacy.clientId, legacy.refresh_token) @@ -205,7 +205,7 @@ describe.each(LEGACY_DEPLOYMENTS)('grants issued by workers-oauth-provider $vers const tokens = (await refreshed.json()) as { access_token: string; refresh_token: string; resource: string } expect(tokens.resource).toBe(MCP_RESOURCE) expect(upstreamRefreshes()).toEqual(['upstream-refresh-1']) - expect(await listTools(tokens.access_token)).toEqual({ status: 200, tools: ['docs', 'search', 'execute'] }) + expect(await listTools(tokens.access_token)).toEqual({ status: 200, tools: ['docs', 'search', 'execute', 'get_profile'] }) // The grant is rewritten in the current format (with KV key metadata) and keeps its resource. const [grant] = (await env.OAUTH_KV.list({ prefix: 'grant:' })).keys diff --git a/tests/mcp-client.test.ts b/tests/mcp-client.test.ts index 67901d8..e998e6e 100644 --- a/tests/mcp-client.test.ts +++ b/tests/mcp-client.test.ts @@ -64,7 +64,7 @@ describe('automatic protocol negotiation', () => { expect(client.getDiscoverResult()?.supportedVersions).toEqual([MODERN_MCP_VERSION]) const listed = await client.listTools() - expect(listed.tools.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute']) + expect(listed.tools.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute', 'get_profile']) const called = await client.callTool({ name: 'search', diff --git a/tests/mcp-modern.test.ts b/tests/mcp-modern.test.ts index 44670af..f7981b6 100644 --- a/tests/mcp-modern.test.ts +++ b/tests/mcp-modern.test.ts @@ -156,7 +156,7 @@ describe('MCP 2026-07-28 stateless handler', () => { expect(response.status).toBe(200) expect(body.result?.resultType).toBe('complete') - expect(body.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute']) + expect(body.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute', 'get_profile']) }) it('serves a modern Code Mode tools/call', async () => { @@ -271,9 +271,10 @@ describe('MCP 2026-07-28 stateless handler', () => { expect(codemodeResponse.status).toBe(200) expect(endpointResponse.status).toBe(200) - expect(codemode.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute']) + expect(codemode.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute', 'get_profile']) expect(endpoints.result?.tools?.map((tool) => tool.name)).toEqual([ 'docs', + 'get_profile', 'get_accounts_workers_scripts' ]) expect(codemodeResponse.headers.get('mcp-session-id')).toBeNull() @@ -307,7 +308,7 @@ describe('MCP 2026-07-28 stateless handler', () => { expect(response.status).toBe(200) expect(response.headers.get('content-type')).toContain('text/event-stream') expect(response.headers.get('mcp-session-id')).toBeNull() - expect(body.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute']) + expect(body.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute', 'get_profile']) }) it.each(['GET', 'DELETE'])('rejects session-only %s requests', async (method) => { diff --git a/tests/non-codemode.test.ts b/tests/non-codemode.test.ts index c06fb87..cb560b9 100644 --- a/tests/non-codemode.test.ts +++ b/tests/non-codemode.test.ts @@ -240,7 +240,7 @@ describe('createServer with codemode=false', () => { expect(Object.keys((server as any)._registeredTools)).toEqual([]) const tools = await listTools(server) - expect(tools).toHaveLength(3_001) // docs + 3,000 endpoint tools + expect(tools).toHaveLength(3_002) // docs + profile + 3,000 endpoint tools }) it('registers one tool per endpoint when codemode=false', async () => { diff --git a/tests/profile-worker.test.ts b/tests/profile-worker.test.ts new file mode 100644 index 0000000..91005fa --- /dev/null +++ b/tests/profile-worker.test.ts @@ -0,0 +1,269 @@ +import { env, exports } from 'cloudflare:workers' +import { http, HttpResponse } from 'msw' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { runProfileTool } from '../src/tools/profile' +import { + API_BASE, + cfAccountsSuccess, + cfError, + cfSuccess, + mockIdentityProbe +} from './helpers/cloudflare-api' +import { clearKv } from './helpers/kv' +import { MCP_HOST, MCP_URL, modernMcpRequest, parseMcpResult } from './helpers/mcp' +import { clearSpec, seedSpec } from './helpers/spec' +import { server } from './setup/msw' + +const SUBJECT_ID = '00000000000000000000000000000001' +// Permanent fixtures: changing the encoding must not silently rename profiles. +const USER_PROFILE_ID = '0fb8feb386a6c952d79a83e88b0fac8435bdf44ea4bdeaf675d9f5d6058c2455' +const ACCOUNT_PROFILE_ID = '85e1035ef0f71e5a7ec8fabced8bb5b51e6d35312c9a3c625bad1078305001e9' +const CASES = [ + { version: '2026-07-28', codemode: true }, + { version: '2026-07-28', codemode: false }, + { version: '2025-06-18', codemode: true }, + { version: '2025-06-18', codemode: false } +] + +function request( + token: string, + method: string, + args: Record, + version: string, + codemode: boolean +): Request { + const url = codemode ? MCP_URL : `${MCP_URL}?codemode=false` + const params = method === 'tools/call' ? { name: 'get_profile', arguments: args } : {} + if (version === '2026-07-28') return modernMcpRequest(token, method, params, { url }) + return new Request(url, { + method: 'POST', + headers: { + Host: MCP_HOST, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': version + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) + }) +} + +async function result( + token: string, + method: string, + args: Record = {}, + version = '2026-07-28', + codemode = true +) { + const response = await exports.default.fetch(request(token, method, args, version, codemode)) + expect(response.status).toBe(200) + return (await parseMcpResult(response)).result as { + content?: Array<{ type: string; text: string }> + structuredContent?: { id: string; email?: string; name?: string } + isError?: boolean + tools?: Array> + } +} + +beforeEach(async () => { + await seedSpec({}) + mockIdentityProbe({ user: { id: SUBJECT_ID, email: 'same-label@example.com' }, accounts: [] }) +}) +afterEach(async () => { + await clearKv(env.OAUTH_KV) + await clearSpec() +}) + +describe.each(CASES)( + 'authenticated profile: $version, codemode=$codemode', + ({ version, codemode }) => { + it('publishes the designated read-only profile contract and returns matching JSON/structured content', async () => { + const listed = await result('cfut_profile-token', 'tools/list', {}, version, codemode) + const profileTool = listed.tools?.find((tool) => tool.name === 'get_profile') + expect(profileTool).toMatchObject({ + _meta: { + 'openai/profile': true, + securitySchemes: [{ type: 'oauth2', scopes: ['user:read', 'account:read'] }] + }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + outputSchema: { + type: 'object', + properties: { id: { type: 'string', minLength: 1, pattern: '\\S' } }, + required: ['id'], + additionalProperties: false + } + }) + expect(profileTool?.description).toContain('do not cache responses') + const called = await result('cfut_profile-token', 'tools/call', {}, version, codemode) + expect(called.isError).toBe(false) + expect(called.structuredContent).toEqual({ + id: USER_PROFILE_ID, + email: 'same-label@example.com' + }) + expect(JSON.parse(called.content![0].text)).toEqual(called.structuredContent) + expect(called.content![0].text).not.toContain('cfut_profile-token') + }) + + it.each([ + { label: 'success', args: {}, isError: false }, + { label: 'validation error', args: { user_id: 'someone-else' }, isError: true } + ])( + 'prevents caching of profile $label responses, preserving browser CORS', + async ({ args, isError }) => { + for (const origin of [undefined, `https://${MCP_HOST}`]) { + const req = request('cfut_profile-token', 'tools/call', args, version, codemode) + if (origin) req.headers.set('Origin', origin) + const response = await exports.default.fetch(req) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store, no-transform') + expect(response.headers.get('access-control-allow-origin')).toBe(origin ?? null) + const body = await parseMcpResult(response) + expect(body.result?.isError).toBe(isError) + } + } + ) + + it('rejects caller-supplied profile selectors', async () => { + const called = await result( + 'cfut_profile-token', + 'tools/call', + { user_id: 'someone-else' }, + version, + codemode + ) + expect(called.isError).toBe(true) + expect(called.structuredContent).toBeUndefined() + }) + + it('returns a real auth challenge when credentials are invalid', async () => { + server.use( + http.get(`${API_BASE}/user`, () => + HttpResponse.json(cfError([{ code: 10000, message: 'Authentication error' }]), { + status: 401 + }) + ) + ) + const response = await exports.default.fetch( + request('cfut_invalid-token', 'tools/call', {}, version, codemode) + ) + expect(response.status).toBe(401) + expect(response.headers.get('cache-control')).toContain('no-store') + expect(response.headers.get('www-authenticate')).toContain('invalid_token') + expect(await response.text()).not.toContain('structuredContent') + }) + } +) + +it('keeps public metadata identical across users and tool modes', async () => { + const first = await result('cfut_first', 'tools/list') + mockIdentityProbe({ + user: { id: 'different-user', email: 'different@example.com' }, + accounts: [] + }) + const second = await result('cfut_second', 'tools/list', {}, '2026-07-28', false) + expect(first.tools?.find((tool) => tool.name === 'get_profile')).toEqual( + second.tools?.find((tool) => tool.name === 'get_profile') + ) +}) + +it('keeps user identity stable across credentials, email, account-list and permission changes', async () => { + const first = await result('cfut_original', 'tools/call') + mockIdentityProbe({ + user: { id: SUBJECT_ID, email: 'changed@example.com' }, + accounts: [{ id: 'new-account', name: 'New account' }] + }) + const reconnected = await result('cfoat_new-credential', 'tools/call') + expect(reconnected.structuredContent?.id).toBe(first.structuredContent?.id) + expect(reconnected.structuredContent?.email).toBe('changed@example.com') +}) + +it('isolates concurrent identities, same display labels and account/user namespaces', async () => { + server.use( + http.get(`${API_BASE}/user`, ({ request }) => + HttpResponse.json( + cfSuccess({ + id: + request.headers.get('Authorization') === 'Bearer cfut_other' + ? 'other-user' + : SUBJECT_ID, + email: 'same-label@example.com' + }) + ) + ), + http.get(`${API_BASE}/accounts`, () => + HttpResponse.json(cfAccountsSuccess([{ id: SUBJECT_ID, name: 'same-label@example.com' }])) + ) + ) + const [user, other, account] = await Promise.all([ + result('cfut_user', 'tools/call'), + result('cfut_other', 'tools/call'), + result('cfat_account', 'tools/call', {}, '2026-07-28', false) + ]) + expect( + new Set([ + user.structuredContent?.id, + other.structuredContent?.id, + account.structuredContent?.id + ]).size + ).toBe(3) + expect(account.structuredContent).toEqual({ + id: ACCOUNT_PROFILE_ID, + name: 'same-label@example.com' + }) + expect(account.structuredContent).not.toHaveProperty('email') +}) + +it.each([ + { type: 'user_token' as const, expectedId: USER_PROFILE_ID }, + { type: 'account_token' as const, expectedId: ACCOUNT_PROFILE_ID } +])( + 'preserves the permanent $type ID and distinguishes a recreated subject with the same label', + async ({ type, expectedId }) => { + const propsFor = (subject: string) => + type === 'user_token' + ? { + type, + accessToken: 'unused', + user: { id: subject, email: 'same-label@example.com' }, + accounts: [] + } + : { + type, + accessToken: 'unused', + account: { id: subject, name: 'same-label@example.com' } + } + const original = await runProfileTool(propsFor(SUBJECT_ID)) + const recreated = await runProfileTool(propsFor('00000000000000000000000000000002')) + expect(original.structuredContent?.id).toBe(expectedId) + expect(recreated.structuredContent?.id).not.toBe(expectedId) + expect(original.isError).toBe(false) + expect(recreated.isError).toBe(false) + } +) + +it('fails rather than inventing a profile for missing validated identity', async () => { + const called = await runProfileTool({ + type: 'user_token', + accessToken: 'unused', + user: { id: ' ', email: 'user@example.com' }, + accounts: [] + }) + expect(called.isError).toBe(true) + expect(called.structuredContent).toBeUndefined() +}) + +it('bounds display metadata without changing profile identity', async () => { + const first = await runProfileTool({ + type: 'account_token', + accessToken: 'unused', + account: { id: SUBJECT_ID, name: 'a'.repeat(20_000) } + }) + const renamed = await runProfileTool({ + type: 'account_token', + accessToken: 'rotated', + account: { id: SUBJECT_ID, name: 'Renamed' } + }) + expect(first.structuredContent?.name).toHaveLength(256) + expect(first.structuredContent?.id).toBe(renamed.structuredContent?.id) +})