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
  •  
  •  
  •  
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,12 @@ 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). `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
- `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`
- 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()`, the tools as `tools/list` sends them as one JSON string, 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.
- 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
- `&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 parsed body as `structuredContent`, never truncated, next to the text. 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

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 the forge-mcp branch. Bump ref to pick up generator changes; CI builds it.",
"repository": "cloudflare/forge",
"ref": "9561734e4fc4c8082c5f09ea5f4ff0dfc031aefd"
"ref": "ce4183c985f34c384d83718b5d502ac3517f6920"
}
14 changes: 10 additions & 4 deletions src/forge-tools/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* (`src/generated/forge-mcp`) and serves them to the main Worker over RPC, so
* isolates serving Code Mode never compile them.
*
* It holds no credentials and makes no requests. `list` returns the tools as
* `tools/list` sends them; `plan` builds the request a call makes, which the
* It holds no credentials and makes no requests. `listJson` returns the tools
* as `tools/list` sends them, optionally with their output schemas; `plan` builds the request a call makes, which the
* main Worker sends with the session's token; `checkResponse` checks a
* response against the generated response schemas. It has no public route:
* the main Worker reaches it through its `FORGE_TOOLS` service binding.
Expand All @@ -27,6 +27,7 @@ import { concretePaths } from '../spec-processor'
const tools = generatedTools.map(cloudflareTool)

let listing: string | undefined
let listingWithOutput: string | undefined
let byOperation: Map<string, (typeof tools)[number]> | undefined

/**
Expand Down Expand Up @@ -65,9 +66,14 @@ export default class ForgeTools extends WorkerEntrypoint {
/**
* The generated tools as `tools/list` sends them, as one JSON string built
* once per isolate. A string crosses RPC as bytes; ~3,000 nested objects
* cost the caller hundreds of milliseconds of CPU to decode.
* cost the caller hundreds of milliseconds of CPU to decode. With
* `outputSchema`, each tool carries its generated output schema.
*/
listJson(): string {
listJson({ outputSchema = false }: { outputSchema?: boolean } = {}): string {
if (outputSchema) {
listingWithOutput ??= JSON.stringify(listTools(tools, { outputSchema: true }))
return listingWithOutput
}
listing ??= JSON.stringify(listTools(tools))
return listing
}
Expand Down
37 changes: 30 additions & 7 deletions src/generated/forge-mcp/_/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export interface Tool {
readonly title?: string;
readonly description: string;
readonly inputSchema: InputSchema;
/**
* The JSON Schema of the tool's structured result, the JSON body of a
* successful response, with the models it refers to in `$defs`. A tool
* without one has a successful response with no JSON body. `listTools`
* sends it only when asked.
*/
readonly outputSchema?: JsonSchema;
readonly annotations: ToolAnnotations;
/** The HTTP method of the API method, such as `GET`. */
readonly method: string;
Expand Down Expand Up @@ -170,22 +177,38 @@ function scalar(name: string, value: unknown): string {
throw new InvalidArguments(`${name} must be a string, number or boolean`);
}

/** How {@link listTools} lists the tools. */
export interface ListOptions {
/** Send each tool's output schema. Defaults to `false`. */
readonly outputSchema?: boolean;
}

const listings = new WeakMap<readonly Tool[], readonly McpTool[]>();
const listingsWithOutput = new WeakMap<readonly Tool[], readonly McpTool[]>();
const indexes = new WeakMap<readonly Tool[], ReadonlyMap<string, Tool>>();

/**
* Returns `tools` as `tools/list` sends them, without their request code,
* method, path, extensions and response schemas. It is built once for each array of tools, and
* the same array every time after.
* method, path, extensions and response schemas, and without their output
* schemas unless `options.outputSchema`. Each listing is built once for each
* array of tools, and the same array every time after.
*/
export function listTools(tools: readonly Tool[]): readonly McpTool[] {
let listing = listings.get(tools);
export function listTools(tools: readonly Tool[], { outputSchema = false }: ListOptions = {}): readonly McpTool[] {
const cache = outputSchema ? listingsWithOutput : listings;
let listing = cache.get(tools);
if (!listing) {
listing = tools.map(
({ request: _request, method: _method, path: _path, extensions: _extensions, responses: _responses, ...tool }) =>
tool as McpTool,
({
request: _request,
method: _method,
path: _path,
extensions: _extensions,
responses: _responses,
outputSchema: output,
...tool
}) => (outputSchema && output ? { ...tool, outputSchema: output } : tool) as McpTool,
);
listings.set(tools, listing);
cache.set(tools, listing);
}
return listing;
}
Expand Down
6 changes: 6 additions & 0 deletions src/generated/forge-mcp/abuseReports/appeals/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export const tools: readonly Tool[] = [
"report_id"
],
},
outputSchema: {
"$ref": "#/$defs/abuse-reports_AppealEligibility",
$defs: {
"abuse-reports_AppealEligibility": abuse_reports_AppealEligibility_output,
},
},
annotations: {
"destructiveHint": false,
"idempotentHint": true,
Expand Down
24 changes: 24 additions & 0 deletions src/generated/forge-mcp/abuseReports/emails/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ export const tools: readonly Tool[] = [
"report_id"
],
},
outputSchema: {
"properties": {
"emails": {
"items": {
"$ref": "#/$defs/abuse-reports_EmailListItem"
},
"type": "array"
}
},
"required": [
"emails"
],
"type": "object",
$defs: {
"abuse-reports_EmailListItem": abuse_reports_EmailListItem_output,
},
},
annotations: {
"destructiveHint": false,
"idempotentHint": true,
Expand Down Expand Up @@ -179,6 +196,13 @@ export const tools: readonly Tool[] = [
"report_id"
],
},
outputSchema: {
"$ref": "#/$defs/abuse-reports_EmailListResponse_result",
$defs: {
"abuse-reports_EmailListItem": abuse_reports_EmailListItem_output,
"abuse-reports_EmailListResponse_result": abuse_reports_EmailListResponse_result_output,
},
},
annotations: {
"destructiveHint": false,
"idempotentHint": true,
Expand Down
53 changes: 53 additions & 0 deletions src/generated/forge-mcp/abuseReports/mitigations/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ export const tools: readonly Tool[] = [
"abuse-reports_MitigationType": abuse_reports_MitigationType,
},
},
outputSchema: {
"properties": {
"mitigations": {
"items": {
"$ref": "#/$defs/abuse-reports_MitigationListItem"
},
"type": "array"
}
},
"required": [
"mitigations"
],
"type": "object",
$defs: {
"abuse-reports_MitigatedEntityType": abuse_reports_MitigatedEntityType_output,
"abuse-reports_MitigationListItem": abuse_reports_MitigationListItem_output,
"abuse-reports_MitigationStatus": abuse_reports_MitigationStatus_output,
"abuse-reports_MitigationType": abuse_reports_MitigationType_output,
},
},
annotations: {
"destructiveHint": false,
"idempotentHint": true,
Expand Down Expand Up @@ -221,6 +241,39 @@ export const tools: readonly Tool[] = [
"abuse-reports_MitigationAppealRequest": abuse_reports_MitigationAppealRequest,
},
},
outputSchema: {
"anyOf": [
{
"properties": {
"result": {
"items": {
"$ref": "#/$defs/abuse-reports_MitigationListItem"
},
"type": "array"
},
"result_info": {
"$ref": "#/$defs/abuse-reports_MitigationAppealResult_result_info"
}
},
"required": [
"result"
],
"type": "object"
},
{
"$ref": "#/$defs/abuse-reports_MitigationAppealResult"
}
],
$defs: {
"abuse-reports_Message": abuse_reports_Message_output,
"abuse-reports_MitigatedEntityType": abuse_reports_MitigatedEntityType_output,
"abuse-reports_MitigationAppealResult": abuse_reports_MitigationAppealResult_output,
"abuse-reports_MitigationAppealResult_result_info": abuse_reports_MitigationAppealResult_result_info_output,
"abuse-reports_MitigationListItem": abuse_reports_MitigationListItem_output,
"abuse-reports_MitigationStatus": abuse_reports_MitigationStatus_output,
"abuse-reports_MitigationType": abuse_reports_MitigationType_output,
},
},
annotations: {
"destructiveHint": false,
"idempotentHint": false,
Expand Down
61 changes: 61 additions & 0 deletions src/generated/forge-mcp/abuseReports/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ export const tools: readonly Tool[] = [
"abuse-reports_TrademarkReport": abuse_reports_TrademarkReport,
},
},
outputSchema: {
"$ref": "#/$defs/abuse-reports_SubmitReportResponse",
$defs: {
"abuse-reports_SubmissionReportType": abuse_reports_SubmissionReportType_output,
"abuse-reports_SubmitReportResponse": abuse_reports_SubmitReportResponse_output,
"abuse-reports_SubmitReportResponse_request": abuse_reports_SubmitReportResponse_request_output,
},
},
annotations: {
"destructiveHint": false,
"idempotentHint": false,
Expand Down Expand Up @@ -216,6 +224,16 @@ export const tools: readonly Tool[] = [
"report_id"
],
},
outputSchema: {
"$ref": "#/$defs/abuse-reports_AbuseReport",
$defs: {
"abuse-reports_AbuseReport": abuse_reports_AbuseReport_output,
"abuse-reports_MitigationSummary": abuse_reports_MitigationSummary_output,
"abuse-reports_ReportStatus": abuse_reports_ReportStatus_output,
"abuse-reports_ReportType": abuse_reports_ReportType_output,
"abuse-reports_SubmitterDetails": abuse_reports_SubmitterDetails_output,
},
},
annotations: {
"destructiveHint": false,
"idempotentHint": true,
Expand Down Expand Up @@ -336,6 +354,17 @@ export const tools: readonly Tool[] = [
"report_id"
],
},
outputSchema: {
"$ref": "#/$defs/abuse-reports_SubmittedAbuseReportDetail",
$defs: {
"abuse-reports_RegistrarWhoIsFields": abuse_reports_RegistrarWhoIsFields_output,
"abuse-reports_ReportType": abuse_reports_ReportType_output,
"abuse-reports_SubmittedAbuseReportDenialReason": abuse_reports_SubmittedAbuseReportDenialReason_output,
"abuse-reports_SubmittedAbuseReportDetail": abuse_reports_SubmittedAbuseReportDetail_output,
"abuse-reports_SubmittedAbuseReportStatus": abuse_reports_SubmittedAbuseReportStatus_output,
"abuse-reports_SubmitterDetails": abuse_reports_SubmitterDetails_output,
},
},
annotations: {
"destructiveHint": false,
"idempotentHint": true,
Expand Down Expand Up @@ -446,6 +475,27 @@ export const tools: readonly Tool[] = [
"abuse-reports_ReportType": abuse_reports_ReportType,
},
},
outputSchema: {
"properties": {
"reports": {
"items": {
"$ref": "#/$defs/abuse-reports_AbuseReport"
},
"type": "array"
}
},
"required": [
"reports"
],
"type": "object",
$defs: {
"abuse-reports_AbuseReport": abuse_reports_AbuseReport_output,
"abuse-reports_MitigationSummary": abuse_reports_MitigationSummary_output,
"abuse-reports_ReportStatus": abuse_reports_ReportStatus_output,
"abuse-reports_ReportType": abuse_reports_ReportType_output,
"abuse-reports_SubmitterDetails": abuse_reports_SubmitterDetails_output,
},
},
annotations: {
"destructiveHint": false,
"idempotentHint": true,
Expand Down Expand Up @@ -607,6 +657,17 @@ export const tools: readonly Tool[] = [
"abuse-reports_SubmittedAbuseReportStatus": abuse_reports_SubmittedAbuseReportStatus,
},
},
outputSchema: {
"$ref": "#/$defs/abuse-reports_SubmittedAbuseReportListResponse_result",
$defs: {
"abuse-reports_ReportType": abuse_reports_ReportType_output,
"abuse-reports_SubmittedAbuseReportDenialReason": abuse_reports_SubmittedAbuseReportDenialReason_output,
"abuse-reports_SubmittedAbuseReportListItem": abuse_reports_SubmittedAbuseReportListItem_output,
"abuse-reports_SubmittedAbuseReportListResponse_result": abuse_reports_SubmittedAbuseReportListResponse_result_output,
"abuse-reports_SubmittedAbuseReportStatus": abuse_reports_SubmittedAbuseReportStatus_output,
"abuse-reports_SubmitterDetails": abuse_reports_SubmitterDetails_output,
},
},
annotations: {
"destructiveHint": false,
"idempotentHint": true,
Expand Down
Loading
Loading