Skip to content

feat(mcp): serve forge-generated direct tools at ?forge=true - #300

Merged
mattzcarey merged 6 commits into
mainfrom
feat/forge-mcp-direct-tools
Oct 20, 2026
Merged

mattzcarey merged 6 commits into
mainfrom
feat/forge-mcp-direct-tools

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Stacked on #297. /mcp?forge=true serves direct tools whose code forge generates from openapi.forge.json, as an opt-in preview. ?codemode=false and Code Mode are unchanged.

The generator is a new --target mcp in forge, on the long-lived forge-mcp branch of mattzcarey/forge. It writes TypeScript for @modelcontextprotocol/server, which is committed here in src/generated/forge-mcp/. A workflow regenerates it every six hours and opens a PR when it changes, and merging that PR deploys. The generated tools run in their own Worker, so the main Worker's startup is unchanged.

flowchart LR
  release["Forge release<br/>openapi.forge.json"] --> action["forge-tools.yml (every 6h)<br/>forge --target mcp"]
  pin["forge.json<br/>pinned forge commit"] --> action
  action -->|code changed| pr["bot/forge-tools PR<br/>(agent-think)"]
  pr -->|merge| deploy["Workers Builds"]
  client["MCP client<br/>/mcp?forge=true"] --> main["cloudflare-api-mcp<br/>auth, token, refusals"]
  main -->|"FORGE_TOOLS RPC<br/>list() · plan()"| svc["cloudflare-api-mcp-forge-tools<br/>generated tools, no secrets"]
  main -->|"planned request + token"| api["api.cloudflare.com"]
Loading

Each generated tool is plain code:

{
  name: "d1_list",
  inputSchema: { type: "object", properties: { account_id: …, name: …, page: … }, … },
  annotations: { readOnlyHint: true, … },
  method: "GET",
  path: "/accounts/{account_id}/d1/database",
  extensions: { "x-api-token-group": ["D1 Read", "D1 Write"] },
  request: (args) => ({
    path: `/accounts/${pathParam(args, "account_id")}/d1/database`,
    query: [["name", args["name"], true], ["page", args["page"], true], …],
  }),
},
  • Generated code (src/generated/forge-mcp/, never edited by hand, skipped by oxfmt and oxlint):

    • each group's tools.ts
    • schemas.ts files with model schemas shared by every tool that uses them
    • index.ts exporting every tool
    • a README.md listing the 27 methods without a tool and why
    • forge's small runtime in _/
  • Forge tools service (src/forge-tools/worker.ts, wrangler.forge-tools.jsonc): a separate Worker that holds the generated tools and applies the Cloudflare rules (optional account_id, the accounts/zones enum on merged paths). It has no secrets, no outbound requests and no route. Its two RPC methods:

    • list(): the tools as tools/list sends them
    • plan(name, args, { baseUrl, sessionAccountId }): the request a call makes (src/forge-tools/plan.ts)
  • Main Worker (src/tools/forge-tools.ts): serves docs and whoami itself. It sends each planned request with the session's token, which never leaves it, and explains refusals and steps up scopes like the existing direct tools. It parses +json responses as JSON, as fix: preserve successful JSON responses without a REST envelope #251 does for execute.

  • Why a second Worker: workerd compiles every module a Worker uploads at startup, so bundling the tools into the main Worker cost every isolate, Code Mode included. Measured with wrangler check startup, in startup CPU:

    upload (gzip) startup
    main Worker, before this PR 2.0 MB (0.39 MB) ~41 ms
    tools bundled, static import 10.7 MB (1.17 MB) ~98 ms
    tools bundled, lazy import 12.2 MB (1.25 MB) ~68 ms
    tools as a separate uploaded module 2.0 + 8.6 MB ~78 ms
    main Worker, this PR 2.0 MB (0.38 MB) ~40 ms
    forge tools service (only ?forge=true) 8.5 MB (0.76 MB) ~53 ms

    The RPC hop adds about 20–40 ms to tools/list.

  • CI (.github/workflows/forge-tools.yml): builds forge at the commit in forge.json and regenerates. When the code changes, it runs the type check and tests, then force-pushes bot/forge-tools and opens or updates the PR.

    • With AGENT_THINK_APP_ID and AGENT_THINK_PRIVATE_KEY set, it acts as the agent-think GitHub App (team-apps), so CI runs on the PR as usual.
    • Without them it falls back to GITHUB_TOKEN, whose PRs don't start CI.

    Bump forge.json to change the generator.

This drops the vendored forge-mcp tarball, the @cloudflare/forge and jco dev dependencies, the WebAssembly plugin and the R2 artifact. The lockfile is unchanged from #297.

Important

The main Worker binds to the service, and a Worker whose service binding target doesn't exist fails to deploy. Deploy cloudflare-api-mcp-forge-tools (and -staging) before this merges: npm run deploy:forge-tools:prod and npm run deploy:forge-tools. Workers Builds also needs a second build for the new Worker, with deploy command npx wrangler deploy -c wrangler.forge-tools.jsonc --env production.

Note

Tool names are forge's, matching the cf CLI (zones_list, d1_list, zero_trust_access_applications_list). That's why this is a separate opt-in rather than a change to ?codemode=false.

Tests ran, and they call the real service over its binding: Vitest builds it and runs it as an auxiliary Worker. One full run had 3 failures I couldn't reproduce in the next five runs, so watch CI for a flaky test.

I also ran npm run dev (both Workers) with my OAuth token and the MCP TS client:

  • tools/list returned 3,055 tools in 128–152 ms
  • accounts_list, zones_list, d1_list and the merged Access tool returned live data
  • d1_list without account_id asked which account to use

@mattzcarey mattzcarey changed the title feat(mcp): serve forge-mcp generated direct tools at ?forge=true feat(mcp): serve forge-generated direct tools at ?forge=true Oct 20, 2026
@mattzcarey
mattzcarey force-pushed the feat/forge-mcp-direct-tools branch from 4c3ac32 to 52d7217 Compare October 20, 2026 14:54
@mattzcarey
mattzcarey added this pull request to stack #306 October 20, 2026 15:42
@mattzcarey
mattzcarey force-pushed the feat/forge-mcp-direct-tools branch from 52d7217 to 6812f77 Compare October 20, 2026 16:03
Base automatically changed from feat/codemode-forge-spec to main October 20, 2026 16:14
A forge plugin (src/forge-tools/) builds the direct-tool catalogue with the
generic @cloudflare/forge-mcp tools plus the Cloudflare policy: optional
account_id, the two values of merged account_or_zone paths, and accepted
API token permissions in each tool's extensions. npm run generate:tools
builds and runs it with forge; the seed script uploads the catalogue to R2
as forge-mcp-tools.json.

/mcp?forge=true serves the catalogue next to docs and whoami: tools/list
from the isolate cache, tools/call with the session's account filled in,
requests built by forge-mcp, and refusals explained with scope step-up as
the other direct tools do. ?codemode=false is unchanged.
Replace the forge-mcp plugin catalogue in R2 with code: forge's MCP target
(--target mcp, on the forge-mcp branch pinned in forge.json) writes
TypeScript for @modelcontextprotocol/server into src/generated/forge-mcp,
which is committed. Each tool has its schema and a generated request
function; /mcp?forge=true imports the module lazily and applies the
Cloudflare policy (optional account_id, account_or_zone values) once per
isolate.

.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 a bot/forge-tools pull request when the generated code
changes. Merging it deploys.

Drops the vendored @cloudflare/forge-mcp tarball, the @cloudflare/forge and
jco dev dependencies, the WebAssembly plugin, and the R2 artifact.
…ithout the SDK

- forge.json: bump to 3f7e168, whose generated files import the tool type
  and request helpers from _/tool.ts, so importing the tools doesn't import
  the MCP SDK
- forge-tools.yml: mint an agent-think GitHub App token when its secrets are
  set, so CI runs on the bot pull request; fall back to GITHUB_TOKEN
- AGENTS.md: a separate uploaded module measured no better than the lazy
  import, since workerd compiles every uploaded module at startup
The generated direct tools move into their own Worker,
cloudflare-api-mcp-forge-tools (src/forge-tools/worker.ts,
wrangler.forge-tools.jsonc), which the main Worker calls over a FORGE_TOOLS
service binding. workerd compiles every module a Worker uploads at startup,
so even a lazy import cost every main isolate ~27 ms; now the main Worker is
back to its 2 MB, ~40 ms startup, and only isolates serving ?forge=true
compile the tools.

The service holds no credentials and sends nothing: list() returns the
tools, and plan() returns the request a call makes (src/forge-tools/plan.ts),
which the main Worker sends with the session's token, explaining refusals
and stepping up scopes as before.

npm run dev runs both Workers; npm run types types the binding from both
configs; vitest builds the service and runs it as an auxiliary Worker, so
tests call it over the real binding.
As #251 does for execute: application/scim+json and other +json types are
JSON, so they're parsed and formatted like application/json.
@mattzcarey
mattzcarey force-pushed the feat/forge-mcp-direct-tools branch from 6812f77 to 10b1f27 Compare October 20, 2026 16:14
@mattzcarey
mattzcarey merged commit a61ef38 into main Oct 20, 2026
5 checks passed
@mattzcarey
mattzcarey deleted the feat/forge-mcp-direct-tools branch October 20, 2026 16:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant