Repository navigation
feat(mcp): generate an MCP server in TypeScript (--target mcp) - #71
mattzcarey wants to merge 10 commits into
Conversation
`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.
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.
commit: |
…ession CodeQL flagged /\/+$/ as polynomial on many slashes.
srikrsna
left a comment
There was a problem hiding this comment.
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.
| | 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. | |
There was a problem hiding this comment.
If we are using group and method names this is not a problem as they are ensured to be unique by Forge
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| /// Generates an MCP server. | ||
| pub struct Generator; | ||
|
|
||
| impl Plugin for Generator { |
There was a problem hiding this comment.
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
| | 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. | |
There was a problem hiding this comment.
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.
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.
* 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
forge openapi.json --target mcp --out toolsgenerates 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 arequestfunction that builds its HTTP request.rfcs/mcp.mddescribes how the target maps the API onto MCP.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 listingtools/listsends, built once, with output schemas only when askedtoolsByName: the index fortools/callbuildRequest: the request as plain data,{ method, url, headers, body? }, which crosses RPC as it is andfetchsendsWhen 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-hiddenget a tool. Forge says hidden methods are left out of listings, sotools/listshould probably skip them too.Tests ran, and cloudflare/mcp's suite passes on code generated by this branch.