diff --git a/packages/forge-mcp/README.md b/packages/forge-mcp/README.md new file mode 100644 index 000000000..00dcd2ce2 --- /dev/null +++ b/packages/forge-mcp/README.md @@ -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. diff --git a/packages/forge-mcp/example/.gitignore b/packages/forge-mcp/example/.gitignore new file mode 100644 index 000000000..9ab870da8 --- /dev/null +++ b/packages/forge-mcp/example/.gitignore @@ -0,0 +1 @@ +generated/ diff --git a/packages/forge-mcp/example/generate.mjs b/packages/forge-mcp/example/generate.mjs new file mode 100644 index 000000000..7dad90031 --- /dev/null +++ b/packages/forge-mcp/example/generate.mjs @@ -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')}`); diff --git a/packages/forge-mcp/example/src/worker.ts b/packages/forge-mcp/example/src/worker.ts new file mode 100644 index 000000000..8561f2596 --- /dev/null +++ b/packages/forge-mcp/example/src/worker.ts @@ -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 { + if (new URL(request.url).pathname !== '/mcp') { + return new Response('Not found', { status: 404 }); + } + return mcp.fetch(request); + }, +}; diff --git a/packages/forge-mcp/example/tsconfig.json b/packages/forge-mcp/example/tsconfig.json new file mode 100644 index 000000000..86b5903aa --- /dev/null +++ b/packages/forge-mcp/example/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { "resolveJsonModule": true }, + "include": ["src"] +} diff --git a/packages/forge-mcp/example/wrangler.jsonc b/packages/forge-mcp/example/wrangler.jsonc new file mode 100644 index 000000000..09ac025a5 --- /dev/null +++ b/packages/forge-mcp/example/wrangler.jsonc @@ -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 }, +} diff --git a/packages/forge-mcp/package.json b/packages/forge-mcp/package.json new file mode 100644 index 000000000..2a1b5ef11 --- /dev/null +++ b/packages/forge-mcp/package.json @@ -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" + } +} diff --git a/packages/forge-mcp/plugin/forge-mcp.ts b/packages/forge-mcp/plugin/forge-mcp.ts new file mode 100644 index 000000000..d739a76a7 --- /dev/null +++ b/packages/forge-mcp/plugin/forge-mcp.ts @@ -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 `. +import { definePlugin } from '@cloudflare/forge/plugin'; + +import { catalogueFile } from '../src/plugin.ts'; + +export const { generate, generateGroup } = definePlugin({ + generate: (forge) => [catalogueFile(forge)], +}); diff --git a/packages/forge-mcp/src/catalogue.ts b/packages/forge-mcp/src/catalogue.ts new file mode 100644 index 000000000..80509c427 --- /dev/null +++ b/packages/forge-mcp/src/catalogue.ts @@ -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); +} diff --git a/packages/forge-mcp/src/index.ts b/packages/forge-mcp/src/index.ts new file mode 100644 index 000000000..cb388d6f1 --- /dev/null +++ b/packages/forge-mcp/src/index.ts @@ -0,0 +1,42 @@ +/** + * Serve an MCP tool catalogue that the forge-mcp plugin generated, over + * Streamable HTTP. + * + * ```ts + * import { createForgeMcpHandler } from '@cloudflare/forge-mcp'; + * import catalogue from './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) }; + * ``` + * + * @module + */ +export { + CATALOGUE_FORMAT, + isCatalogue, + type Catalogue, + type CatalogueTool, + type JsonSchema, + type ParamRoute, + type RequestRoute, + type SkippedMethod, + type ToolAnnotations, +} from './catalogue.ts'; +export { buildRequest, InvalidArguments } from './request.ts'; +export { + callTool, + catalogueTools, + createForgeMcpHandler, + serveCatalogue, + type CallContext, + type CatalogueTools, + type ForgeMcpOptions, +} from './server.ts'; diff --git a/packages/forge-mcp/src/plugin.ts b/packages/forge-mcp/src/plugin.ts new file mode 100644 index 000000000..f63f62f00 --- /dev/null +++ b/packages/forge-mcp/src/plugin.ts @@ -0,0 +1,35 @@ +/** + * Build a forge plugin that writes an MCP tool catalogue. + * + * The default plugin, `plugin/forge-mcp.ts`, is this with no options. To + * change which methods get tools, their names or the tools themselves, write + * your own entry module: + * + * ```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.httpMethod === 'GET' })], + * }); + * ``` + * + * and build it with `forge-plugin build plugin.ts -o plugin.wasm`. + * + * @module + */ +import type { File, Forge } from '@cloudflare/forge/plugin'; + +import { buildCatalogue, type CatalogueOptions } from './tools.ts'; + +export type { Catalogue, CatalogueTool, ParamRoute, RequestRoute, SkippedMethod } from './catalogue.ts'; +export { CATALOGUE_FORMAT } from './catalogue.ts'; +export { buildCatalogue, defaultName, type CatalogueOptions } from './tools.ts'; + +/** The file the plugin writes. */ +export const CATALOGUE_PATH = 'mcp-tools.json'; + +/** The catalogue of `forge`, as the file {@link CATALOGUE_PATH}. */ +export function catalogueFile(forge: Forge, options?: CatalogueOptions): File { + return { path: CATALOGUE_PATH, content: JSON.stringify(buildCatalogue(forge, options)) }; +} diff --git a/packages/forge-mcp/src/request.ts b/packages/forge-mcp/src/request.ts new file mode 100644 index 000000000..d008de90b --- /dev/null +++ b/packages/forge-mcp/src/request.ts @@ -0,0 +1,101 @@ +import type { CatalogueTool, ParamRoute } from './catalogue.ts'; + +/** Arguments a tool can't be called with, such as a missing required one. */ +export class InvalidArguments extends Error {} + +/** + * The HTTP request a tool call makes: path parameters substituted and + * encoded, query parameters serialized as OpenAPI's `form` style, headers + * set, and the JSON body sent as given. Values are not validated here; the + * API validates them. + */ +export function buildRequest(tool: CatalogueTool, args: Record, baseUrl: string): Request { + const required = tool.inputSchema.required; + if (Array.isArray(required)) { + const missing = required.filter((key) => args[key] === undefined); + if (missing.length > 0) { + throw new InvalidArguments(`Missing required arguments: ${missing.join(', ')}`); + } + } + + const { method, path, params, body } = tool.request; + let resolved = path; + const query = new URLSearchParams(); + const headers = new Headers({ Accept: 'application/json' }); + + for (const param of params) { + const value = args[param.key]; + if (value === undefined || value === null) { + continue; + } + switch (param.in) { + case 'path': + resolved = resolved.replaceAll(`{${param.name}}`, encodeURIComponent(scalar(param, value))); + break; + case 'query': + appendQuery(query, param, value); + break; + case 'header': + headers.set( + param.name, + Array.isArray(value) ? value.map((item) => scalar(param, item)).join(',') : scalar(param, value), + ); + break; + } + } + + const unfilled = resolved.match(/\{([^}]+)\}/); + if (unfilled) { + throw new InvalidArguments(`Missing path parameter: ${unfilled[1]}`); + } + + const url = new URL(baseUrl.replace(/\/+$/, '') + resolved); + for (const [name, value] of query) { + url.searchParams.append(name, value); + } + + const init: RequestInit = { method, headers }; + if (body && args[body.key] !== undefined) { + headers.set('Content-Type', body.mediaType); + init.body = JSON.stringify(args[body.key]); + } + return new Request(url, init); +} + +/** OpenAPI's default query style, `form`: `a=1&a=2` exploded, `a=1,2` not. */ +function appendQuery(query: URLSearchParams, param: ParamRoute, value: unknown): void { + const explode = param.explode ?? true; + if (Array.isArray(value)) { + const items = value.map((item) => scalar(param, item)); + if (explode) { + for (const item of items) { + query.append(param.name, item); + } + } else { + query.append(param.name, items.join(',')); + } + return; + } + if (typeof value === 'object' && value !== null) { + const entries = Object.entries(value).filter(([, item]) => item !== undefined && item !== null); + if (explode) { + for (const [key, item] of entries) { + query.append(key, scalar(param, item)); + } + } else { + query.append(param.name, entries.flatMap(([key, item]) => [key, scalar(param, item)]).join(',')); + } + return; + } + query.append(param.name, scalar(param, value)); +} + +function scalar(param: ParamRoute, value: unknown): string { + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value); + } + throw new InvalidArguments(`${param.key} must be a string, number or boolean`); +} diff --git a/packages/forge-mcp/src/schema.ts b/packages/forge-mcp/src/schema.ts new file mode 100644 index 000000000..c0237b955 --- /dev/null +++ b/packages/forge-mcp/src/schema.ts @@ -0,0 +1,161 @@ +import type { Field, Model, Type } from '@cloudflare/forge/plugin'; + +import type { JsonSchema } from './catalogue.ts'; + +type Schema = { [key: string]: unknown }; + +/** + * Turns forge types into JSON Schema 2020-12. + * + * A model becomes a `$ref` to `#/$defs/`, so recursive models work. Each + * model's schema is built once for the whole catalogue; {@link defs} gives a + * tool only the models it can reach, so every tool's schema stands alone. + */ +export class SchemaBuilder { + readonly #keys = new Map(); + readonly #usedKeys = new Set(); + readonly #schemas = new Map(); + readonly #deps = new Map>(); + + /** The schema of a type. Models it refers to are recorded in `refs`. */ + type(type: Type, refs: Set): Schema { + switch (type.kind) { + case 'scalar': + return scalar(type); + case 'enum': { + const kinds = new Set(type.values.map((value) => typeof value)); + const [only] = kinds; + return kinds.size === 1 && only !== undefined + ? { type: only, enum: [...type.values] } + : { enum: [...type.values] }; + } + case 'array': + return { + type: 'array', + items: this.type(type.elem, refs), + ...(type.minItems !== undefined && { minItems: type.minItems }), + ...(type.maxItems !== undefined && { maxItems: type.maxItems }), + }; + case 'object': + return this.object(type.fields, refs, type.extra); + case 'union': + return { [type.exclusive ? 'oneOf' : 'anyOf']: type.members.map((member) => this.type(member, refs)) }; + case 'intersection': + return { allOf: type.members.map((member) => this.type(member, refs)) }; + case 'model': + refs.add(type.model); + return { $ref: `#/$defs/${this.#key(type.model)}` }; + } + } + + /** An object schema of fields, keyed by `key`. Read-only fields, which the server sets, are left out. */ + object(fields: readonly Field[], refs: Set, extra?: Type, key = (field: Field) => field.wireName): Schema { + const properties: Schema = {}; + const required: string[] = []; + for (const field of fields) { + if (field.readOnly) { + continue; + } + properties[key(field)] = this.field(field, refs); + if (field.required) { + required.push(key(field)); + } + } + return { + type: 'object', + properties, + ...(required.length > 0 && { required }), + ...(extra && { additionalProperties: this.type(extra, refs) }), + }; + } + + /** A field's schema, with its description, default and deprecation. */ + field(field: Field, refs: Set): Schema { + return { + ...this.type(field.type, refs), + ...(field.description && { description: field.description }), + ...(field.default !== undefined && { default: field.default }), + ...(field.deprecated && { deprecated: true }), + }; + } + + /** The `$defs` for `refs`: each model they refer to, directly or through other models. */ + defs(refs: Iterable): Record | undefined { + const seen = new Set(); + const queue = [...refs]; + for (let model = queue.pop(); model !== undefined; model = queue.pop()) { + if (seen.has(model)) { + continue; + } + seen.add(model); + this.#model(model); + queue.push(...(this.#deps.get(model) ?? [])); + } + if (seen.size === 0) { + return undefined; + } + const entries = [...seen].map((model) => [this.#key(model), this.#model(model)] as const); + return Object.fromEntries(entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); + } + + /** A model's schema, built the first time it is asked for. */ + #model(model: Model): Schema { + let schema = this.#schemas.get(model); + if (schema) { + return schema; + } + const deps = new Set(); + schema = { + ...this.type(model.type, deps), + ...(model.description && { description: model.description }), + ...(model.deprecated && { deprecated: true }), + }; + this.#schemas.set(model, schema); + this.#deps.set(model, deps); + return schema; + } + + /** + * A model's key in `$defs`: its schema name, with characters a JSON Pointer + * or URI fragment would need escaped replaced by `_`, and made unique. + */ + #key(model: Model): string { + let key = this.#keys.get(model); + if (key) { + return key; + } + const base = model.schemaName.replace(/[^A-Za-z0-9_.-]/g, '_') || 'model'; + key = base; + for (let i = 2; this.#usedKeys.has(key); i++) { + key = `${base}_${i}`; + } + this.#keys.set(model, key); + this.#usedKeys.add(key); + return key; + } +} + +function scalar(type: Extract): Schema { + switch (type.scalar) { + case 'number': + return { type: type.number === 'float' || type.number === 'double' ? 'number' : 'integer' }; + case 'boolean': + return { type: 'boolean' }; + case 'null': + return { type: 'null' }; + case 'unknown': + return {}; + case 'string': + return { type: 'string' }; + case 'date-time': + return { type: 'string', format: 'date-time' }; + case 'date': + return { type: 'string', format: 'date' }; + case 'uuid': + return { type: 'string', format: 'uuid' }; + case 'bytes': + return { type: 'string', contentEncoding: 'base64' }; + case 'binary': + return { type: 'string', format: 'binary' }; + } +} diff --git a/packages/forge-mcp/src/server.ts b/packages/forge-mcp/src/server.ts new file mode 100644 index 000000000..7f82204af --- /dev/null +++ b/packages/forge-mcp/src/server.ts @@ -0,0 +1,181 @@ +import { createMcpHandler, McpServer, type CallToolResult, type Tool } from '@modelcontextprotocol/server'; + +import { isCatalogue, type Catalogue, type CatalogueTool } from './catalogue.ts'; +import { buildRequest, InvalidArguments } from './request.ts'; + +/** What a tool call needs besides the tool and its arguments. */ +export interface CallContext { + /** The API's base URL. */ + baseUrl: string; + /** Headers every API call sends, such as `Authorization`. */ + headers?: HeadersInit; + fetch?: (request: Request) => Promise; + /** Turns a response body into the tool result's text. By default JSON is pretty-printed and text is kept. */ + formatResult?: (body: unknown, response: Response) => string; +} + +export interface ForgeMcpOptions { + /** The catalogue, or a function that loads it. A loaded catalogue is kept for the life of the handler. */ + catalogue: Catalogue | (() => Catalogue | Promise); + serverInfo?: { name: string; version: string }; + instructions?: string; + /** The API's base URL. Defaults to the catalogue's. */ + baseUrl?: string; + /** + * Called before each MCP request is served. Return the headers its API calls + * send, such as the caller's `Authorization`, or a response to refuse it. + */ + authorize?: (request: Request) => HeadersInit | Response | undefined | Promise; + fetch?: (request: Request) => Promise; + formatResult?: CallContext['formatResult']; +} + +/** + * An MCP server over Streamable HTTP that serves a catalogue's tools: a + * `fetch` to call from a Worker or any server with web-standard requests. + * + * Each request gets a fresh, stateless MCP server, as the SDK serves the + * 2026-07-28 protocol and stateless 2025 clients. `tools/list` returns the + * catalogue's tools, built once; `tools/call` makes one API request. + */ +export function createForgeMcpHandler(options: ForgeMcpOptions): { fetch(request: Request): Promise } { + const load = catalogueLoader(options.catalogue); + const serverInfo = options.serverInfo ?? { name: 'forge-mcp', version: '0.1.0' }; + + return { + async fetch(request) { + const authorized = options.authorize ? await options.authorize(request) : undefined; + if (authorized instanceof Response) { + return authorized; + } + const catalogue = await load(); + const context: CallContext = { + baseUrl: options.baseUrl ?? catalogue.api.baseUrl, + ...(authorized && { headers: authorized }), + ...(options.fetch && { fetch: options.fetch }), + ...(options.formatResult && { formatResult: options.formatResult }), + }; + const handler = createMcpHandler(() => { + const server = new McpServer(serverInfo, options.instructions ? { instructions: options.instructions } : {}); + serveCatalogue(server, catalogue, (tool, args) => callTool(tool, args, context)); + return server; + }); + return handler.fetch(request); + }, + }; +} + +/** + * Serves a catalogue's tools on an existing server, with low-level handlers: + * no per-tool registration, which for thousands of tools costs more than the + * request. `call` makes the API request; {@link callTool} is the default. + */ +export function serveCatalogue( + server: McpServer, + catalogue: Catalogue, + call: (tool: CatalogueTool, args: Record) => Promise, +): void { + const { list, byName } = catalogueTools(catalogue); + server.server.registerCapabilities({ tools: { listChanged: false } }); + // The SDK types the list as mutable; it only serializes it. + server.server.setRequestHandler('tools/list', () => ({ tools: list as Tool[] })); + server.server.setRequestHandler('tools/call', async (request) => { + const tool = byName.get(request.params.name); + if (!tool) { + return errorResult(`Unknown tool: ${request.params.name}`); + } + return call(tool, request.params.arguments ?? {}); + }); +} + +/** + * Calls one tool: builds its request, sends it, and returns the response as + * the result. A non-2xx response is a result with `isError: true`, not a + * protocol error, so the model sees what the API said. + */ +export async function callTool( + tool: CatalogueTool, + args: Record, + context: CallContext, +): Promise { + let request: Request; + try { + request = buildRequest(tool, args, context.baseUrl); + } catch (err) { + if (err instanceof InvalidArguments) { + return errorResult(err.message); + } + throw err; + } + if (context.headers) { + for (const [name, value] of new Headers(context.headers)) { + request.headers.set(name, value); + } + } + + const response = await (context.fetch ?? fetch)(request); + const type = response.headers.get('Content-Type') ?? ''; + const raw = await response.text(); + let body: unknown = raw; + if (type.includes('json') && raw) { + try { + body = JSON.parse(raw); + } catch { + // Not the JSON it says it is: keep the text. + } + } + const text = (context.formatResult ?? defaultFormat)(body, response); + return { content: [{ type: 'text', text }], ...(!response.ok && { isError: true }) }; +} + +function defaultFormat(body: unknown, response: Response): string { + const text = typeof body === 'string' ? body : JSON.stringify(body, null, 2); + return response.ok ? text : `${response.status} ${response.statusText}\n${text}`.trim(); +} + +function errorResult(text: string): CallToolResult { + return { content: [{ type: 'text', text }], isError: true }; +} + +/** A catalogue's tools as `tools/list` sends them, and a lookup by name. */ +export interface CatalogueTools { + /** Each tool without its `request` and `extensions`. The same array every time. */ + readonly list: readonly Tool[]; + readonly byName: ReadonlyMap; +} + +const servedCatalogues = new WeakMap(); + +/** + * A catalogue's `tools/list` payload and name lookup, built once per + * catalogue. For a server that lists other tools too and dispatches itself. + */ +export function catalogueTools(catalogue: Catalogue): CatalogueTools { + let entry = servedCatalogues.get(catalogue); + if (!entry) { + entry = { + list: catalogue.tools.map(({ request: _request, extensions: _extensions, ...tool }) => tool as Tool), + byName: new Map(catalogue.tools.map((tool) => [tool.name, tool])), + }; + servedCatalogues.set(catalogue, entry); + } + return entry; +} + +/** Loads the catalogue once. A failed load is retried on the next request. */ +function catalogueLoader(source: ForgeMcpOptions['catalogue']): () => Promise { + let loading: Promise | undefined; + return () => { + loading ??= (async () => { + const catalogue = typeof source === 'function' ? await source() : source; + if (!isCatalogue(catalogue)) { + throw new Error('Not a forge-mcp/1 catalogue'); + } + return catalogue; + })(); + loading.catch(() => { + loading = undefined; + }); + return loading; + }; +} diff --git a/packages/forge-mcp/src/tools.ts b/packages/forge-mcp/src/tools.ts new file mode 100644 index 000000000..fb1767df2 --- /dev/null +++ b/packages/forge-mcp/src/tools.ts @@ -0,0 +1,213 @@ +import type { Field, Forge, Method, Model, Type } from '@cloudflare/forge/plugin'; + +import { + CATALOGUE_FORMAT, + type Catalogue, + type CatalogueTool, + type ParamRoute, + type SkippedMethod, + type ToolAnnotations, +} from './catalogue.ts'; +import { SchemaBuilder } from './schema.ts'; + +/** MCP tool names are 1 to 128 characters. */ +const MAX_NAME_LENGTH = 128; + +const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']); + +export interface CatalogueOptions { + /** + * Whether a method gets a tool. By default every method that isn't + * deprecated. Hidden methods are included: `x-forge-hidden` keeps a method + * out of listings such as a CLI's help while leaving it callable, and a + * client can only call the tools `tools/list` returns. + */ + include?(method: Method): boolean; + /** A method's tool name. By default its group path and name in snake case, joined by `_`: `dns_records_create`. */ + name?(method: Method): string; + /** Changes a tool after it is built, or drops it by returning `undefined`. */ + transform?(tool: CatalogueTool, method: Method): CatalogueTool | undefined; +} + +/** A tool that couldn't be built, and why. */ +class Skip extends Error {} + +/** Builds the catalogue of every method in the forge. */ +export function buildCatalogue(forge: Forge, options: CatalogueOptions = {}): Catalogue { + const include = options.include ?? ((method: Method) => !method.deprecated); + const name = options.name ?? defaultName; + const schemas = new SchemaBuilder(); + const tools: CatalogueTool[] = []; + const skipped: SkippedMethod[] = []; + const names = new Set(); + + for (const group of forge.groups) { + for (const method of group.methods) { + if (!include(method)) { + continue; + } + const skip = (reason: string) => skipped.push({ method: method.httpMethod, path: method.path, reason }); + try { + let tool: CatalogueTool | undefined = buildTool(method, name(method), schemas); + if (options.transform) { + tool = options.transform(tool, method); + } + if (!tool) { + continue; + } + if (tool.name.length > MAX_NAME_LENGTH) { + throw new Skip(`tool name is over ${MAX_NAME_LENGTH} characters`); + } + if (names.has(tool.name)) { + throw new Skip(`another method already has the tool name ${tool.name}`); + } + names.add(tool.name); + tools.push(tool); + } catch (err) { + if (!(err instanceof Skip)) { + throw err; + } + skip(err.message); + } + } + } + + return { format: CATALOGUE_FORMAT, api: { version: forge.version, baseUrl: forge.baseUrl }, tools, skipped }; +} + +export function defaultName(method: Method): string { + return [...method.group.path, method.name].map((part) => part.snake()).join('_'); +} + +/** + * One method's tool. Path, query and header parameters are top-level + * arguments named by the parameter in snake case; a JSON body is the `body` + * argument, as the API takes it. + */ +function buildTool(method: Method, name: string, schemas: SchemaBuilder): CatalogueTool { + const refs = new Set(); + const params: ParamRoute[] = []; + const properties: Record = {}; + const required: string[] = []; + + const add = (key: string, schema: unknown, isRequired: boolean) => { + properties[key] = schema; + if (isRequired) { + required.push(key); + } + }; + + const located = parameterFields(method.request).flatMap((field) => { + const location = method.location(field); + return location ? [{ field, location }] : []; + }); + const headers = (method.headers?.fields ?? []).filter((field) => !field.readOnly); + const taken = new Set(); + const keyFor = (field: Field, where: string) => { + let key = field.name.snake() || field.wireName; + if (taken.has(key)) { + key = `${where}_${key}`; + } + if (taken.has(key)) { + throw new Skip(`two arguments are both named ${key}`); + } + taken.add(key); + return key; + }; + + for (const { field, location } of located) { + const key = keyFor(field, location.in); + add(key, schemas.field(field, refs), field.required || location.in === 'path'); + params.push( + location.in === 'path' + ? { key, name: field.wireName, in: 'path' } + : { key, name: field.wireName, in: 'query', explode: location.exploded }, + ); + } + for (const field of headers) { + const key = keyFor(field, 'header'); + add(key, schemas.field(field, refs), field.required); + params.push({ key, name: field.wireName, in: 'header' }); + } + + let body: CatalogueTool['request']['body']; + if (method.body) { + const json = method.body.json; + if (json) { + const key = taken.has('body') ? 'request_body' : 'body'; + if (taken.has(key)) { + throw new Skip(`two arguments are both named ${key}`); + } + add( + key, + { ...schemas.type(json.type, refs), ...(method.body.description && { description: method.body.description }) }, + method.body.required, + ); + body = { key, mediaType: json.mediaType }; + } else if (method.body.required) { + throw new Skip(`the request body is not JSON (${method.body.contents.map((c) => c.mediaType).join(', ')})`); + } + } + + const defs = schemas.defs(refs); + const title = method.summary || undefined; + return { + name, + ...(title && { title }), + description: describe(method), + inputSchema: { + type: 'object', + properties, + ...(required.length > 0 && { required }), + ...(defs && { $defs: defs }), + }, + annotations: annotations(method, title), + request: { method: method.httpMethod, path: method.path, params, ...(body && { body }) }, + }; +} + +/** The fields of a method's request: an object of parameters, or an intersection of parameters and the body. */ +function parameterFields(request: Type | undefined): readonly Field[] { + if (!request) { + return []; + } + if (request.kind === 'object') { + return request.fields; + } + if (request.kind === 'intersection') { + return request.members.flatMap(parameterFields); + } + return []; +} + +function describe(method: Method): string { + const parts = [method.description || method.summary || `${method.httpMethod} ${method.path}`]; + if (method.availability && method.availability !== 'generally-available') { + parts.push(`This method is ${method.availability}.`); + } + if (method.confirmation !== undefined) { + parts.push( + `Confirm with the user before calling this tool.${method.confirmation ? ` ${method.confirmation}` : ''}`, + ); + } + return parts.join('\n\n'); +} + +/** + * Hints from the HTTP method. A method that needs confirmation is never + * read-only and always destructive. Hints describe the method; they don't + * authorize anything. + */ +function annotations(method: Method, title: string | undefined): ToolAnnotations { + const verb = method.httpMethod.toUpperCase(); + const confirm = method.confirmation !== undefined; + const readOnly = READ_ONLY_METHODS.has(verb) && !confirm; + return { + ...(title && { title }), + readOnlyHint: readOnly, + destructiveHint: !readOnly && (confirm || verb === 'DELETE' || verb === 'PUT'), + idempotentHint: IDEMPOTENT_METHODS.has(verb), + openWorldHint: true, + }; +} diff --git a/packages/forge-mcp/test/catalogue.test.ts b/packages/forge-mcp/test/catalogue.test.ts new file mode 100644 index 000000000..ea65a1fbd --- /dev/null +++ b/packages/forge-mcp/test/catalogue.test.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { buildCatalogue } from '../src/tools.ts'; +import { field, forge, inPath, inQuery, integer, jsonBody, model, object, string } from './fake-forge.ts'; + +test('a method becomes a tool named by its group path and name', () => { + const zoneId = field('zone_id', string, { required: true, description: 'Zone.' }); + const page = field('page', integer); + const catalogue = buildCatalogue( + forge({ + 'dns.records': [ + { + name: 'list', + path: '/zones/{zone_id}/dns_records', + summary: 'List DNS records', + params: [ + [zoneId, inPath], + [page, inQuery], + ], + }, + ], + }), + ); + + assert.equal(catalogue.format, 'forge-mcp/1'); + assert.deepEqual(catalogue.api, { version: '1.0.0', baseUrl: 'https://api.example.com/v1' }); + assert.deepEqual(catalogue.tools, [ + { + name: 'dns_records_list', + title: 'List DNS records', + description: 'List DNS records', + inputSchema: { + type: 'object', + properties: { + zone_id: { type: 'string', description: 'Zone.' }, + page: { type: 'integer' }, + }, + required: ['zone_id'], + }, + annotations: { + title: 'List DNS records', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + request: { + method: 'GET', + path: '/zones/{zone_id}/dns_records', + params: [ + { key: 'zone_id', name: 'zone_id', in: 'path' }, + { key: 'page', name: 'page', in: 'query', explode: true }, + ], + }, + }, + ]); +}); + +test('a JSON body is the body argument, and headers are arguments too', () => { + const ifMatch = field('If-Match', string, { required: true }); + const record = object([ + field('name', string, { required: true }), + field('id', string, { readOnly: true, required: true }), + ]); + const [tool] = buildCatalogue( + forge({ records: [{ name: 'update', httpMethod: 'PUT', headers: [ifMatch], body: jsonBody(record) }] }), + ).tools; + + assert.deepEqual(tool?.inputSchema, { + type: 'object', + properties: { + if_match: { type: 'string' }, + body: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + }, + required: ['if_match', 'body'], + }); + assert.deepEqual(tool?.request.params, [{ key: 'if_match', name: 'If-Match', in: 'header' }]); + assert.deepEqual(tool?.request.body, { key: 'body', mediaType: 'application/json' }); + assert.equal(tool?.annotations.destructiveHint, true); +}); + +test('models are $defs, recursive ones included, and each tool has only the ones it reaches', () => { + const node = model('Tree Node', () => + object([ + field('children', { + kind: 'array', + elem: { kind: 'model', model: node }, + minItems: undefined, + maxItems: undefined, + }), + ]), + ); + const unused = model('Unused', () => string); + const [tree, other] = buildCatalogue( + forge({ + trees: [ + { name: 'create', httpMethod: 'POST', body: jsonBody({ kind: 'model', model: node }) }, + { name: 'unrelated', httpMethod: 'POST', body: jsonBody({ kind: 'model', model: unused }) }, + ], + }), + ).tools; + + assert.deepEqual(tree?.inputSchema.$defs, { + Tree_Node: { + type: 'object', + properties: { children: { type: 'array', items: { $ref: '#/$defs/Tree_Node' } } }, + }, + }); + assert.deepEqual(tree?.inputSchema.properties, { body: { $ref: '#/$defs/Tree_Node' } }); + assert.deepEqual(Object.keys(other?.inputSchema.$defs ?? {}), ['Unused']); +}); + +test('deprecated methods get no tool, hidden ones do, and other methods can be chosen', () => { + const methods = { + things: [ + { name: 'list' }, + { name: 'secret', hidden: true }, + { name: 'old', deprecated: true }, + { name: 'delete', httpMethod: 'DELETE' }, + ], + }; + assert.deepEqual( + buildCatalogue(forge(methods)).tools.map((tool) => tool.name), + ['things_list', 'things_secret', 'things_delete'], + ); + assert.deepEqual( + buildCatalogue(forge(methods), { include: (method) => method.httpMethod === 'GET' }).tools.map((t) => t.name), + ['things_list', 'things_secret', 'things_old'], + ); +}); + +test('a required body that is not JSON skips the method, with the reason', () => { + const upload = { mediaType: 'multipart/form-data', isJson: false, description: '', type: string }; + const catalogue = buildCatalogue( + forge({ + scripts: [ + { + name: 'upload', + httpMethod: 'PUT', + path: '/scripts', + body: { required: true, description: '', contents: [upload], json: undefined }, + }, + ], + }), + ); + + assert.deepEqual(catalogue.tools, []); + assert.deepEqual(catalogue.skipped, [ + { method: 'PUT', path: '/scripts', reason: 'the request body is not JSON (multipart/form-data)' }, + ]); +}); + +test('a second method with the same tool name is skipped', () => { + const catalogue = buildCatalogue( + forge({ + things: [ + { name: 'list', path: '/a' }, + { name: 'list', path: '/b' }, + ], + }), + ); + + assert.deepEqual( + catalogue.tools.map((tool) => tool.request.path), + ['/a'], + ); + assert.deepEqual(catalogue.skipped, [ + { method: 'GET', path: '/b', reason: 'another method already has the tool name things_list' }, + ]); +}); + +test('confirmation and availability are in the description, and confirmation makes a tool destructive', () => { + const [tool] = buildCatalogue( + forge({ + zones: [ + { + name: 'purge', + httpMethod: 'GET', + description: 'Purge everything.', + availability: 'beta', + confirmation: 'This removes every cached file.', + }, + ], + }), + ).tools; + + assert.equal( + tool?.description, + 'Purge everything.\n\nThis method is beta.\n\nConfirm with the user before calling this tool. This removes every cached file.', + ); + assert.equal(tool?.annotations.readOnlyHint, false); + assert.equal(tool?.annotations.destructiveHint, true); +}); + +test('transform changes or drops a tool', () => { + const catalogue = buildCatalogue(forge({ things: [{ name: 'list' }, { name: 'drop' }] }), { + transform: (tool) => (tool.name === 'things_drop' ? undefined : { ...tool, description: 'Changed.' }), + }); + + assert.deepEqual( + catalogue.tools.map((tool) => [tool.name, tool.description]), + [['things_list', 'Changed.']], + ); +}); diff --git a/packages/forge-mcp/test/fake-forge.ts b/packages/forge-mcp/test/fake-forge.ts new file mode 100644 index 000000000..1b7093f16 --- /dev/null +++ b/packages/forge-mcp/test/fake-forge.ts @@ -0,0 +1,126 @@ +// Plain objects with the shapes of @cloudflare/forge/plugin's interfaces, so +// the catalogue builder runs in Node without a forge host. +import type { + Body, + Field, + Forge, + Group, + Location, + Method, + Model, + Name, + ObjectType, + Type, +} from '@cloudflare/forge/plugin'; + +export function name(text: string): Name { + const words = text + .toLowerCase() + .split(/[\s_-]+/) + .filter(Boolean); + const cap = (word: string) => word.charAt(0).toUpperCase() + word.slice(1); + return { + words, + isEmpty: words.length === 0, + camel: () => words.map((word, i) => (i === 0 ? word : cap(word))).join(''), + pascal: () => words.map(cap).join(''), + snake: () => words.join('_'), + kebab: () => words.join('-'), + equals: (other) => other.words.join(' ') === words.join(' '), + toString: () => words.join(' '), + }; +} + +export const string: Type = { kind: 'scalar', scalar: 'string' }; +export const integer: Type = { kind: 'scalar', scalar: 'number', number: 'int32' }; + +export function field(wireName: string, type: Type, extra: Partial = {}): Field { + return { + wireName, + name: name(wireName), + required: false, + description: '', + deprecated: false, + readOnly: false, + sensitive: false, + default: undefined, + type, + ...extra, + }; +} + +export function object(fields: Field[], extra?: Type): ObjectType { + return { kind: 'object', fields, extra }; +} + +export function model(schemaName: string, type: () => Type, description = ''): Model { + const m = { schemaName, name: name(schemaName), group: undefined, description, deprecated: false } as Model & { + type: Type; + }; + Object.defineProperty(m, 'type', { get: type }); + return m; +} + +export function jsonBody(type: Type, required = true): Body { + const json = { mediaType: 'application/json', isJson: true, description: '', type }; + return { required, description: '', contents: [json], json }; +} + +interface MethodSpec { + name: string; + httpMethod?: string; + path?: string; + summary?: string; + description?: string; + params?: Array<[Field, Location]>; + headers?: Field[]; + body?: Body; + hidden?: boolean; + deprecated?: boolean; + confirmation?: string; + availability?: Method['availability']; +} + +export function forge(groups: Record, baseUrl = 'https://api.example.com/v1'): Forge { + const built: Group[] = Object.entries(groups).map(([groupName, specs]) => { + const group = { + name: name(groupName), + description: '', + path: groupName.split('.').map(name), + parent: undefined, + children: [], + descendants: [], + models: [], + } as unknown as Group & { methods: Method[] }; + group.methods = specs.map((spec) => method(spec, group)); + return group; + }); + return { version: '1.0.0', description: '', baseUrl, groups: built, models: [] }; +} + +function method(spec: MethodSpec, group: Group): Method { + const locations = new Map(spec.params ?? []); + return { + name: name(spec.name), + group, + httpMethod: spec.httpMethod ?? 'GET', + path: spec.path ?? '/things', + summary: spec.summary ?? '', + description: spec.description ?? '', + deprecated: spec.deprecated ?? false, + availability: spec.availability, + hidden: spec.hidden ?? false, + confirmation: spec.confirmation, + request: locations.size > 0 ? object([...locations.keys()]) : undefined, + headers: spec.headers ? object(spec.headers) : undefined, + body: spec.body, + responses: [], + errors: [], + extensions: [], + extension: () => undefined, + location: (f) => locations.get(f), + }; +} + +export const inPath: Location = { in: 'path' }; +export const inQuery: Location = { in: 'query', exploded: true }; diff --git a/packages/forge-mcp/test/request.test.ts b/packages/forge-mcp/test/request.test.ts new file mode 100644 index 000000000..6b6dbe742 --- /dev/null +++ b/packages/forge-mcp/test/request.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import type { CatalogueTool } from '../src/catalogue.ts'; +import { buildRequest, InvalidArguments } from '../src/request.ts'; + +const BASE = 'https://api.example.com/v1/'; + +function tool(request: CatalogueTool['request'], required: string[] = []): CatalogueTool { + return { + name: 't', + description: '', + inputSchema: { type: 'object', properties: {}, required }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + request, + }; +} + +test('path parameters are encoded, and query and header parameters are set', () => { + const request = buildRequest( + tool({ + method: 'GET', + path: '/zones/{zone_id}/records/{name}', + params: [ + { key: 'zone_id', name: 'zone_id', in: 'path' }, + { key: 'name', name: 'name', in: 'path' }, + { key: 'page', name: 'page', in: 'query', explode: true }, + { key: 'unset', name: 'unset', in: 'query', explode: true }, + { key: 'if_match', name: 'If-Match', in: 'header' }, + ], + }), + { zone_id: 'abc', name: 'a b/c', page: 2, if_match: 'etag' }, + BASE, + ); + + assert.equal(request.method, 'GET'); + assert.equal(request.url, 'https://api.example.com/v1/zones/abc/records/a%20b%2Fc?page=2'); + assert.equal(request.headers.get('If-Match'), 'etag'); + assert.equal(request.headers.get('Accept'), 'application/json'); +}); + +test('query arrays and objects follow the form style, exploded or not', () => { + const request = buildRequest( + tool({ + method: 'GET', + path: '/things', + params: [ + { key: 'ids', name: 'id', in: 'query', explode: true }, + { key: 'tags', name: 'tags', in: 'query', explode: false }, + { key: 'filter', name: 'filter', in: 'query', explode: true }, + ], + }), + { ids: [1, 2], tags: ['a', 'b'], filter: { status: 'active', type: 'A' } }, + BASE, + ); + + assert.equal(new URL(request.url).search, '?id=1&id=2&tags=a%2Cb&status=active&type=A'); +}); + +test('the JSON body is sent as given, with its media type', async () => { + const request = buildRequest( + tool({ method: 'POST', path: '/things', params: [], body: { key: 'body', mediaType: 'application/json' } }), + { body: { name: 'x', nested: { port: 5432 } } }, + BASE, + ); + + assert.equal(request.headers.get('Content-Type'), 'application/json'); + assert.deepEqual(await request.json(), { name: 'x', nested: { port: 5432 } }); +}); + +test('missing required arguments and unfilled path parameters are refused', () => { + const route = { + method: 'GET', + path: '/zones/{zone_id}', + params: [{ key: 'zone_id', name: 'zone_id', in: 'path' as const }], + }; + + assert.throws( + () => buildRequest(tool(route, ['zone_id']), {}, BASE), + new InvalidArguments('Missing required arguments: zone_id'), + ); + assert.throws(() => buildRequest(tool(route), {}, BASE), new InvalidArguments('Missing path parameter: zone_id')); + assert.throws(() => buildRequest(tool(route), { zone_id: { a: 1 } }, BASE), InvalidArguments); +}); diff --git a/packages/forge-mcp/test/server.test.ts b/packages/forge-mcp/test/server.test.ts new file mode 100644 index 000000000..913aed775 --- /dev/null +++ b/packages/forge-mcp/test/server.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import type { Catalogue } from '../src/catalogue.ts'; +import { catalogueTools, createForgeMcpHandler } from '../src/server.ts'; + +const catalogue: Catalogue = { + format: 'forge-mcp/1', + api: { version: '1', baseUrl: 'https://api.example.com/v1' }, + skipped: [], + tools: [ + { + name: 'zones_get', + title: 'Get a zone', + description: 'Get a zone.', + inputSchema: { type: 'object', properties: { zone_id: { type: 'string' } }, required: ['zone_id'] }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + request: { method: 'GET', path: '/zones/{zone_id}', params: [{ key: 'zone_id', name: 'zone_id', in: 'path' }] }, + }, + ], +}; + +/** A 2026-07-28 request, with the per-request envelope the protocol requires. */ +function mcp(method: string, params: Record = {}, headers: Record = {}): Request { + return new Request('https://mcp.example.com/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': '2026-07-28', + 'Mcp-Method': method, + ...(typeof params.name === 'string' && { 'Mcp-Name': params.name }), + ...headers, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params: { + ...params, + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'test', version: '1' }, + 'io.modelcontextprotocol/clientCapabilities': {}, + }, + }, + }), + }); +} + +async function result(response: Response): Promise { + const text = await response.text(); + const json = response.headers.get('Content-Type')?.includes('text/event-stream') + ? text + .split('\n') + .find((line) => line.startsWith('data:'))! + .slice(5) + : text; + return JSON.parse(json); +} + +function handler(calls: Request[] = [], status = 200) { + return createForgeMcpHandler({ + catalogue, + authorize: (request) => { + const authorization = request.headers.get('Authorization'); + return authorization ? { Authorization: authorization } : new Response('Unauthorized', { status: 401 }); + }, + fetch: async (request) => { + calls.push(request); + return Response.json({ success: status === 200, result: { id: 'abc' } }, { status }); + }, + }); +} + +test('tools/list returns the catalogue tools without their routes', async () => { + const body = await result(await handler().fetch(mcp('tools/list', {}, { Authorization: 'Bearer t' }))); + + assert.deepEqual(body.result.tools, [ + { + name: 'zones_get', + title: 'Get a zone', + description: 'Get a zone.', + inputSchema: { type: 'object', properties: { zone_id: { type: 'string' } }, required: ['zone_id'] }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + ]); +}); + +test('tools/call makes the API request with the authorized headers', async () => { + const calls: Request[] = []; + const body = await result( + await handler(calls).fetch( + mcp('tools/call', { name: 'zones_get', arguments: { zone_id: 'abc' } }, { Authorization: 'Bearer t' }), + ), + ); + + assert.equal(calls[0]?.url, 'https://api.example.com/v1/zones/abc'); + assert.equal(calls[0]?.headers.get('Authorization'), 'Bearer t'); + assert.equal(body.result.isError, undefined); + assert.deepEqual(JSON.parse(body.result.content[0].text), { success: true, result: { id: 'abc' } }); +}); + +test('an API error is a tool result with isError, so the model sees it', async () => { + const body = await result( + await handler([], 403).fetch( + mcp('tools/call', { name: 'zones_get', arguments: { zone_id: 'abc' } }, { Authorization: 'Bearer t' }), + ), + ); + + assert.equal(body.result.isError, true); + assert.match(body.result.content[0].text, /^403/); +}); + +test('missing arguments and unknown tools are tool errors', async () => { + const h = handler(); + const missing = await result( + await h.fetch(mcp('tools/call', { name: 'zones_get', arguments: {} }, { Authorization: 'Bearer t' })), + ); + const unknown = await result(await h.fetch(mcp('tools/call', { name: 'nope' }, { Authorization: 'Bearer t' }))); + + assert.equal(missing.result.isError, true); + assert.equal(missing.result.content[0].text, 'Missing required arguments: zone_id'); + assert.equal(unknown.result.isError, true); + assert.equal(unknown.result.content[0].text, 'Unknown tool: nope'); +}); + +test('authorize can refuse a request before it reaches MCP', async () => { + const response = await handler().fetch(mcp('tools/list')); + + assert.equal(response.status, 401); +}); + +test('a catalogue in an unknown format is refused', async () => { + const h = createForgeMcpHandler({ catalogue: () => ({ ...catalogue, format: 'forge-mcp/0' }) as never }); + + await assert.rejects(h.fetch(mcp('tools/list')), /Not a forge-mcp\/1 catalogue/); +}); + +test('extensions stay on the server', async () => { + const withExtensions: Catalogue = { + ...catalogue, + tools: catalogue.tools.map((tool) => ({ ...tool, extensions: { permissions: ['Zone Read'] } })), + }; + const h = createForgeMcpHandler({ catalogue: withExtensions }); + const body = await result(await h.fetch(mcp('tools/list'))); + + assert.equal('extensions' in body.result.tools[0], false); + assert.equal(catalogueTools(withExtensions).byName.get('zones_get')?.extensions?.permissions instanceof Array, true); +}); diff --git a/packages/forge-mcp/tsconfig.build.json b/packages/forge-mcp/tsconfig.build.json new file mode 100644 index 000000000..efa2cabbc --- /dev/null +++ b/packages/forge-mcp/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + // Compiles src/ into dist/ for the published package: JavaScript, and + // declarations, with .ts import specifiers rewritten to .js. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "allowImportingTsExtensions": false, + "rewriteRelativeImportExtensions": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/forge-mcp/tsconfig.json b/packages/forge-mcp/tsconfig.json new file mode 100644 index 000000000..2e282eb90 --- /dev/null +++ b/packages/forge-mcp/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["esnext", "dom", "dom.iterable"] + }, + "include": ["src", "test", "plugin"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 028c71f7c..6e08a5057 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -301,6 +301,28 @@ importers: specifier: 7.0.2 version: 7.0.2 + packages/forge-mcp: + dependencies: + '@modelcontextprotocol/server': + specifier: 2.3.1 + version: 2.3.1 + devDependencies: + '@bytecodealliance/jco': + specifier: 1.37.0 + version: 1.37.0 + '@cloudflare/forge': + specifier: workspace:* + version: link:../forge + '@types/node': + specifier: 25.3.5 + version: 25.3.5 + typescript: + specifier: 5.9.3 + version: 5.9.3 + wrangler: + specifier: ^4.127.0 + version: 4.127.0 + src/bin/forge/ts/lib: devDependencies: '@types/node': @@ -1544,6 +1566,14 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@modelcontextprotocol/core@2.3.1': + resolution: {integrity: sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.3.1': + resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==} + engines: {node: '>=20'} + '@napi-rs/lzma-android-arm-eabi@1.5.1': resolution: {integrity: sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -5365,6 +5395,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@modelcontextprotocol/core@2.3.1': + dependencies: + zod: 4.4.3 + + '@modelcontextprotocol/server@2.3.1': + dependencies: + '@modelcontextprotocol/core': 2.3.1 + zod: 4.4.3 + '@napi-rs/lzma-android-arm-eabi@1.5.1': optional: true