Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
130 changes: 130 additions & 0 deletions .github/workflows/forge-tools.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
name: Forge tools

# Every six hours, regenerates src/generated/forge-mcp, the direct tools served
# at /mcp?forge=true, from the latest openapi.forge.json Forge release with the
# forge commit that forge.json names. When the generated code changes, it opens
# or updates one pull request; merging it deploys through Workers Builds.
#
# The pull request is opened as the agent-think GitHub App (team-apps, in
# cloudflare/ai-agents) when its AGENT_THINK_APP_ID and AGENT_THINK_PRIVATE_KEY
# secrets are set, so CI runs on it as on any pull request. Without them it
# falls back to GITHUB_TOKEN, whose pull requests don't start other workflows;
# this job runs the type check and tests itself either way.

on:
schedule:
- cron: '30 */6 * * *'
workflow_dispatch:

permissions:
contents: write
pull-requests: write

concurrency:
group: forge-tools
cancel-in-progress: false

env:
BRANCH: bot/forge-tools
TITLE: 'chore(forge): regenerate the forge MCP tools'
HAS_APP: ${{ secrets.AGENT_THINK_APP_ID != '' }}

jobs:
generate:
name: Generate
runs-on: ubuntu-latest
steps:
- name: Mint an agent-think token
id: app
if: env.HAS_APP == 'true'
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.AGENT_THINK_APP_ID }}
private-key: ${{ secrets.AGENT_THINK_PRIVATE_KEY }}

- uses: actions/checkout@v6
with:
token: ${{ steps.app.outputs.token || github.token }}

- name: Read forge.json
id: forge
run: |
echo "repository=$(jq -r .repository forge.json)" >> "$GITHUB_OUTPUT"
echo "ref=$(jq -r .ref forge.json)" >> "$GITHUB_OUTPUT"

- uses: actions/checkout@v6
with:
repository: ${{ steps.forge.outputs.repository }}
ref: ${{ steps.forge.outputs.ref }}
path: .forge

- uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
.forge/target
key: forge-${{ runner.os }}-${{ steps.forge.outputs.ref }}

# rustup reads .forge/rust-toolchain.toml and installs that toolchain.
- name: Build forge
working-directory: .forge
run: cargo build --release --bin forge

- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Generate
env:
FORGE_BINARY: .forge/target/release/forge
run: npm run generate:tools -- --summary "$RUNNER_TEMP/summary.md"

- name: Check for changes
id: changes
run: |
if [ -n "$(git status --porcelain -- src/generated/forge-mcp)" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

- name: Type check
if: steps.changes.outputs.changed == 'true'
run: npm run typecheck

- name: Test
if: steps.changes.outputs.changed == 'true'
run: npm run test

- name: Open or update the pull request
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ steps.app.outputs.token || github.token }}
APP_SLUG: ${{ steps.app.outputs.app-slug }}
FORGE_REF: ${{ steps.forge.outputs.ref }}
run: |
if [ -n "$APP_SLUG" ]; then
user_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id)
git config user.name "${APP_SLUG}[bot]"
git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com"
else
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
fi
git checkout -B "$BRANCH"
git add src/generated/forge-mcp
git commit -q -m "$TITLE"
git push -q --force origin "$BRANCH"
{
echo "Regenerated from the latest \`openapi.forge.json\` with forge \`${FORGE_REF:0:12}\`. Merging deploys the new tools to \`/mcp?forge=true\`."
echo
cat "$RUNNER_TEMP/summary.md"
} > "$RUNNER_TEMP/body.md"
if [ "$(gh pr view "$BRANCH" --json state --jq .state 2>/dev/null)" = OPEN ]; then
gh pr edit "$BRANCH" --body-file "$RUNNER_TEMP/body.md"
else
gh pr create --base main --head "$BRANCH" --title "$TITLE" --body-file "$RUNNER_TEMP/body.md"
fi
5 changes: 4 additions & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"semi": false,
"trailingComma": "none"
"trailingComma": "none",
"ignorePatterns": [
"src/generated/**"
]
}
22 changes: 21 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ cloudflare-mcp/
│ ├── api-refusal-hint.ts # Looks up a refused execute request's permissions in mcp-tools.json
│ ├── scope-challenge.ts # Per-request insufficient_scope step-up challenge
│ ├── clients.ts # Known MCP clients that deviate from the spec, and their behavior
│ ├── generated/forge-mcp/ # Direct tools generated by forge (?forge=true); never edit by hand
│ ├── forge-tools/ # Forge tools service (worker.ts), call planning, Cloudflare policy
│ ├── auth/
│ │ ├── types.ts # Auth props schemas (Zod discriminated union)
│ │ ├── api-token-mode.ts # Prefix classification & external resolver
Expand All @@ -54,11 +56,15 @@ cloudflare-mcp/
│ ├── worker.test.ts # Real worker end to end via exports.default.fetch
│ └── *.test.ts
├── scripts/
│ └── seed-r2.ts # Seed OpenAPI spec to R2 bucket
│ ├── seed-r2.ts # Seed OpenAPI spec to R2 bucket
│ └── generate-tools.ts # Regenerate src/generated/forge-mcp with forge
├── forge.json # The forge commit that generates src/generated/forge-mcp
├── .github/workflows/
│ ├── ci.yml # PR validation
│ ├── forge-tools.yml # Six-hourly regeneration PR for src/generated/forge-mcp
│ └── bonk.yml # AI code review
├── wrangler.jsonc # Workers config (dev/staging/prod)
├── wrangler.forge-tools.jsonc # Forge tools service config (dev/staging/prod)
├── .oxfmtrc.json # oxfmt formatter config
└── README.md
```
Expand All @@ -78,6 +84,8 @@ Node 22+ required.
| `npm run dev` | Start local dev server (wrangler dev) |
| `npm run deploy` | Deploy to staging |
| `npm run deploy:prod` | Deploy to production |
| `npm run deploy:forge-tools` / `deploy:forge-tools:prod` | Deploy the forge tools service to staging / production |
| `npm run generate:tools` | Regenerate `src/generated/forge-mcp` (needs `FORGE_BINARY`) |
| `npm run types` | Generate worker type definitions |
| `npm run typecheck` | TypeScript type checking (no emit) |
| `npm run lint` | Lint with oxlint |
Expand Down Expand Up @@ -162,6 +170,18 @@ The consent page offers minimal, read-only and full-access templates. Minimal is
- `mcp-tools.json` holds the final wire JSON Schemas (including the session-independent optional `account_id`) plus minimal request-routing metadata. Each isolate builds the `tools/list` payload from it once and serves it unchanged; `tools/call` lazily validates/dispatches only the requested operation with Zod. No per-endpoint SDK tools are registered
- `src/isolate-cache.ts` caches all three artifacts for one hour in warm isolates. Concurrent requests on a cold isolate share one load, and a failed load is not cached. There is no fallback: a missing `mcp-tools.json` fails the request, so seed R2 (`npm run seed:staging` / `seed:prod`) before deploying a change to its key or shape

### Forge-generated direct tools (`?forge=true`, opt-in)

A second direct-tool surface whose code is generated by forge rather than built from the spec at runtime, while it is tried out:

- `src/generated/forge-mcp/` is TypeScript that `forge openapi.forge.json --target mcp` writes, committed and never edited by hand. Each group's `tools.ts` has its tools: schema, hints, the method's extensions, and a `request(args)` function that builds the API request. `schemas.ts` files hold the shared model schemas, `index.ts` exports every tool, `README.md` lists the methods without a tool and why, and `_/` is forge's runtime for serving them on an `McpServer`. oxfmt and oxlint skip it
- The MCP target lives on the long-lived `forge-mcp` branch of a forge fork; `forge.json` pins the commit. `FORGE_BINARY=… npm run generate:tools` regenerates locally. `.github/workflows/forge-tools.yml` builds that forge, regenerates from the latest `openapi.forge.json` every six hours, runs the type check and tests, and opens or updates the `bot/forge-tools` pull request when the code changes, as the agent-think GitHub App when its `AGENT_THINK_APP_ID` / `AGENT_THINK_PRIVATE_KEY` secrets are set (otherwise `GITHUB_TOKEN`, whose pull requests don't start CI). Merging it deploys. To change the generator, bump `forge.json`'s `ref`
- The generated tools run in their own Worker, the **forge tools service** (`src/forge-tools/worker.ts`, `wrangler.forge-tools.jsonc`, `cloudflare-api-mcp-forge-tools[-staging|-dev]`), which the main Worker reaches through its `FORGE_TOOLS` service binding. Code Mode isolates never compile the ~10 MB of generated code: workerd compiles every module a Worker uploads at startup, so in the main Worker a lazy import still cost ~27 ms per isolate, and a separate uploaded module didn't help. The service holds no credentials, makes no requests, and has no route. Its RPC methods are `list()`, the tools as `tools/list` sends them, and `plan(name, args, { baseUrl, sessionAccountId })`, the request a call makes (`src/forge-tools/plan.ts`)
- `src/forge-tools/cloudflare.ts` applies the Cloudflare policy once per service isolate: `account_id` is optional with one description, and merged `account_or_zone` tools name their two values. Permissions come from each tool's `x-api-token-group` extension, which clients never see
- `src/tools/forge-tools.ts`, in the main Worker, serves `?forge=true`: `docs` and `whoami` itself, and the generated tools from the service. It sends each planned request with the session's token, which never leaves the main Worker, and explains refused calls and steps up OAuth scopes like the other direct tools
- Deploy the service before the main Worker in each environment (`npm run deploy:forge-tools` / `deploy:forge-tools:prod`), since the main Worker binds to it. `npm run dev` runs both, and `npm run types` reads both configs to type the binding. Vitest builds the service with `wrangler deploy --dry-run` and runs it as an auxiliary Worker, so tests call it over the real binding
- Tool names are forge's (`d1_list`, `zones_list`), not `src/openapi.ts`'s, so `?forge=true` is a separate opt-in; `?codemode=false` is unchanged

### Skills over MCP

The server implements the MCP Skills extension (`io.modelcontextprotocol/skills`, [SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)) in both tool modes and serves the skills from [`cloudflare/skills`](https://github.com/cloudflare/skills).
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

### Forge-generated tools (preview)

`?forge=true` serves direct tools whose code [forge](https://github.com/cloudflare/forge) generates from the Cloudflare API's OpenAPI document instead. Tool names follow the `cf` CLI (`zones_list`, `d1_list`), and request bodies are typed, nested JSON. It is a preview: names and schemas may change.

```
https://mcp.cloudflare.com/mcp?forge=true
```

### Missing Permissions

When Cloudflare refuses a call because the OAuth connection lacks a scope, the server asks the client to re-authorize with that scope added to the ones it already has. Claude and other clients that follow the [MCP authorization spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#scope-challenge-handling) get a `403 insufficient_scope` challenge. Codex and ChatGPT get it in the tool result, which is where they look for it. Either way, the tool error says which scope is missing.
Expand Down
5 changes: 5 additions & 0 deletions forge.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"$comment": "The forge that generates src/generated/forge-mcp: a commit of the forge-mcp branch. Bump ref to pick up generator changes; CI builds it.",
"repository": "mattzcarey/forge",
"ref": "3f7e168c5e8fe33c71802833fa7a54a795376bae"
}
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
"description": "MCP server for the entire Cloudflare API using codemode",
"type": "module",
"scripts": {
"dev": "wrangler dev",
"dev": "wrangler dev -c wrangler.jsonc -c wrangler.forge-tools.jsonc",
"deploy": "wrangler deploy --env staging",
"deploy:forge-tools": "wrangler deploy -c wrangler.forge-tools.jsonc --env staging",
"deploy:forge-tools:prod": "wrangler deploy -c wrangler.forge-tools.jsonc --env production",
"deploy:prod": "wrangler deploy --env production",
"types": "wrangler types --env-file .env_example",
"types": "wrangler types --env-file .env_example -c wrangler.jsonc -c wrangler.forge-tools.jsonc",
"typecheck": "tsc --noEmit",
"lint": "oxlint src/",
"lint": "oxlint src/ --ignore-pattern 'src/generated/**'",
"format": "oxfmt --write src/ tests/ scripts/ vitest.config.ts",
"generate:tools": "tsx scripts/generate-tools.ts",
"format:check": "oxfmt --check src/ tests/ scripts/ vitest.config.ts",
"test": "vitest run",
"test:events": "vitest run tests/events-e2e.test.ts tests/ans-bridge.test.ts",
Expand Down
84 changes: 84 additions & 0 deletions scripts/generate-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Regenerates `src/generated/forge-mcp`, the direct tools served at
* `/mcp?forge=true`, with forge's MCP target:
*
* FORGE_BINARY=path/to/forge npm run generate:tools [-- --spec openapi.forge.json] [-- --summary summary.md]
*
* `forge.json` names the forge commit to build; `.github/workflows/forge-tools.yml`
* builds it, runs this every six hours, and opens a pull request when the
* generated code changes. Without `--spec`, the latest `openapi.forge.json`
* Forge release is downloaded. `--summary` writes the tools added and removed
* as Markdown, for the pull request.
*/
import { execFileSync } from 'node:child_process'
import {
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { parseArgs } from 'node:util'

const SPEC_URL = 'https://github.com/cloudflare/forge/releases/latest/download/openapi.forge.json'
const OUT = 'src/generated/forge-mcp'

const { values } = parseArgs({ options: { spec: { type: 'string' }, summary: { type: 'string' } } })
const forge = process.env.FORGE_BINARY ?? 'forge'
const tmp = mkdtempSync(join(tmpdir(), 'forge-mcp-'))

try {
let spec = values.spec
if (!spec) {
console.log(`Downloading ${SPEC_URL}`)
const response = await fetch(SPEC_URL)
if (!response.ok) throw new Error(`Failed to download the spec: HTTP ${response.status}`)
spec = join(tmp, 'openapi.forge.json')
writeFileSync(spec, Buffer.from(await response.arrayBuffer()))
}

const out = join(tmp, 'out')
execFileSync(forge, [spec, '--target', 'mcp', '--out', out], { stdio: 'inherit' })

const before = toolNames(OUT)
rmSync(OUT, { recursive: true, force: true })
renameSync(out, OUT)
const after = toolNames(OUT)

const added = [...after].filter((name) => !before.has(name)).sort()
const removed = [...before].filter((name) => !after.has(name)).sort()
console.log(`${after.size} tools: ${added.length} added, ${removed.length} removed`)
if (values.summary) writeFileSync(values.summary, summary(after.size, added, removed))
} finally {
rmSync(tmp, { recursive: true, force: true })
}

/** The names of the generated tools in `dir`, read from each `tools.ts`. */
function toolNames(dir: string): Set<string> {
const names = new Set<string>()
if (!existsSync(dir)) return names
for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) {
if (entry.isFile() && entry.name === 'tools.ts') {
const source = readFileSync(join(entry.parentPath, entry.name), 'utf8')
for (const match of source.matchAll(/^ {4}name: "([^"]+)",$/gm)) names.add(match[1]!)
}
}
return names
}

function summary(count: number, added: string[], removed: string[]): string {
const list = (names: string[]) => names.map((name) => `- \`${name}\``).join('\n')
return [
`${count} tools: ${added.length} added, ${removed.length} removed.`,
removed.length > 0
? `\n**Removed** (clients calling these will get "not found"):\n\n${list(removed)}`
: '',
added.length > 0 ? `\n<details><summary>Added</summary>\n\n${list(added)}\n\n</details>` : ''
]
.filter(Boolean)
.join('\n')
}
62 changes: 62 additions & 0 deletions src/forge-tools/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { InputSchema, JsonSchema, Tool } from '../generated/forge-mcp'
import {
ACCOUNT_OR_ZONE_DESCRIPTION,
ACCOUNT_OR_ZONE_ID_DESCRIPTION,
ACCOUNT_OR_ZONE_VALUES,
FORGE_ACCOUNT_ID_DESCRIPTION,
PERMISSIONS_EXTENSION
} from './policy'

/**
* The Cloudflare policy on top of a generated tool:
*
* - `account_id` is optional with one description, so tool lists don't depend
* on the session; the server fills it in when the session has one account.
* - Forge merges methods that exist under both accounts and zones into
* `/{account_or_zone}/{account_or_zone_id}/…`. `account_or_zone` gets its
* two values, and `account_or_zone_id` is optional like `account_id`. The
* document doesn't describe them yet (cloudflare/forge#59); once it does,
* the generator can carry them and this can go.
*
* The tool's request code is kept, so calls build the same requests.
*/
export function cloudflareTool(tool: Tool): Tool {
const properties = tool.inputSchema.properties
if (!properties || !('account_id' in properties || 'account_or_zone' in properties)) return tool

const changed: Record<string, JsonSchema> = { ...properties }
const optional = new Set<string>()
if ('account_id' in changed) {
changed.account_id = { type: 'string', description: FORGE_ACCOUNT_ID_DESCRIPTION }
optional.add('account_id')
}
if ('account_or_zone' in changed) {
changed.account_or_zone = {
type: 'string',
enum: [...ACCOUNT_OR_ZONE_VALUES],
description: ACCOUNT_OR_ZONE_DESCRIPTION
}
}
if ('account_or_zone_id' in changed) {
changed.account_or_zone_id = { type: 'string', description: ACCOUNT_OR_ZONE_ID_DESCRIPTION }
optional.add('account_or_zone_id')
}
const required = (tool.inputSchema.required ?? []).filter((key) => !optional.has(key))
const { required: _required, ...schema } = tool.inputSchema
const inputSchema: InputSchema = {
...schema,
properties: changed,
...(required.length > 0 && { required })
}
return { ...tool, inputSchema }
}

/** The API token permissions `tool` accepts, if its method lists them. */
export function permissionsOf(tool: Tool): string[] | undefined {
const value = tool.extensions?.[PERMISSIONS_EXTENSION]
return Array.isArray(value) &&
value.length > 0 &&
value.every((item) => typeof item === 'string' && item.length > 0)
? value
: undefined
}
Loading
Loading