From 29a637ba0e13278b4b1c959770c991a7a40205bf Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Tue, 6 Oct 2026 16:37:36 -0500 Subject: [PATCH 1/6] Add stable authenticated Cloudflare profile discovery --- AGENTS.md | 7 +- README.md | 33 ++-- docs/connection-diagnostics.md | 40 +++++ src/server.ts | 3 + src/tools/non-codemode.ts | 6 +- src/tools/profile.ts | 86 ++++++++++ tests/auth/cimd.test.ts | 2 +- tests/auth/oauth-routes.test.ts | 158 +++++++++++++++++- tests/auth/token-compatibility.test.ts | 4 +- tests/mcp-client.test.ts | 2 +- tests/mcp-modern.test.ts | 7 +- tests/non-codemode.test.ts | 2 +- tests/profile-worker.test.ts | 214 +++++++++++++++++++++++++ 13 files changed, 535 insertions(+), 29 deletions(-) create mode 100644 docs/connection-diagnostics.md create mode 100644 src/tools/profile.ts create mode 100644 tests/profile-worker.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7c351b44..7357fe79 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,8 @@ 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` is a read-only discovery tool in both modes. It accepts only empty input, returns matching JSON text and `structuredContent`, and publishes `outputSchema` plus `_meta["openai/profile"]: true`. Public metadata never varies by credentials. A versioned SHA-256 namespace over the immutable Cloudflare user/account ID gives stable opaque IDs; OAuth and direct user credentials share the user namespace, and account-owned credentials use a disjoint account namespace. Never change this derivation without preserving existing profile IDs. Reuse validated request identity; account lists, display metadata, tokens and grants are not profile keys. Client saved-row lifecycle remains outside this repository; see `docs/connection-diagnostics.md`. + ### MCP HTTP serving - `src/mcp-handler.ts` uses `createMcpHandler(factory)` directly from `@modelcontextprotocol/server`; this repository does not depend on the Agents SDK. diff --git a/README.md b/README.md index 1d24f6ee..f0f03bb7 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ ## Token Comparison -| Approach | Tools | Token cost | Context used (200K) | -| ------------------------------------------- | ----- | ---------- | ------------------- | -| Raw OpenAPI spec in prompt | — | ~2,000,000 | 977% | -| Native MCP (full schemas) | 2,594 | 1,170,523 | 585% | -| Native MCP (minimal — required params only) | 2,594 | 244,047 | 122% | -| Code mode | 3 | ~1,100 | 0.5% | +| Approach | Tools | Token cost | Context used (200K) | +| ------------------------------------------- | ----- | ------------------------- | ------------------- | +| Raw OpenAPI spec in prompt | — | ~2,000,000 | 977% | +| Native MCP (full schemas) | 2,594 | 1,170,523 | 585% | +| Native MCP (minimal — required params only) | 2,594 | 244,047 | 122% | +| Code mode (including profile discovery) | 4 | ~1,100 + profile metadata | ~0.5% | ## Get Started @@ -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({})` returns the same bounded profile object as JSON text and `structuredContent`. Its `outputSchema` and `_meta["openai/profile"]: true` let clients discover it in both tool modes, using MCP 2026-07-28 or the stateless 2025 compatibility path. It uses the request's validated identity and does not need API permissions beyond the normal connection bootstrap. + +OAuth and direct user credentials for the same Cloudflare user share one opaque profile ID. Account-owned credentials identify an account in a separate namespace. IDs depend only on the immutable Cloudflare identity, so refresh, reconnect, scope changes, email changes and changing authorized account lists preserve them. Email is display metadata for user profiles; account name is display metadata for account profiles. Direct-credential identity caching can delay display changes. + +A profile helps clients recognize a connection; it does not own saved rows, their primary selection or chat references. Separately permissioned connections may be intentional. See [connection diagnostics and client acceptance](docs/connection-diagnostics.md) before changing matching or cleaning up duplicate labels. + ## 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/docs/connection-diagnostics.md b/docs/connection-diagnostics.md new file mode 100644 index 00000000..8626d7cb --- /dev/null +++ b/docs/connection-diagnostics.md @@ -0,0 +1,40 @@ +# Connection diagnostics and acceptance + +The authenticated `get_profile` tool follows the [OpenAI profile contract](https://developers.openai.com/plugins/build/auth#support-multiple-accounts). The same Cloudflare user has one opaque ID for OAuth and direct user credentials. Account-owned credentials use a separate profile namespace. Equal display labels do not establish equal profiles or equal connection policies. Profile metadata supports recognition; it does not update or deduplicate client-side saved connections. + +The client connection store is outside this repository. Preventing extra saved rows requires the client changes and joint trace below; passing the server suite cannot prove row preservation. Do not delete saved connections or revoke real grants during investigation. + +## Grant behavior verified by the server suite + +With workers-oauth-provider 1.2.1, successful reauthorization creates a new grant. For the same user, client and canonical resource, its default replacement revokes the older grant after writing the pending new grant, before the client exchanges its code. A failed replacement exchange can therefore leave the old connection requiring reconnection. Replaying a consumed authorization code returns `invalid_grant` and revokes its grant, so duplicate callback handling must also avoid a second token exchange. A new DCR client ID retains a separate grant even for the same profile. The provider also separates CIMD redirects and resources when deciding replacement. Neither a grant ID nor a token generation identifies a saved client row. + +Real-worker tests verify profile discovery and matching JSON/structured output in both tool modes and both protocol paths; authenticated user/account isolation; stable IDs across refreshed credentials, reconnection and scope upgrades; same-client grant replacement before exchange; code replay rejection; separate grants for new DCR clients; and failure recovery's stale-grant risk. These tests preserve the provider's existing replacement behavior. + +## Remaining client implementation + +1. Store explicit transaction intent: create, reconnect or upgrade. Associate reconnect/upgrade privately with the existing connection ID, expected profile and credential generation. Use fresh OAuth state and PKCE; never expose the connection ID as state. +2. Reuse that connection's client registration, callback configuration, canonical resource and endpoint/query options. Treat a changed configuration as an explicit migration. Request the necessary scope union without silently broadening permissions. +3. Keep the original row, primary selection and chat references while authorization runs. After exchange, call the designated profile tool using the new credentials. A different profile requires an explicit account-switch/add-account decision. +4. Commit credentials and granted scopes atomically to the original row using a generation/compare-and-swap guard and a transaction idempotency key. Coalesce concurrent upgrades. Duplicate or older callbacks must not insert another row, overwrite newer credentials or resurrect a removed connection. +5. On denial, cancellation, exchange failure or missing scope growth, finish without inserting. Keep useful recovery state on the existing row; if its grant was replaced, mark it as requiring reconnection. Bound permission prompts and safe retries per user action. + +Preserve deliberate scope-specific or workspace-specific connections. Do not key deduplication by label, email, token, grant ID or a blanket `(client_id, user_id)` constraint. Existing-row cleanup requires a separate reviewed migration preserving primary selection and references, plus separate explicit authorization for deletion/revocation. + +## Controlled joint trace + +Record the host's connection gate separately from server authentication and API permissions. A not-connected error or Connect prompt can occur before any request reaches this Worker; it does not establish an OAuth or upstream API failure. Confirm the actual request path before attributing the prompt. + +Use a disposable workspace and test identity. First inventory saved connection IDs, plugin/server configuration, workspace, primary selection, creation/update times and credential generations. Then capture one action through callback and storage commit: + +- Client build and exact entry point; transaction intent/ID; target connection ID/generation; original API status separately from outer MCP status; requested scope set and attempt count. +- Discovery issuer and canonical resource; client ID fingerprint; callback scheme/host/path and whether DCR repeats; server deployment version and bounded timestamps/request IDs. +- Consent request and granted scopes; pending/replaced grant fingerprints and replacement outcome; callback validation and exchange result; authenticated profile ID/fingerprint. +- Client profile comparison, insert/update target, idempotency/CAS result, retry result, final row count, primary selection and retained chat references. + +Use an agreed correlation ID without weakening state/PKCE. If no propagation is available, join bounded timestamps/request IDs and sanitized transaction fingerprints. Never collect raw access/refresh tokens, authorization codes, cookies, PKCE verifiers, full redirect URLs or credential hashes. Tool/auth Analytics Engine events and refused-registration diagnostics do not provide a complete transaction trace. Add only the temporary diagnostics shown necessary by a controlled trace, with agreed retention and removal; preserve the shared positional metrics schema. + +## Client release gate + +Verify the same saved ID, primary state and references after a read-only connection performs one write, one upgrade and one safe retry. Also verify refresh, manual reconnect and display changes; denial/cancellation/exchange failure/missing scope growth; parallel failures and duplicate callbacks; account switches; intentional add-account actions; equal labels on distinct profiles; OAuth/direct user/account credentials; changed registration/callback/resource configuration; and removal during a pending upgrade. Failures must produce useful recovery without extra rows, consent loops, silent profile replacement or delayed overwrites. + +Release to a test cohort only after the joint trace passes. Track extra rows per reconnect, upgrade completion, stale credentials, repeated prompts and profile mismatches. Roll back client matching if it updates a different identity or removes an intentional connection. Production row prevention and the incident root cause remain unverified until the client owner completes this gate. diff --git a/src/server.ts b/src/server.ts index 14d29595..6c0b7d0c 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 20875c3b..66c4e183 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 00000000..8bce0596 --- /dev/null +++ b/src/tools/profile.ts @@ -0,0 +1,86 @@ +import { z } from 'zod' +import type { CallToolResult, McpServer, Tool } from '@modelcontextprotocol/server' +import type { AuthProps } from '../auth/types' + +export const ProfileInputSchema = z.strictObject({}) +const ProfileSchema = z.strictObject({ + id: z + .string() + .min(1) + .regex(/\S/) + .describe( + 'Opaque profile identifier, stable across refresh, reconnection, scope upgrades and display changes.' + ), + 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.", + 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 + }, + _meta: { 'openai/profile': true } +} + +/** 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. + 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 a794105d..e6c3902f 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 996a2c4f..a6db1ca4 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 9fb1f570..b2bbbfc8 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 67901d8b..e998e6e2 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 44670af5..f7981b68 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 c06fb872..cb560b9d 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 00000000..93cc2c2f --- /dev/null +++ b/tests/profile-worker.test.ts @@ -0,0 +1,214 @@ +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' +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 }, + 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 + } + }) + const called = await result('cfut_profile-token', 'tools/call', {}, version, codemode) + expect(called.isError).toBe(false) + expect(called.structuredContent).toEqual({ + id: expect.stringMatching(/^[a-f0-9]{64}$/), + 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('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('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: expect.any(String), + name: 'same-label@example.com' + }) + expect(account.structuredContent).not.toHaveProperty('email') +}) + +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) +}) From 5b4a276015c72bdbdcc4055a6e6e9e3bec9fa45c Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Tue, 6 Oct 2026 16:53:37 -0500 Subject: [PATCH 2/6] Apply suggestion from @irvinebroque --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7357fe79..2b4786ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ 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` is a read-only discovery tool in both modes. It accepts only empty input, returns matching JSON text and `structuredContent`, and publishes `outputSchema` plus `_meta["openai/profile"]: true`. Public metadata never varies by credentials. A versioned SHA-256 namespace over the immutable Cloudflare user/account ID gives stable opaque IDs; OAuth and direct user credentials share the user namespace, and account-owned credentials use a disjoint account namespace. Never change this derivation without preserving existing profile IDs. Reuse validated request identity; account lists, display metadata, tokens and grants are not profile keys. Client saved-row lifecycle remains outside this repository; see `docs/connection-diagnostics.md`. +`get_profile` is a read-only discovery tool, that conforms with the [ChatGPT Plugins standard](https://developers.openai.com/plugins/build/auth#implement-and-declare-your-profile-tool). It returns JSON, and allows MCP clients to disambiguate which Cloudflare account the MCP client has been granted access to. ### MCP HTTP serving From d71515eca33911d055ddc61281a70adbe4cc0675 Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Tue, 6 Oct 2026 17:07:18 -0500 Subject: [PATCH 3/6] Remove connection diagnostics document --- README.md | 2 +- docs/connection-diagnostics.md | 40 ---------------------------------- 2 files changed, 1 insertion(+), 41 deletions(-) delete mode 100644 docs/connection-diagnostics.md diff --git a/README.md b/README.md index f0f03bb7..aadbd3e1 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ https://mcp.cloudflare.com/mcp?codemode=false&truncateToolResult=false OAuth and direct user credentials for the same Cloudflare user share one opaque profile ID. Account-owned credentials identify an account in a separate namespace. IDs depend only on the immutable Cloudflare identity, so refresh, reconnect, scope changes, email changes and changing authorized account lists preserve them. Email is display metadata for user profiles; account name is display metadata for account profiles. Direct-credential identity caching can delay display changes. -A profile helps clients recognize a connection; it does not own saved rows, their primary selection or chat references. Separately permissioned connections may be intentional. See [connection diagnostics and client acceptance](docs/connection-diagnostics.md) before changing matching or cleaning up duplicate labels. +A profile helps clients recognize a connection; it does not own saved rows, their primary selection or chat references. Separately permissioned connections may be intentional. ## The Problem diff --git a/docs/connection-diagnostics.md b/docs/connection-diagnostics.md deleted file mode 100644 index 8626d7cb..00000000 --- a/docs/connection-diagnostics.md +++ /dev/null @@ -1,40 +0,0 @@ -# Connection diagnostics and acceptance - -The authenticated `get_profile` tool follows the [OpenAI profile contract](https://developers.openai.com/plugins/build/auth#support-multiple-accounts). The same Cloudflare user has one opaque ID for OAuth and direct user credentials. Account-owned credentials use a separate profile namespace. Equal display labels do not establish equal profiles or equal connection policies. Profile metadata supports recognition; it does not update or deduplicate client-side saved connections. - -The client connection store is outside this repository. Preventing extra saved rows requires the client changes and joint trace below; passing the server suite cannot prove row preservation. Do not delete saved connections or revoke real grants during investigation. - -## Grant behavior verified by the server suite - -With workers-oauth-provider 1.2.1, successful reauthorization creates a new grant. For the same user, client and canonical resource, its default replacement revokes the older grant after writing the pending new grant, before the client exchanges its code. A failed replacement exchange can therefore leave the old connection requiring reconnection. Replaying a consumed authorization code returns `invalid_grant` and revokes its grant, so duplicate callback handling must also avoid a second token exchange. A new DCR client ID retains a separate grant even for the same profile. The provider also separates CIMD redirects and resources when deciding replacement. Neither a grant ID nor a token generation identifies a saved client row. - -Real-worker tests verify profile discovery and matching JSON/structured output in both tool modes and both protocol paths; authenticated user/account isolation; stable IDs across refreshed credentials, reconnection and scope upgrades; same-client grant replacement before exchange; code replay rejection; separate grants for new DCR clients; and failure recovery's stale-grant risk. These tests preserve the provider's existing replacement behavior. - -## Remaining client implementation - -1. Store explicit transaction intent: create, reconnect or upgrade. Associate reconnect/upgrade privately with the existing connection ID, expected profile and credential generation. Use fresh OAuth state and PKCE; never expose the connection ID as state. -2. Reuse that connection's client registration, callback configuration, canonical resource and endpoint/query options. Treat a changed configuration as an explicit migration. Request the necessary scope union without silently broadening permissions. -3. Keep the original row, primary selection and chat references while authorization runs. After exchange, call the designated profile tool using the new credentials. A different profile requires an explicit account-switch/add-account decision. -4. Commit credentials and granted scopes atomically to the original row using a generation/compare-and-swap guard and a transaction idempotency key. Coalesce concurrent upgrades. Duplicate or older callbacks must not insert another row, overwrite newer credentials or resurrect a removed connection. -5. On denial, cancellation, exchange failure or missing scope growth, finish without inserting. Keep useful recovery state on the existing row; if its grant was replaced, mark it as requiring reconnection. Bound permission prompts and safe retries per user action. - -Preserve deliberate scope-specific or workspace-specific connections. Do not key deduplication by label, email, token, grant ID or a blanket `(client_id, user_id)` constraint. Existing-row cleanup requires a separate reviewed migration preserving primary selection and references, plus separate explicit authorization for deletion/revocation. - -## Controlled joint trace - -Record the host's connection gate separately from server authentication and API permissions. A not-connected error or Connect prompt can occur before any request reaches this Worker; it does not establish an OAuth or upstream API failure. Confirm the actual request path before attributing the prompt. - -Use a disposable workspace and test identity. First inventory saved connection IDs, plugin/server configuration, workspace, primary selection, creation/update times and credential generations. Then capture one action through callback and storage commit: - -- Client build and exact entry point; transaction intent/ID; target connection ID/generation; original API status separately from outer MCP status; requested scope set and attempt count. -- Discovery issuer and canonical resource; client ID fingerprint; callback scheme/host/path and whether DCR repeats; server deployment version and bounded timestamps/request IDs. -- Consent request and granted scopes; pending/replaced grant fingerprints and replacement outcome; callback validation and exchange result; authenticated profile ID/fingerprint. -- Client profile comparison, insert/update target, idempotency/CAS result, retry result, final row count, primary selection and retained chat references. - -Use an agreed correlation ID without weakening state/PKCE. If no propagation is available, join bounded timestamps/request IDs and sanitized transaction fingerprints. Never collect raw access/refresh tokens, authorization codes, cookies, PKCE verifiers, full redirect URLs or credential hashes. Tool/auth Analytics Engine events and refused-registration diagnostics do not provide a complete transaction trace. Add only the temporary diagnostics shown necessary by a controlled trace, with agreed retention and removal; preserve the shared positional metrics schema. - -## Client release gate - -Verify the same saved ID, primary state and references after a read-only connection performs one write, one upgrade and one safe retry. Also verify refresh, manual reconnect and display changes; denial/cancellation/exchange failure/missing scope growth; parallel failures and duplicate callbacks; account switches; intentional add-account actions; equal labels on distinct profiles; OAuth/direct user/account credentials; changed registration/callback/resource configuration; and removal during a pending upgrade. Failures must produce useful recovery without extra rows, consent loops, silent profile replacement or delayed overwrites. - -Release to a test cohort only after the joint trace passes. Track extra rows per reconnect, upgrade completion, stale credentials, repeated prompts and profile mismatches. Roll back client matching if it updates a different identity or removes an intentional connection. Production row prevention and the incident root cause remain unverified until the client owner completes this gate. From a1ec8e2d8b585a9dd5725e12212a51cdded22371 Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Tue, 6 Oct 2026 17:08:10 -0500 Subject: [PATCH 4/6] Keep original token comparison table --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index aadbd3e1..6ceff60a 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ ## Token Comparison -| Approach | Tools | Token cost | Context used (200K) | -| ------------------------------------------- | ----- | ------------------------- | ------------------- | -| Raw OpenAPI spec in prompt | — | ~2,000,000 | 977% | -| Native MCP (full schemas) | 2,594 | 1,170,523 | 585% | -| Native MCP (minimal — required params only) | 2,594 | 244,047 | 122% | -| Code mode (including profile discovery) | 4 | ~1,100 + profile metadata | ~0.5% | +| Approach | Tools | Token cost | Context used (200K) | +| ------------------------------------------- | ----- | ---------- | ------------------- | +| Raw OpenAPI spec in prompt | — | ~2,000,000 | 977% | +| Native MCP (full schemas) | 2,594 | 1,170,523 | 585% | +| Native MCP (minimal — required params only) | 2,594 | 244,047 | 122% | +| Code mode | 3 | ~1,100 | 0.5% | ## Get Started From 8143fe1b6f17cc9b777ba6a2f822106adad2c6f6 Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Tue, 6 Oct 2026 17:09:06 -0500 Subject: [PATCH 5/6] Simplify profile documentation --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index 6ceff60a..c2827c6f 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,7 @@ https://mcp.cloudflare.com/mcp?codemode=false&truncateToolResult=false ### Authenticated Profile -`get_profile({})` returns the same bounded profile object as JSON text and `structuredContent`. Its `outputSchema` and `_meta["openai/profile"]: true` let clients discover it in both tool modes, using MCP 2026-07-28 or the stateless 2025 compatibility path. It uses the request's validated identity and does not need API permissions beyond the normal connection bootstrap. - -OAuth and direct user credentials for the same Cloudflare user share one opaque profile ID. Account-owned credentials identify an account in a separate namespace. IDs depend only on the immutable Cloudflare identity, so refresh, reconnect, scope changes, email changes and changing authorized account lists preserve them. Email is display metadata for user profiles; account name is display metadata for account profiles. Direct-credential identity caching can delay display changes. - -A profile helps clients recognize a connection; it does not own saved rows, their primary selection or chat references. Separately permissioned connections may be intentional. +`get_profile` is a read-only discovery tool, that conforms with the [ChatGPT Plugins standard](https://developers.openai.com/plugins/build/auth#implement-and-declare-your-profile-tool). It returns JSON, and allows MCP clients to disambiguate which Cloudflare account the MCP client has been granted access to. ## The Problem From 2dd95ea7cca351baa56175ae61c4972ca49b196b Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Tue, 6 Oct 2026 17:25:54 -0500 Subject: [PATCH 6/6] Declare profile OAuth metadata and prevent caching authenticated results --- AGENTS.md | 5 ++- README.md | 6 +++- src/mcp-handler.ts | 14 ++++++--- src/tools/profile.ts | 19 +++++++++-- tests/profile-worker.test.ts | 61 ++++++++++++++++++++++++++++++++++-- 5 files changed, 92 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b4786ef..47d227cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,9 @@ 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` is a read-only discovery tool, that conforms with the [ChatGPT Plugins standard](https://developers.openai.com/plugins/build/auth#implement-and-declare-your-profile-tool). It returns JSON, and allows MCP clients to disambiguate which Cloudflare account the MCP client has been granted access to. +`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 @@ -120,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 c2827c6f..644b091f 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,11 @@ https://mcp.cloudflare.com/mcp?codemode=false&truncateToolResult=false ### Authenticated Profile -`get_profile` is a read-only discovery tool, that conforms with the [ChatGPT Plugins standard](https://developers.openai.com/plugins/build/auth#implement-and-declare-your-profile-tool). It returns JSON, and allows MCP clients to disambiguate which Cloudflare account the MCP client has been granted access to. +`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 diff --git a/src/mcp-handler.ts b/src/mcp-handler.ts index 82cb76bd..7ec4f8bd 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/tools/profile.ts b/src/tools/profile.ts index 8bce0596..9a2605c9 100644 --- a/src/tools/profile.ts +++ b/src/tools/profile.ts @@ -1,6 +1,13 @@ 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({ @@ -9,7 +16,7 @@ const ProfileSchema = z.strictObject({ .min(1) .regex(/\S/) .describe( - 'Opaque profile identifier, stable across refresh, reconnection, scope upgrades and display changes.' + '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.') @@ -20,7 +27,7 @@ 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.", + "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', @@ -34,7 +41,9 @@ export const PROFILE_TOOL: Tool = { destructiveHint: false, openWorldHint: false }, - _meta: { 'openai/profile': true } + // 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. */ @@ -49,6 +58,10 @@ export async function runProfileTool( // 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', diff --git a/tests/profile-worker.test.ts b/tests/profile-worker.test.ts index 93cc2c2f..91005faa 100644 --- a/tests/profile-worker.test.ts +++ b/tests/profile-worker.test.ts @@ -15,6 +15,9 @@ 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 }, @@ -78,7 +81,10 @@ describe.each(CASES)( 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 }, + _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: { @@ -88,16 +94,36 @@ describe.each(CASES)( 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: expect.stringMatching(/^[a-f0-9]{64}$/), + 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', @@ -122,6 +148,7 @@ describe.each(CASES)( 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') }) @@ -181,12 +208,40 @@ it('isolates concurrent identities, same display labels and account/user namespa ]).size ).toBe(3) expect(account.structuredContent).toEqual({ - id: expect.any(String), + 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',