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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ cloudflare-mcp/
│ ├── isolate-cache.ts # One-hour in-isolate cache of the R2 artifacts
│ ├── truncate.ts # Response truncation (~6K token limit)
│ ├── metrics.ts # Analytics Engine metrics (auth_user/tool_call)
│ ├── api-permissions.ts # Connection kinds; explains refused (401/403) API calls
│ ├── api-refusal-hint.ts # Looks up a refused execute request's permissions in spec.json
│ ├── auth/
│ │ ├── types.ts # Auth props schemas (Zod discriminated union)
│ │ ├── api-token-mode.ts # Prefix classification & external resolver
Expand Down Expand Up @@ -157,6 +159,15 @@ Everything the Worker reads about the API comes from one daily build of the newe
- Forge is a build-time dependency vendored as `vendor/cloudflare-forge-0.1.0.tgz`. Generator tests run with `node --test` (`npm run test:generator`), outside the workers pool. The workers pool can't run containers; `tests/tools-builder.test.ts` covers `BuildEgress`, the Forge download and the scheduled dispatch.
- `wrangler dev` runs without containers (`dev.enable_containers: false`); use `npm run seed:local` to fill local R2.

### Refused API calls

Cloudflare answers a missing OAuth scope, an endpoint OAuth can't reach (billing, API tokens), and a missing role all with `403` and `10000: Authentication error` or `9109: Unauthorized`. Agents read that as a signed-out connection and ask users to reconnect, which changes nothing. So every 401/403 tool error names the method, path, status and Cloudflare's errors, and adds an explanation from `src/api-permissions.ts`:

- The connection kind comes from the provider's `ctx.auth`. A token the provider issued carries `clientId` and its granted `scope` (`oauth`). A directly resolved credential carries neither (`direct`).
- The endpoint's accepted permissions come from Forge's `x-api-token-group`, which the build copies into each `spec.json` operation and each `mcp-tools.json` entry (`permissions`). OAuth scopes share those names (`DERIVED_OAUTH_SCOPES`). Any one permission is enough.
- The explanation says one of four things. The connection lacks every scope the endpoint accepts: grant one. It already holds one: reconnecting won't help. No OAuth scope exists for the endpoint: use an API token. For a direct credential: these are the permissions the token needs.
- `execute`: `GlobalOutbound` sets the explanation in an `X-Cloudflare-MCP-Refusal-Hint` response header. The sandbox puts it in the error it throws, so it survives code that catches the error. Endpoint tools add it to the tool result, using their own entry's permissions. `execute` matches the refused path against `spec.json`, parsed only for refused requests.

### Response truncation

Responses capped at ~6,000 tokens (~24KB). `src/truncate.ts` shrinks oversized JSON structurally so it stays valid JSON: arrays keep whole items from the start and end with a `--- TRUNCATED --- N more items` element, long strings are clipped, and objects drop their largest values first, naming them in a `--- TRUNCATED ---` entry. Plain text is cut at the cap and followed by a notice with the original size.
Expand Down
14 changes: 14 additions & 0 deletions scripts/generator/spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,17 @@ test('builds the search spec and products from one pass over the document', () =
test('handles documents without paths', () => {
assert.deepEqual(processSpec({}), { spec: { paths: {} }, products: [] })
})

test('keeps x-api-token-group lists and drops malformed values', () => {
const { spec } = processSpec({
paths: {
'/zones/{zone_id}/dns_records': {
get: { summary: 'List', 'x-api-token-group': ['DNS Read', 'DNS Write'] },
post: { summary: 'Create', 'x-api-token-group': 'DNS Write' }
}
}
})
const item = spec.paths['/zones/{zone_id}/dns_records']! as Record<string, any>
assert.deepEqual(item.get['x-api-token-group'], ['DNS Read', 'DNS Write'])
assert.equal(item.post['x-api-token-group'], undefined)
})
11 changes: 11 additions & 0 deletions scripts/generator/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ interface OperationObject {
requestBody?: unknown
responses?: unknown
'x-fern-sdk-group-name'?: unknown
'x-api-token-group'?: unknown
}

/** Keep `x-api-token-group` only when it is a list of permission names. */
function permissionLabels(value: unknown): string[] | undefined {
return Array.isArray(value) &&
value.length > 0 &&
value.every((item) => typeof item === 'string' && item.length > 0)
? value
: undefined
}

export interface SearchSpec {
Expand Down Expand Up @@ -86,6 +96,7 @@ export function processSpec(source: Record<string, unknown>): {
}
paths[path][method] = {
summary: op.summary,
'x-api-token-group': permissionLabels(op['x-api-token-group']),
description: op.description,
tags,
parameters: resolveRefs(op.parameters, source),
Expand Down
155 changes: 155 additions & 0 deletions src/api-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { z } from 'zod'
import { DERIVED_OAUTH_SCOPES } from './auth/derived-oauth-scopes'
import type { OperationInfo } from './openapi'

/**
* Which credential a request carries, as far as Cloudflare permissions go.
*
* - `oauth`: a token this server issued. Its scopes are the ones Cloudflare
* granted the connection.
* - `direct`: a Cloudflare API token or OAuth token passed straight through.
* Its permissions are unknown here.
*/
export type Connection =
| { readonly kind: 'oauth'; readonly scopes: readonly string[] }
| { readonly kind: 'direct' }

/** A credential whose permissions this server doesn't know. */
export const DIRECT_CONNECTION: Connection = { kind: 'direct' }

// workers-oauth-provider reports a client and scopes only for the tokens it
// issued; for an externally resolved credential it reports neither.
const IssuedTokenAuth = z.object({ clientId: z.string().min(1), scope: z.array(z.string()) })

/**
* Parse the provider's `ctx.auth` into a {@link Connection}.
*
* @param auth - `ctx.auth` from workers-oauth-provider, or nothing.
* @returns `oauth` with the granted scopes for a provider-issued token, else `direct`.
*/
export function connectionFromAuth(auth: unknown): Connection {
const issued = IssuedTokenAuth.safeParse(auth)
return issued.success ? { kind: 'oauth', scopes: issued.data.scope } : DIRECT_CONNECTION
}

const PermissionLabels = z.array(z.string().min(1)).min(1)

/**
* The API token permissions an operation accepts, from its `x-api-token-group`.
* Any one of them is enough.
*
* @param operation - The operation as stored in `spec.json`.
* @returns The permission names, or `undefined` when the spec lists none.
*/
export function acceptedPermissions(
operation: { readonly 'x-api-token-group'?: unknown } | undefined
): string[] | undefined {
const labels = PermissionLabels.safeParse(operation?.['x-api-token-group'])
return labels.success ? [...new Set(labels.data)] : undefined
}

const SCOPE_NAMES: ReadonlyMap<string, string> = new Map(
Object.entries(DERIVED_OAUTH_SCOPES).map(([scope, { name }]) => [scope, name])
)

/** OAuth scope IDs by permission name. OAuth scopes share API token permission names. */
const SCOPES_BY_PERMISSION: ReadonlyMap<string, readonly string[]> = (() => {
const scopes = new Map<string, string[]>()
for (const [scope, name] of SCOPE_NAMES) scopes.set(name, [...(scopes.get(name) ?? []), scope])
return scopes
})()

/**
* Find the spec operation a request was sent to.
*
* @param paths - `spec.json` paths.
* @param method - The request method.
* @param path - The request path below the API base, such as `/zones/abc/dns_records`.
* @returns The operation whose template matches with the most literal segments.
*/
export function findOperation(
paths: Record<string, Record<string, OperationInfo>>,
method: string,
path: string
): OperationInfo | undefined {
const segments = path.replace(/\/+$/, '').split('/')
const verb = method.toLowerCase()
let best: { operation: OperationInfo; literals: number } | undefined
for (const [template, item] of Object.entries(paths)) {
const operation = item[verb]
if (!operation) continue
const parts = template.split('/')
if (parts.length !== segments.length) continue
let literals = 0
const matches = parts.every((part, index) => {
if (/^\{[^}]+\}$/.test(part)) return segments[index] !== ''
literals++
return part === segments[index]
})
if (matches && (!best || literals > best.literals)) best = { operation, literals }
}
return best?.operation
}

const MAX_LISTED = 8

function list(items: readonly string[]): string {
const shown = items.slice(0, MAX_LISTED).join(', ')
return items.length > MAX_LISTED ? `${shown} and ${items.length - MAX_LISTED} more` : shown
}

function scopeLabel(scope: string): string {
const name = SCOPE_NAMES.get(scope)
return name ? `\`${scope}\` (${name})` : `\`${scope}\``
}

/**
* Explain why Cloudflare refused a request with 401 or 403, and what to do.
*
* Cloudflare reports a missing OAuth scope, an endpoint OAuth can't reach, and
* a missing role all as `10000: Authentication error` or `9109: Unauthorized`.
* That reads like a signed-out connection, so agents ask users to reconnect,
* which changes nothing. This tells the cases apart.
*
* @param status - The HTTP status Cloudflare returned.
* @param connection - The credential that made the request.
* @param permissions - What the endpoint accepts, from {@link acceptedPermissions}.
* @returns Guidance for the agent, or `undefined` for other statuses.
*/
export function explainRefusal(
status: number,
connection: Connection,
permissions: readonly string[] | undefined
): string | undefined {
if (status === 401) {
return connection.kind === 'oauth'
? 'Cloudflare did not accept this connection’s credentials. Reconnect to sign in again.'
: 'Cloudflare did not accept this credential. Check that the API token is valid and has not expired.'
}
if (status !== 403) return undefined

if (!permissions) {
return 'The API spec lists no permissions for this endpoint. HTTP 403 means the credential was accepted but is not allowed to do this, so reconnecting with the same permissions will not help. Check the endpoint’s documentation.'
}

const scopes = [...new Set(permissions.flatMap((name) => SCOPES_BY_PERMISSION.get(name) ?? []))]

if (connection.kind === 'direct') {
const named = permissions.map((name) => {
const ids = SCOPES_BY_PERMISSION.get(name)
return ids ? `${name} (${ids.map((id) => `\`${id}\``).join(' or ')})` : name
})
return `This endpoint needs one of these permissions: ${list(named)}. Add one to the token.`
}

if (scopes.length === 0) {
return `No OAuth scope covers this endpoint, so OAuth connections cannot use it. Connect with an API token that has one of these permissions instead: ${list(permissions)}.`
}

const held = scopes.filter((scope) => connection.scopes.includes(scope))
if (held.length > 0) {
return `This connection already has ${list(held.map(scopeLabel))}, which this endpoint accepts, so reconnecting will not help. Cloudflare refused for another reason: usually the user’s role on the account or zone, or a resource in an account this connection was not granted.`
}

return `This connection was not granted a scope this endpoint accepts. Reconnect and grant one of: ${list(scopes.map(scopeLabel))}.`
}
37 changes: 37 additions & 0 deletions src/api-refusal-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
acceptedPermissions,
explainRefusal,
findOperation,
type Connection
} from './api-permissions'
import { getSpecPaths } from './isolate-cache'

/**
* Header `GlobalOutbound` sets on a refused response so the `execute` sandbox
* can put the explanation in the error it throws. URI-encoded.
*/
export const REFUSAL_HINT_HEADER = 'X-Cloudflare-MCP-Refusal-Hint'

/**
* Explain a 401 or 403 from the Cloudflare API for the request that got it.
*
* Reads `spec.json` only for refused requests, to look up the permissions the
* endpoint accepts. Without the spec it still explains the status.
*
* @param status - The HTTP status Cloudflare returned.
* @param connection - The credential that made the request.
* @param method - The request method.
* @param path - The request path below the API base.
* @returns Guidance for the agent, or `undefined` for other statuses.
*/
export async function explainRefusedRequest(
status: number,
connection: Connection,
method: string,
path: string
): Promise<string | undefined> {
if (status !== 401 && status !== 403) return undefined
const paths = await getSpecPaths().catch(() => undefined)
const operation = paths ? findOperation(paths, method, path) : undefined
return explainRefusal(status, connection, acceptedPermissions(operation))
}
17 changes: 17 additions & 0 deletions src/isolate-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type McpTool,
type McpToolsArtifact
} from './mcp-tools'
import type { OperationInfo } from './openapi'

/**
* In-isolate cache for the R2 artifacts the daily ToolsBuilder run writes
Expand All @@ -28,7 +29,10 @@ export interface DirectTools {
byName: Map<string, McpTool>
}

type SpecPaths = Record<string, Record<string, OperationInfo>>

let specEntry: Entry<string> | undefined
let specPaths: { text: string; paths: SpecPaths } | undefined
let productsEntry: Entry<string[]> | undefined
let toolsEntry: Entry<DirectTools> | undefined

Expand All @@ -52,6 +56,18 @@ export async function getSpec(): Promise<string> {
return value
}

/**
* `spec.json` paths, parsed only when needed (explaining a refused `execute`
* request) and reused until the cached text changes.
*/
export async function getSpecPaths(): Promise<SpecPaths> {
const text = await getSpec()
if (specPaths?.text !== text) {
specPaths = { text, paths: (JSON.parse(text) as { paths: SpecPaths }).paths }
}
return specPaths.paths
}

/** The direct tools, served exactly as generated. */
export async function getDirectTools(): Promise<DirectTools> {
const now = Date.now()
Expand Down Expand Up @@ -87,6 +103,7 @@ export async function getProducts(): Promise<string[]> {
/** Drop cached artifacts. For tests that re-seed R2 between cases. */
export function resetIsolateCache(): void {
specEntry = undefined
specPaths = undefined
productsEntry = undefined
toolsEntry = undefined
}
30 changes: 22 additions & 8 deletions src/mcp-handler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { OAuthResourceContext } from '@cloudflare/workers-oauth-provider'
import {
createMcpHandler,
hostHeaderValidationResponse,
Expand All @@ -6,6 +7,7 @@ import {
originValidationResponse
} from '@modelcontextprotocol/server'
import { createServer, type ServerOptions } from './server'
import { connectionFromAuth, type Connection } from './api-permissions'
import { AuthProps as AuthPropsSchema, type AuthProps } from './auth/types'

export const MCP_ROUTE = '/mcp'
Expand Down Expand Up @@ -34,13 +36,13 @@ function serverOptionsFromUrl(url: string): ServerOptions {
}
}

function createAuthenticatedHandler(props: AuthProps) {
function createAuthenticatedHandler(props: AuthProps, connection: Connection) {
return createMcpHandler(({ requestInfo }) => {
if (!requestInfo) {
throw new Error('The Cloudflare MCP server requires an HTTP request')
}

return createServer(props, serverOptionsFromUrl(requestInfo.url))
return createServer(props, serverOptionsFromUrl(requestInfo.url), connection)
})
}

Expand Down Expand Up @@ -91,10 +93,17 @@ export function handleMcpPreflight(request: Request): Response {
return new Response(null, { status: 204, headers: corsHeaders(request) })
}

/** Serve one authenticated MCP exchange with a fresh SDK v2 server instance. */
/**
* Serve one authenticated MCP exchange with a fresh SDK v2 server instance.
*
* @param request - The MCP HTTP request.
* @param rawProps - `ctx.props` from workers-oauth-provider.
* @param auth - `ctx.auth` from workers-oauth-provider: the verified token's scopes.
*/
export async function handleAuthenticatedMcpRequest(
request: Request,
rawProps: unknown
rawProps: unknown,
auth?: unknown
): Promise<Response> {
if (new URL(request.url).pathname !== MCP_ROUTE) {
return new Response('Not Found', { status: 404 })
Expand All @@ -104,15 +113,20 @@ export async function handleAuthenticatedMcpRequest(
if (rejected) return rejected

const props = AuthPropsSchema.parse(rawProps)
const handler = createAuthenticatedHandler(props)
const handler = createAuthenticatedHandler(props, connectionFromAuth(auth))
const response = await handler.fetch(request)

return withCors(response, request)
}

/** ExportedHandler adapter required by workers-oauth-provider 0.8.x. */
/** workers-oauth-provider API handler: `ctx.props` holds the grant, `ctx.auth` the verified token. */
export const oauthMcpHandler = {
fetch(request: Request, _env: Env, ctx: ExecutionContext) {
return handleAuthenticatedMcpRequest(request, ctx.props)
// The provider's apiHandlers type predates ctx.auth, so it is optional here.
fetch(
request: Request,
_env: Env,
ctx: ExecutionContext & Partial<Pick<OAuthResourceContext<unknown>, 'auth'>>
) {
return handleAuthenticatedMcpRequest(request, ctx.props, ctx.auth)
}
}
3 changes: 3 additions & 0 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*/
export interface OperationInfo {
summary?: string
/** API token permissions the operation accepts; any one is enough. OAuth scopes share these names. */
'x-api-token-group'?: string[]
description?: string
tags?: string[]
parameters?: Array<{
Expand All @@ -26,6 +28,7 @@ export interface OperationInfo {
export const SPEC_TYPES = `
interface OperationInfo {
summary?: string;
"x-api-token-group"?: string[]; // API token permissions the endpoint accepts (any one); OAuth scopes have the same names
description?: string;
tags?: string[];
parameters?: Array<{ name: string; in: string; required?: boolean; schema?: unknown; description?: string }>;
Expand Down
Loading