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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,8 @@ 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 `_/` 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 `cloudflare/forge`, kept rebased on its main; `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. 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`
- `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
- 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. 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 `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`), and `checkResponse(...)`, which checks a trimmed response against the generated response schemas.
- `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/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
Expand Down
4 changes: 2 additions & 2 deletions 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 the forge-mcp branch. Bump ref to pick up generator changes; CI builds it.",
"$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": "ce4183c985f34c384d83718b5d502ac3517f6920"
"ref": "580804fccf9c3f04d0449d2bc22506a2f2a8e419"
}
39 changes: 16 additions & 23 deletions src/forge-tools/plan.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { Args, Tool } from '../generated/forge-mcp'
import {
buildRequest,
InvalidArguments,
type Args,
type HttpRequest,
type Tool
} from '../generated/forge-mcp/_/tool'
import { permissionsOf } from './cloudflare'

const ACCOUNT_OR_ZONE_PREFIX = '/{account_or_zone}/{account_or_zone_id}'
Expand All @@ -21,55 +27,42 @@ export type CallPlan =
| { 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' }
| {
| (HttpRequest & {
readonly kind: 'request'
readonly method: string
readonly url: string
readonly headers: Readonly<Record<string, string>>
readonly body?: string
/** The tool's path template, such as `/accounts/{account_id}/d1/database`. */
readonly template: string
/** The API token permissions the method accepts. */
readonly permissions?: readonly string[]
/** The account the request names, if any, for explaining a refusal. */
readonly accountId?: string
}
})

/**
* Plans calling `tool` with `given`: fills in the session's account where the
* tool takes one and the caller left it out, then builds the request with the
* tool's generated code.
* tool's generated code. The request is plain data, so the plan crosses RPC
* as it is.
*/
export async function planCall(
tool: Tool,
given: Args,
options: PlanOptions,
build: (tool: Tool, args: Args, baseUrl: string) => Request,
isInvalidArguments: (error: unknown) => boolean
): Promise<CallPlan> {
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: Request
let request: HttpRequest
try {
request = build(tool, args, options.baseUrl)
request = buildRequest(tool, args, options.baseUrl)
} catch (error) {
if (isInvalidArguments(error)) return { kind: 'invalid', message: (error as Error).message }
if (error instanceof InvalidArguments) return { kind: 'invalid', message: error.message }
throw error
}
const body = request.body ? await request.text() : undefined
const permissions = permissionsOf(tool)
const accountId = accountIdOf(args)
return {
kind: 'request',
method: request.method,
url: request.url,
headers: Object.fromEntries(request.headers),
...(body !== undefined && { body }),
...request,
template: tool.path,
...(permissions && { permissions }),
...(accountId && { accountId })
Expand Down
19 changes: 3 additions & 16 deletions src/forge-tools/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,7 @@
* the main Worker reaches it through its `FORGE_TOOLS` service binding.
*/
import { WorkerEntrypoint } from 'cloudflare:workers'
import {
buildRequest,
InvalidArguments,
listTools,
tools as generatedTools,
toolsByName,
type Args
} from '../generated/forge-mcp'
import { listTools, tools as generatedTools, toolsByName, type Args } from '../generated/forge-mcp'
import { cloudflareTool } from './cloudflare'
import { planCall, type CallPlan, type PlanOptions } from './plan'
import {
Expand Down Expand Up @@ -135,16 +128,10 @@ export default class ForgeTools extends WorkerEntrypoint {
* The request calling the tool `name` with `args` makes, or why it can't be
* made, and what of a successful response its output schema describes.
*/
async plan(name: string, args: Args, options: PlanOptions): Promise<ToolCallPlan> {
plan(name: string, args: Args, options: PlanOptions): ToolCallPlan {
const tool = toolsByName(tools).get(name)
if (!tool) return { kind: 'unknown' }
const plan = await planCall(
tool,
args,
options,
buildRequest,
(error) => error instanceof InvalidArguments
)
const plan = planCall(tool, args, options)
return plan.kind === 'request' && tool.outputSchema
? { ...plan, payload: payloadKind(tool) }
: plan
Expand Down
2 changes: 1 addition & 1 deletion src/generated/forge-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# MCP tools

3033 tools in 1033 groups. `index.ts` exports them as `tools`, with the runtime in `_/tool.ts` that builds their requests; `_/server.ts` serves them on an `McpServer`.
3041 tools in 1035 groups. `index.ts` exports them as `tools`, with the runtime in `_/tool.ts` that lists them and builds their requests.

## Methods without a tool

Expand Down
20 changes: 0 additions & 20 deletions src/generated/forge-mcp/_/index.ts

This file was deleted.

156 changes: 0 additions & 156 deletions src/generated/forge-mcp/_/server.ts

This file was deleted.

39 changes: 34 additions & 5 deletions src/generated/forge-mcp/_/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ export type QueryParam = readonly [name: string, value: unknown, explode: boolea
/** A header parameter: its name and its value. */
export type HeaderParam = readonly [name: string, value: unknown];

/**
* An HTTP request as plain data, so it crosses RPC or `postMessage` as it is.
* `fetch(request.url, request)` sends it. Header names are lower case.
*/
export interface HttpRequest {
readonly method: string;
readonly url: string;
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
}

/**
* The request a tool call makes, relative to the API's base URL. The path has
* its parameters substituted and encoded; query and header values are
Expand Down Expand Up @@ -112,14 +123,15 @@ export function pathParam(args: Args, key: string): string {
* Returns the HTTP request that calling `tool` with `args` makes, at
* `baseUrl`. Query parameters are serialized in OpenAPI's default `form`
* style. Values are not validated against the schema; the API does that.
* Throws {@link InvalidArguments}.
*/
export function buildRequest(tool: Tool, args: Args, baseUrl: string): Request {
export function buildRequest(tool: Tool, args: Args, baseUrl: string): HttpRequest {
const missing = (tool.inputSchema.required ?? []).filter((key) => args[key] === undefined);
if (missing.length > 0) {
throw new InvalidArguments(`Missing required arguments: ${missing.join(', ')}`);
}
const api = tool.request(args);
const url = new URL(baseUrl.replace(/\/+$/, '') + api.path);
const url = new URL(withoutTrailingSlashes(baseUrl) + api.path);
for (const [name, value, explode] of api.query ?? []) {
appendQuery(url.searchParams, name, value, explode);
}
Expand All @@ -129,12 +141,29 @@ export function buildRequest(tool: Tool, args: Args, baseUrl: string): Request {
headers.set(name, Array.isArray(value) ? value.map((item) => scalar(name, item)).join(',') : scalar(name, value));
}
}
const init: RequestInit = { method: tool.method, headers };
let body: string | undefined;
if (api.body && api.body.value !== undefined) {
headers.set('Content-Type', api.body.mediaType);
init.body = JSON.stringify(api.body.value);
body = JSON.stringify(api.body.value);
}
return {
method: tool.method,
url: url.href,
headers: Object.fromEntries(headers),
...(body !== undefined && { body }),
};
}

/**
* Returns `url` without the `/`s it ends with. A loop rather than a regular
* expression, which would take quadratic time on many `/`s.
*/
function withoutTrailingSlashes(url: string): string {
let end = url.length;
while (end > 0 && url[end - 1] === '/') {
end--;
}
return new Request(url, init);
return url.slice(0, end);
}

/** Appends a query parameter in the `form` style: `a=1&a=2` exploded, `a=1,2` not. */
Expand Down
Loading
Loading