Skip to content

feat(mcp): generate an MCP server in TypeScript (--target mcp) - #71

Open
mattzcarey wants to merge 10 commits into
mainfrom
mcp
Open

mattzcarey wants to merge 10 commits into
mainfrom
mcp

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

forge openapi.json --target mcp --out tools generates the tools of an MCP server in TypeScript: one tool per API method, as data a server lists and calls. Each tool has its argument schema, hints, extensions and the JSON Schemas of its responses, plus a request function that builds its HTTP request. rfcs/mcp.md describes how the target maps the API onto MCP.

import { buildRequest, listTools, toolsByName, tools } from './tools/index.js'

const listing = listTools(tools) // tools/list

const request = buildRequest(toolsByName(tools).get(name), args, baseUrl) // tools/call
const response = await fetch(request.url, { ...request, headers: { ...request.headers, authorization } })

It generates no server. Every server around the tools differs: how it authenticates, what it serves alongside them, what it does with an API error. So the runtime is one file, _/tool.ts, with only what every server needs:

  • listTools: the listing tools/list sends, built once, with output schemas only when asked
  • toolsByName: the index for tools/call
  • buildRequest: the request as plain data, { method, url, headers, body? }, which crosses RPC as it is and fetch sends

When I closed #53 the plan was to run this in production before proposing it. It now serves mcp.cloudflare.com/mcp?codemode=false&experimentalForge=true, with about 3,000 tools regenerated every six hours. Our server applies its own policy to the tools, plans calls in a separate Worker and sends them with the session's token. It checks responses against the generated response schemas so teams can fix the spec before tools advertise output schemas (cloudflare/mcp#350 moves it to this version).

One question for review: methods with x-forge-hidden get a tool. Forge says hidden methods are left out of listings, so tools/list should probably skip them too.

Tests ran, and cloudflare/mcp's suite passes on code generated by this branch.

`forge openapi.json --target mcp --out server` generates TypeScript for the
official MCP TypeScript SDK (@modelcontextprotocol/server):

- <group>/tools.ts: the tools of the group's methods, each with the JSON
  Schema of its arguments, its hints, the method's extensions, and a
  request function that builds its API request
- <group>/schemas.ts: a constant for each model's schema, shared by every
  tool whose arguments use it
- index.ts: every tool, and the API's base URL and version
- README.md: the methods without a tool, and why
- _/: the runtime, which serves the tools with low-level tools/list and
  tools/call handlers and sends their requests

Tools are named after their groups and method in snake_case. Hidden methods
get tools, since a client can only call the tools it lists; deprecated ones
don't. Methods whose required body isn't JSON, or whose tool name is taken
or too long, are listed in README.md instead.
The generated files import the tool type and pathParam from _/tool.ts, and
index.ts re-exports only that half of the runtime, so a server can import
the tools, for example lazily or as a module of its own, without pulling in
@modelcontextprotocol/server. _/server.ts still serves them.
Each tool gets responses: the JSON Schema of every response body the method
declares, by status (2XX, error ranges and default), with a $defs of the
models they reach, from shared outputs.ts constants. Response schemas keep
read-only fields and leave out descriptions and defaults: they're for a
server to check responses against, so listTools leaves them out of
tools/list.
Each tool gets an outputSchema: the JSON body schema of its successful
(2xx) responses, or anyOf the distinct ones, with $defs holding only the
models those reach. A tool with a 2xx response that has no JSON body gets
none. listTools leaves it out unless called with { outputSchema: true }.
rfcs/mcp.md says which methods get a tool, how tools and arguments are
named, how a call becomes a request, how schemas are built for arguments,
responses and output, the hints and extensions, and what the runtime does.
Comment thread src/bin/forge/mcp/lib/src/tool.ts Fixed
Only createHandler serves the tools on an McpServer, so serveTools is no
longer exported, and its extra tools, which nothing passed, are gone.
worker.ts default-exports createHandler with every tool. It calls the API
with the MCP request's Authorization header and refuses requests from
another site's Origin, so the generated server deploys as a Worker with
no code of its own.
Every server around the tools differs in how it authenticates and what it
serves alongside them, so the runtime keeps only what every server needs:
the Tool type, listTools, toolsByName and buildRequest, in _/tool.ts.
worker.ts, createHandler, serveTools and callTool are gone, and nothing
imports the MCP SDK at runtime.

buildRequest returns the request as plain data, { method, url, headers,
body? }, which crosses RPC as it is and fetch(request.url, request)
sends.
@pkg-pr-new

pkg-pr-new Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
npm i https://pkg.pr.new/cloudflare/forge/@cloudflare/forge@71

commit: 580804f

…ession

CodeQL flagged /\/+$/ as polynomial on many slashes.

@srikrsna srikrsna left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing! Thank you so much for writing an RFC, helps make the review so much easier. I left some comments, haven't played around with the output yet but nothing major. I'll do a second pass when I get around to trying it and looking at the generated code.

Comment thread rfcs/mcp.md Outdated
| Deprecated (`deprecated: true`) | Left out silently. |
| A required request body with no JSON media type (`multipart/form-data`, `application/octet-stream`, `text/plain`, …) | A tool's arguments are JSON. An optional non-JSON body is left off the tool, and the method still gets one. |
| A tool name over 128 characters | MCP's limit. |
| A tool name another method already has | Names must be unique across the server; the first method keeps it. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are using group and method names this is not a problem as they are ensured to be unique by Forge

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forge keeps group and method names unique, but joining them with _ can still collide: group a.b method c and group a method b_c are both a_b_c (the duplicate_names test). . would avoid it, but OpenAI and Anthropic only accept [a-zA-Z0-9_-] in tool names. The Cloudflare API has no collisions today; I added the example to the RFC in 580804f.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forge treats _ as a word separate to solve for such use cases. In your example we can generate: a_b_c for group a.b.c and a_b-c. I know it's awkward but will be a bit more open. We can do camelCase as well for method name: a-bC

Comment thread rfcs/mcp.md Outdated
Comment thread src/bin/forge/mcp.rs
/// Generates an MCP server.
pub struct Generator;

impl Plugin for Generator {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most models are scoped to the group by forge. We can implement generate_group to parallelize the generation by group and also simplify the code around maintaining schemas as they can then become lazy

Comment thread rfcs/mcp.md
| A required request body with no JSON media type (`multipart/form-data`, `application/octet-stream`, `text/plain`, …) | A tool's arguments are JSON. An optional non-JSON body is left off the tool, and the method still gets one. |
| A tool name over 128 characters | MCP's limit. |
| A tool name another method already has | Names must be unique across the server; the first method keeps it. |
| Two arguments with the same name, even after [prefixing](#arguments) | The tool can't take both. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of a prefix we can simply qualify them always like query, path, and body. I don't see a downside to this as we already qualify body.

Comment thread rfcs/mcp.md Outdated
Show which names collide, name tools after forge's groups rather than the
extensions forge reads them from, and drop the path parameter row: forge
gives every placeholder a parameter.
mattzcarey added a commit to cloudflare/mcp that referenced this pull request Oct 28, 2026
* refactor(forge): plan calls with forge's plain-data requests

Forge's MCP target now generates tools, not a server (cloudflare/forge#71):
the runtime is _/tool.ts alone, and buildRequest returns the request as
plain data. planCall spreads it into the plan instead of reading a Request
back apart, and imports buildRequest and InvalidArguments directly rather
than taking them as arguments.

* chore(forge): pin the forge commit that trims base URLs without a regex

* chore(forge): regenerate from openapi@d35bb44

8 tools added, none removed; tools with permissions 2,366 -> 2,368.

* chore(forge): pin forge 580804f
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.

3 participants