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
  •  
  •  
  •  
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,14 @@ The consent page offers minimal, read-only and full-access templates. Minimal is
`?codemode=false` asks for direct tools; `?experimentalForge=true` changes how they are built: from code forge generates instead of `mcp-tools.json`. The flag is opt-in while the forge tools soak, and is meant to become how `?codemode=false` works. Code Mode ignores it, since it already reads the Forge spec. The earlier `?forge=true` preview param is gone; it now serves Code Mode.


- `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. Each tool also has `responses`: the JSON Schema of every response body the method declares, by status, from the shared `outputs.ts` models; `tools/list` never sends them (see Response schema checks). Each tool with a JSON body on every 2xx response also has `outputSchema`, that body's schema (or `anyOf` the distinct ones) with the models it reaches in `$defs`; `listTools` sends it only when asked (see the output schema bullet below). `schemas.ts` files hold the shared argument models, `source.json` the `openapi.forge.json` release they came from (written by `generate-tools.ts`), `index.ts` exports every tool, `README.md` lists the methods without a tool and why, and `_/tool.ts` is forge's runtime: `listTools`, `toolsByName`, and `buildRequest`, which returns a call's request as plain data (`{ method, url, headers, body? }`) that `plan()` hands over RPC as it is. Forge generates no server. oxfmt and oxlint skip it
- `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. Each tool also has `responses`: the JSON Schema of every response body the method declares, by status, from the shared `outputs.ts` models; `tools/list` never sends them (see Response schema checks). Each tool with a JSON body on every 2xx response also has `outputSchema`, that body's schema (or `anyOf` the distinct ones) with the models it reaches in `$defs`; `listTools` sends it only when asked (see the output schema bullet below). `schemas.ts` files hold the shared argument models, `source.json` the `openapi.forge.json` release they came from (written by `generate-tools.ts`), `index.ts` exports every tool, `README.md` lists the methods without a tool and why, and `_/tool.ts` is forge's runtime: `listTools`, `toolsByName`, `param`/`pathParam`, which read a parameter from the `path`, `query` or `headers` argument, and `buildRequest`, which returns a call's request as plain data (`{ method, url, headers, body? }`) that `plan()` hands over RPC as it is. Forge generates no server. oxfmt and oxlint skip it
- The MCP target is proposed for `cloudflare/forge`'s main in cloudflare/forge#71; until it merges, `forge.json` pins a commit of that PR's `mcp` branch. `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. That pull request also moves `source.json`, the spec pin Code Mode follows, and its summary counts the tools with permissions before and after, warning when a release drops many. The pull request is opened with `GITHUB_TOKEN`, so CI doesn't run on it; the workflow's own type check and tests are the check. Merging it deploys both Workers through Workers Builds. 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 `search(code)`, which runs Code Mode's search code in a `LOADER` sandbox against the spec, `listJson({ outputSchema })`, the tools as `tools/list` sends them as one JSON string, with or without their output schemas, which the main Worker parses once per isolate (`getForgeToolList()` in `src/isolate-cache.ts`; decoding ~3,000 nested objects over RPC cost ~200–300 ms of CPU per `tools/list`), `plan(name, args, { baseUrl, sessionAccountId })`, the request a call makes (`src/forge-tools/plan.ts`), `checkResponse(...)`, which checks a trimmed response against the generated response schemas, and `specArtifact(name)`, which streams one of the pinned release's spec artifacts (`spec.json`, `products.json`, `mcp-tools.json`). `scripts/generate-tools.ts` writes those to `src/generated/spec/` as text modules from the same release as the tools (`src/forge-tools/spec.ts` imports them), so a pin bump carries both. It returns a stream because `spec.json` (~27 MB) is close to RPC's 32 MiB message limit. The service bundle is ~51 MB of Workers' 64 MiB limit, about 31 MB of it the spec
- `src/forge-tools/cloudflare.ts` applies the Cloudflare policy once per service isolate: `account_id` is optional with one description, and so is a merged tool's `account_or_zone_id` (the document gives `account_or_zone` its values). Permissions come from each tool's `x-api-token-group` extension, which clients never see
- `src/forge-tools/cloudflare.ts` applies the Cloudflare policy once per service isolate: `path.account_id` is optional with one description, and so is a merged tool's `path.account_or_zone_id` (the document gives `account_or_zone` its values); when that leaves no path parameter required, `path` is optional too. 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 `?codemode=false&experimentalForge=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
- `&experimentalOutputSchema=true` (only with `?codemode=false&experimentalForge=true`) turns on output schemas for 2026-07-28 requests: `tools/list` sends each tool's generated `outputSchema`, and a successful call returns the whole response body, which its output schema describes, as `structuredContent`, never truncated, next to the text, which stays the whole response. A request is 2026-07-28 when its `_meta` envelope names a protocol version (`ctx.mcpReq.envelope`); 2025-era requests are served as without the flag, since their clients expect object output schemas and most generated ones are a `$ref`, `allOf` or scalar. The output schemas come straight from the generator, so the Worker only picks a listing. They are the soak's schemas, so a strict client will reject results the response schema checks flag (Hyperdrive's write-only `origin.password`, cloudflare/forge#63). The listing grows from ~6 MB to ~12 MB (0.64 to 0.98 MB gzipped), loaded only when asked for
- Workers Builds deploys the service and the main Worker on every push to `main`. Ship a new RPC method in the service before the main Worker calls it, and in a new environment deploy the service first (`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 switching `?codemode=false` to forge renames every tool for its clients; that is why the flag soaks first
- Tool names are forge's: each group and the method in camelCase, joined by `_` (`d1_list`, `zeroTrust_access_applications_list`), not `src/openapi.ts`'s. Arguments are grouped by where they go: `path`, `query` and `headers`, each keyed by wire name, plus `body` (`{ path: { account_id }, query: { per_page }, body }`), not the endpoint tools' flat arguments. The generated input schemas set `additionalProperties: false`, so clients that validate refuse unknown arguments; forge's `checkArguments` refuses them too, at the top level and inside `path`, `query` and `headers`, and says where a misplaced parameter goes or what a place takes; `plan()` calls it before filling in the account. Wrong types are left to clients, which validate against the generated types, and to the API, whose refusal the result shows. A missing account is asked for as `path.account_id`. So switching `?codemode=false` to forge renames every tool and changes its arguments for its clients; that is why the flag soaks first

### Skills over MCP

Expand Down
2 changes: 1 addition & 1 deletion forge.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$comment": "The forge that generates src/generated/forge-mcp: a commit of cloudflare/forge. Bump ref to pick up generator changes; CI builds it.",
"repository": "cloudflare/forge",
"ref": "580804fccf9c3f04d0449d2bc22506a2f2a8e419"
"ref": "b380789daec251e3f01168101e80c18ce1d19513"
}
9 changes: 7 additions & 2 deletions src/auth/account-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,16 @@ function formatAccounts(accounts: AccountSchema[]): string {
* Error for a call that needs an account when none was passed or auto-resolved.
* Tool results reach only the calling session, so unlike tool metadata they can
* name its accounts. `discoveryGuidance` is used when the list isn't known.
* `argument` names where the account goes, such as `path.account_id`.
*/
export function missingAccountMessage(props: AuthProps, discoveryGuidance: string): string {
export function missingAccountMessage(
props: AuthProps,
discoveryGuidance: string,
argument = 'account_id'
): string {
const accounts = knownAccounts(props)
if (accounts?.length) {
return `No account selected. Pass account_id with one of this session's accounts:\n${formatAccounts(accounts)}`
return `No account selected. Pass ${argument} with one of this session's accounts:\n${formatAccounts(accounts)}`
}
if (accounts)
return 'No account selected: no Cloudflare accounts are authorized for this session.'
Expand Down
52 changes: 36 additions & 16 deletions src/forge-tools/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,64 @@ import {
PERMISSIONS_EXTENSION
} from './policy'

/** The account path parameters the server can fill in, with their descriptions. */
const OPTIONAL_PATH_PARAMS: Readonly<Record<string, string>> = {
account_id: FORGE_ACCOUNT_ID_DESCRIPTION,
account_or_zone_id: ACCOUNT_OR_ZONE_ID_DESCRIPTION
}

/**
* 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.
* - `path.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}/…`. The document gives
* `account_or_zone` its two values (cloudflare/forge#59);
* `account_or_zone_id` is optional here like `account_id`, since the server
* fills in the session's account for `accounts`.
* `path.account_or_zone_id` is optional here like `path.account_id`, since
* the server fills in the session's account for `accounts`.
*
* When that leaves no path parameter required, `path` itself is optional.
* 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_id' in properties))
const path = tool.inputSchema.properties?.path as PathSchema | undefined
const params = path?.properties
if (!path || !params || !Object.keys(params).some((key) => key in OPTIONAL_PATH_PARAMS)) {
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')
const changed: Record<string, JsonSchema> = { ...params }
for (const [key, description] of Object.entries(OPTIONAL_PATH_PARAMS)) {
if (key in changed) changed[key] = { type: 'string', 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 pathRequired = (path.required ?? []).filter((key) => !(key in OPTIONAL_PATH_PARAMS))
const { required: _pathRequired, ...pathRest } = path
const pathSchema: JsonSchema = {
...pathRest,
properties: changed,
...(pathRequired.length > 0 && { required: pathRequired })
}
const required = (tool.inputSchema.required ?? []).filter((key) => !optional.has(key))

const required = (tool.inputSchema.required ?? []).filter(
(key) => key !== 'path' || pathRequired.length > 0
)
const { required: _required, ...schema } = tool.inputSchema
const inputSchema: InputSchema = {
...schema,
properties: changed,
properties: { ...tool.inputSchema.properties, path: pathSchema },
...(required.length > 0 && { required })
}
return { ...tool, inputSchema }
}

/** The `path` argument of a generated tool: an object of its path parameters. */
interface PathSchema extends JsonSchema {
readonly properties?: Readonly<Record<string, JsonSchema>>
readonly required?: readonly string[]
}

/** The API token permissions `tool` accepts, if its method lists them. */
export function permissionsOf(tool: Tool): string[] | undefined {
const value = tool.extensions?.[PERMISSIONS_EXTENSION]
Expand Down
45 changes: 32 additions & 13 deletions src/forge-tools/plan.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
buildRequest,
checkArguments,
InvalidArguments,
param,
type Args,
type HttpRequest,
type Tool
Expand All @@ -25,8 +27,11 @@ export interface PlanOptions {
export type CallPlan =
| { readonly kind: 'unknown' }
| { readonly kind: 'invalid'; readonly message: string }
/** The tool takes an account, the caller left it out, and the session has more than one. */
| { readonly kind: 'needs_account' }
/**
* The tool takes an account, the caller left it out, and the session has
* more than one. `argument` is where it goes, such as `path.account_id`.
*/
| { readonly kind: 'needs_account'; readonly argument: string }
| (HttpRequest & {
readonly kind: 'request'
/** The tool's path template, such as `/accounts/{account_id}/d1/database`. */
Expand All @@ -44,22 +49,24 @@ export type CallPlan =
* as it is.
*/
export function planCall(tool: Tool, given: Args, options: PlanOptions): CallPlan {
const key = accountKey(tool, given)
let args = given
if (key && given[key] === undefined) {
if (!options.sessionAccountId) return { kind: 'needs_account' }
args = { ...given, [key]: options.sessionAccountId }
}

let request: HttpRequest
let accountId: string | undefined
try {
// Before filling in the account, so a misplaced account_id is reported as such.
checkArguments(tool, given)
const key = accountKey(tool, given)
let args = given
if (key && param(given, 'path', key) === undefined) {
if (!options.sessionAccountId) return { kind: 'needs_account', argument: `path.${key}` }
args = withPathParam(given, key, options.sessionAccountId)
}
request = buildRequest(tool, args, options.baseUrl)
accountId = accountIdOf(args)
} catch (error) {
if (error instanceof InvalidArguments) return { kind: 'invalid', message: error.message }
throw error
}
const permissions = permissionsOf(tool)
const accountId = accountIdOf(args)
return {
kind: 'request',
...request,
Expand All @@ -69,16 +76,28 @@ export function planCall(tool: Tool, given: Args, options: PlanOptions): CallPla
}
}

/** The argument that names the account, when the tool takes one for these arguments. */
/** The path parameter that names the account, when the tool takes one for these arguments. */
function accountKey(tool: Tool, args: Args): string | undefined {
if (tool.path.includes('{account_id}')) return 'account_id'
if (tool.path.startsWith(ACCOUNT_OR_ZONE_PREFIX) && args.account_or_zone === 'accounts') {
if (
tool.path.startsWith(ACCOUNT_OR_ZONE_PREFIX) &&
param(args, 'path', 'account_or_zone') === 'accounts'
) {
return 'account_or_zone_id'
}
return undefined
}

/** Returns `args` with the path parameter `key` set to `value`. */
function withPathParam(args: Args, key: string, value: string): Args {
const path = (args.path ?? {}) as Readonly<Record<string, unknown>>
return { ...args, path: { ...path, [key]: value } }
}

function accountIdOf(args: Args): string | undefined {
const id = args.account_or_zone === 'accounts' ? args.account_or_zone_id : args.account_id
const id =
param(args, 'path', 'account_or_zone') === 'accounts'
? param(args, 'path', 'account_or_zone_id')
: param(args, 'path', 'account_id')
return typeof id === 'string' ? id : undefined
}
Loading
Loading