Skip to content
Closed
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
171 changes: 171 additions & 0 deletions packages/forge-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# @cloudflare/forge-mcp

Generates an MCP server's tools from an OpenAPI document with forge, and
serves them over Streamable HTTP.

There are two halves:

- **A forge plugin** turns the document into a tool catalogue,
`mcp-tools.json`: one tool per method, with its input schema, annotations,
and how its arguments become an HTTP request.
- **A runtime** serves a catalogue with the
[MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk).
`tools/list` returns the catalogue's tools; `tools/call` makes one API
request. It needs nothing else from the document at request time.

```sh
pnpm build:plugin # dist/forge-mcp.wasm
forge openapi.json --target wasm://dist/forge-mcp.wasm --out generated
```

```ts
import { createForgeMcpHandler } from '@cloudflare/forge-mcp';
import catalogue from './generated/mcp-tools.json';

const mcp = createForgeMcpHandler({
catalogue,
authorize: (request) => {
const authorization = request.headers.get('Authorization');
return authorization ? { Authorization: authorization } : new Response('Unauthorized', { status: 401 });
},
});

export default { fetch: (request: Request) => mcp.fetch(request) };
```

`example/` is this for the Cloudflare API, as a Worker: `pnpm example:generate`
downloads the latest `openapi.forge.json` release and generates its catalogue
(about 3,000 tools), and `pnpm example:dev` serves it at
`http://localhost:8787/mcp`. Connect with a Cloudflare API token as the bearer
token.

## How methods become tools

This is the contract between the document and the tools. The plugin
implements it in `src/tools.ts` and `src/schema.ts`.

**Which methods.** Every method that isn't deprecated. Hidden methods
(`x-forge-hidden`) are included: hiding keeps a method out of listings such
as a CLI's help but leaves it callable, and a client can only call the tools
`tools/list` returns. A method is skipped, with the reason in the catalogue's
`skipped`, when:

- its request body is required and isn't JSON;
- two of its arguments would have the same name;
- its tool name is over 128 characters, or another method already has it.

**Name.** The method's group path and name in snake case, joined by `_`:
method `create` in group `dns › records` is `dns_records_create`.

**Title and description.** The title is the summary. The description is the
description (or the summary), followed by the availability when it isn't
generally available, and by the `x-forge-require-confirmation` sentence,
introduced as "Confirm with the user before calling this tool."

**Arguments.**

- Path, query and header parameters are top-level arguments, named by the
parameter's name in snake case (`If-Match` is `if_match`). A name two
locations share is prefixed with its location, as in `header_name`.
- A JSON body is one argument, `body`, shaped as the API takes it: nested,
not flattened into the parameters. It is `request_body` if a parameter is
named `body`.
- A path parameter is always required; others are required when the
document says so.

**Input schema.** JSON Schema 2020-12. Each model is a `$ref` into the
tool's own `$defs`, which hold every model the tool reaches and no others, so
recursive models work and each schema stands alone. Read-only fields, which
the server sets, are left out. Descriptions, defaults, enums, array bounds and
deprecation are kept. forge's types don't carry string patterns, lengths or
numeric bounds; the API validates those.

**Annotations.** From the HTTP method:

| | `readOnlyHint` | `destructiveHint` | `idempotentHint` |
| ------------------------ | -------------- | ----------------- | ---------------- |
| `GET`, `HEAD`, `OPTIONS` | true | false | true |
| `PUT` | false | true | true |
| `DELETE` | false | true | true |
| `POST`, `PATCH` | false | false | false |

A method that needs confirmation is never read-only, and always destructive.
`openWorldHint` is always true. The hints describe the method; they authorize
nothing.

**Calls.** Path parameters are substituted and percent-encoded. Query
parameters use OpenAPI's `form` style: `a=1&a=2` when exploded, `a=1,2` when
not, and an exploded object's members as parameters of their own. The `body`
argument is sent as JSON with the body's media type. Before sending, the
runtime checks only that required arguments are present; the API validates
values. A non-2xx response is a tool result with `isError: true`, not a
protocol error, so the model sees what the API said.

## The catalogue

`src/catalogue.ts` is its type. `format` is `forge-mcp/1`; a server refuses a
catalogue in a format it doesn't know, so a catalogue written by a newer
plugin can't be half-served.

```json
{
"format": "forge-mcp/1",
"api": { "version": "4.0.0", "baseUrl": "https://api.cloudflare.com/client/v4" },
"tools": [
{
"name": "dns_records_create",
"title": "Create DNS Record",
"description": "Create a new DNS record for a zone. …",
"inputSchema": { "type": "object", "properties": { "zone_id": { "type": "string" }, "body": { "$ref": "#/$defs/…" } }, "required": ["zone_id", "body"], "$defs": { … } },
"annotations": { "title": "Create DNS Record", "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": true },
"request": {
"method": "POST",
"path": "/zones/{zone_id}/dns_records",
"params": [{ "key": "zone_id", "name": "zone_id", "in": "path" }],
"body": { "key": "body", "mediaType": "application/json" }
}
}
],
"skipped": [{ "method": "PUT", "path": "/accounts/{account_id}/workers/scripts/{script_name}", "reason": "the request body is not JSON (multipart/form-data)" }]
}
```

## Your own plugin

The default plugin, `plugin/forge-mcp.ts`, uses no options. A plugin can't
take options at run time, so to choose methods, rename tools or change them,
write an entry module of your own and build it:

```ts
import { definePlugin } from '@cloudflare/forge/plugin';
import { catalogueFile } from '@cloudflare/forge-mcp/plugin';

export const { generate, generateGroup } = definePlugin({
generate: (forge) => [
catalogueFile(forge, {
include: (method) => !method.deprecated && method.group.path[0]?.snake() === 'dns',
transform: (tool) => ({ ...tool, description: tool.description.slice(0, 1024) }),
}),
],
});
```

```sh
forge-plugin build plugin.ts -o plugin.wasm
```

## Serving

`createForgeMcpHandler` answers each request with a fresh, stateless MCP
server: the SDK's `createMcpHandler`, which serves MCP 2026-07-28 and
stateless 2025 clients over Streamable HTTP. To serve a catalogue on a server
you already have, alongside other tools, use `serveCatalogue(server,
catalogue, call)` with `callTool` or your own call.

The tools are installed as low-level `tools/list` and `tools/call` handlers,
not registered one by one: for thousands of tools, per-tool registration
costs more than the request. The `tools/list` payload is built once per
catalogue.

A large API's `tools/list` is large: about 6 MB for the Cloudflare API. Use
`include` to serve a part of it.
1 change: 1 addition & 0 deletions packages/forge-mcp/example/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
generated/
33 changes: 33 additions & 0 deletions packages/forge-mcp/example/generate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Generates example/generated/mcp-tools.json: downloads the latest
// openapi.forge.json release and runs the default plugin on it with forge.
//
// forge is FORGE_BINARY, or the repository's release build
// (cargo build --release --features wasm).
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const pkg = join(dirname(fileURLToPath(import.meta.url)), '..');
const out = join(pkg, 'example', 'generated');
const spec = join(out, 'openapi.forge.json');
const plugin = join(pkg, 'dist', 'forge-mcp.wasm');
const forge = process.env.FORGE_BINARY ?? join(pkg, '..', '..', 'target', 'release', 'forge');

if (!existsSync(forge)) {
throw new Error(`No forge at ${forge}. Run cargo build --release --features wasm, or set FORGE_BINARY.`);
}
if (!existsSync(plugin)) {
throw new Error(`No plugin at ${plugin}. Run pnpm build:plugin.`);
}

mkdirSync(out, { recursive: true });
const url = 'https://github.com/cloudflare/forge/releases/latest/download/openapi.forge.json';
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url}: ${response.status}`);
}
writeFileSync(spec, Buffer.from(await response.arrayBuffer()));

execFileSync(forge, [spec, '--target', `wasm://${plugin}`, '--out', out], { stdio: 'inherit' });
console.log(`Wrote ${join(out, 'mcp-tools.json')}`);
29 changes: 29 additions & 0 deletions packages/forge-mcp/example/src/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// The Cloudflare API as an MCP server, over Streamable HTTP at /mcp. The
// caller authenticates with a Cloudflare API token as a bearer token, which
// every tool call forwards to the API.
import { createForgeMcpHandler, type Catalogue } from '../../src/index.ts';
import catalogue from '../generated/mcp-tools.json';

const mcp = createForgeMcpHandler({
catalogue: catalogue as Catalogue,
serverInfo: { name: 'cloudflare-api-forge-mcp', version: '0.1.0' },
authorize(request) {
const authorization = request.headers.get('Authorization');
if (authorization?.startsWith('Bearer ')) {
return { Authorization: authorization };
}
return new Response('Send a Cloudflare API token as a bearer token.', {
status: 401,
headers: { 'WWW-Authenticate': 'Bearer' },
});
},
});

export default {
fetch(request: Request): Promise<Response> | Response {
if (new URL(request.url).pathname !== '/mcp') {
return new Response('Not found', { status: 404 });
}
return mcp.fetch(request);
},
};
5 changes: 5 additions & 0 deletions packages/forge-mcp/example/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"compilerOptions": { "resolveJsonModule": true },
"include": ["src"]
}
11 changes: 11 additions & 0 deletions packages/forge-mcp/example/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
// An MCP server for the Cloudflare API, generated by forge-mcp from the
// latest openapi.forge.json release. Generate its catalogue first:
// pnpm example:generate
"$schema": "../node_modules/wrangler/config-schema.json",
"name": "forge-mcp-example",
"main": "src/worker.ts",
"compatibility_date": "2026-09-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
}
42 changes: 42 additions & 0 deletions packages/forge-mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "@cloudflare/forge-mcp",
"version": "0.1.0",
"description": "Generate an MCP server's tools from an OpenAPI document with forge, and serve them over Streamable HTTP.",
"files": [
"dist/*.js",
"dist/*.d.ts",
"dist/forge-mcp.wasm",
"README.md"
],
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./plugin": {
"types": "./dist/plugin.d.ts",
"default": "./dist/plugin.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"build:plugin": "node -e \"require('node:fs').mkdirSync('dist',{recursive:true})\" && forge-plugin build plugin/forge-mcp.ts -o dist/forge-mcp.wasm",
"prepack": "pnpm build && pnpm build:plugin",
"test": "node --experimental-strip-types --test \"test/*.test.ts\"",
"typecheck": "node ../forge/scripts/build.mjs && tsc --noEmit",
"example:generate": "node example/generate.mjs",
"example:dev": "wrangler dev -c example/wrangler.jsonc",
"example:deploy": "wrangler deploy -c example/wrangler.jsonc"
},
"dependencies": {
"@modelcontextprotocol/server": "2.3.1"
},
"devDependencies": {
"@bytecodealliance/jco": "1.37.0",
"@cloudflare/forge": "workspace:*",
"@types/node": "25.3.5",
"typescript": "5.9.3",
"wrangler": "^4.127.0"
}
}
10 changes: 10 additions & 0 deletions packages/forge-mcp/plugin/forge-mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// The default forge-mcp plugin: every method that isn't deprecated becomes a
// tool. Build it with `pnpm build:plugin`, and run it
// with `forge openapi.json --target wasm://dist/forge-mcp.wasm --out <dir>`.
import { definePlugin } from '@cloudflare/forge/plugin';

import { catalogueFile } from '../src/plugin.ts';

export const { generate, generateGroup } = definePlugin({
generate: (forge) => [catalogueFile(forge)],
});
87 changes: 87 additions & 0 deletions packages/forge-mcp/src/catalogue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* The tool catalogue: what the plugin writes and what the server serves.
*
* It is plain JSON. Each tool carries the fields `tools/list` sends, and a
* `request` that says how `tools/call` turns the tool's arguments into one
* HTTP request. Nothing about the OpenAPI document is needed at request time.
*/

/** The catalogue format. A server refuses a catalogue in a format it doesn't know. */
export const CATALOGUE_FORMAT = 'forge-mcp/1';

/** A JSON Schema, as JSON. */
export type JsonSchema = { readonly [key: string]: unknown };

export interface Catalogue {
readonly format: typeof CATALOGUE_FORMAT;
/** The API the tools call. */
readonly api: {
/** The document's `info.version`. */
readonly version: string;
/** The document's first server, which tools call unless the server is given another. */
readonly baseUrl: string;
};
readonly tools: readonly CatalogueTool[];
/** The methods that have no tool, and why. */
readonly skipped: readonly SkippedMethod[];
}

export interface CatalogueTool {
readonly name: string;
readonly title?: string;
readonly description: string;
/** A self-contained JSON Schema 2020-12 object schema: models it uses are in its `$defs`. */
readonly inputSchema: JsonSchema & { readonly type: 'object' };
readonly annotations: ToolAnnotations;
readonly request: RequestRoute;
/**
* Data a plugin adds for its own server, such as the permissions a method
* needs. The runtime never sends it to clients.
*/
readonly extensions?: { readonly [key: string]: unknown };
}

export interface ToolAnnotations {
readonly title?: string;
readonly readOnlyHint: boolean;
readonly destructiveHint: boolean;
readonly idempotentHint: boolean;
readonly openWorldHint: boolean;
}

/** How a tool's arguments become an HTTP request. */
export interface RequestRoute {
/** As it is sent, such as `GET`. */
readonly method: string;
/** The path template, such as `/zones/{zone_id}/dns_records`. */
readonly path: string;
readonly params: readonly ParamRoute[];
/** The argument that is the JSON body, if the method takes one. */
readonly body?: { readonly key: string; readonly mediaType: string };
}

/** Where one argument goes. */
export interface ParamRoute {
/** The argument's name in the tool's input. */
readonly key: string;
/** The parameter's name on the wire. */
readonly name: string;
readonly in: 'path' | 'query' | 'header';
/** For a query parameter: whether an array or object is sent exploded (`a=1&a=2`) or not (`a=1,2`). */
readonly explode?: boolean;
}

export interface SkippedMethod {
readonly method: string;
readonly path: string;
readonly reason: string;
}

/** Whether `value` is a catalogue this version of the package can serve. */
export function isCatalogue(value: unknown): value is Catalogue {
if (typeof value !== 'object' || value === null) {
return false;
}
const { format, tools } = value as { format?: unknown; tools?: unknown };
return format === CATALOGUE_FORMAT && Array.isArray(tools);
}
Loading
Loading