diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b3a6887c..431d28f98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -301,6 +301,18 @@ importers: specifier: 7.0.2 version: 7.0.2 + src/bin/forge/mcp/lib: + devDependencies: + '@modelcontextprotocol/server': + specifier: 2.3.1 + version: 2.3.1 + '@types/node': + specifier: 24.19.0 + version: 24.19.0 + typescript: + specifier: 7.0.2 + version: 7.0.2 + src/bin/forge/ts/lib: devDependencies: '@types/node': @@ -1496,6 +1508,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} @@ -5625,6 +5645,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@modelcontextprotocol/core@2.3.1': + dependencies: + zod: 4.6.7 + + '@modelcontextprotocol/server@2.3.1': + dependencies: + '@modelcontextprotocol/core': 2.3.1 + zod: 4.6.7 + '@napi-rs/lzma-android-arm-eabi@1.5.1': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 37820a9fd..b36da7557 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/* - src/bin/forge/ts/lib + - src/bin/forge/mcp/lib allowBuilds: esbuild: true workerd: true diff --git a/rfcs/mcp.md b/rfcs/mcp.md new file mode 100644 index 000000000..18cb276ff --- /dev/null +++ b/rfcs/mcp.md @@ -0,0 +1,218 @@ +# The MCP target + +`forge openapi.json --target mcp --out tools` generates the tools of an MCP server in TypeScript: one tool for each API method, with the code that turns a tool call into the method's HTTP request. This document says how forge maps the API onto MCP and what it generates. It describes the generator as it is. + +Forge generates the tools, not a server. Every server around them is different: how it authenticates, which tools, resources or prompts of its own it serves alongside, what it does with an API error. So the output is data a server lists and calls, plus the few functions every server needs to do that, typed for the official MCP TypeScript SDK, `@modelcontextprotocol/server`. + +```ts +import { buildRequest, listTools, toolsByName, tools } from './tools/index.js'; + +// tools/list +const listing = listTools(tools); + +// tools/call +const tool = toolsByName(tools).get(name); +const request = buildRequest(tool, args, 'https://api.example.com'); +const response = await fetch(request.url, { ...request, headers: { ...request.headers, authorization } }); +``` + +## Contents + +- [What it generates](#what-it-generates) +- [Which methods get a tool](#which-methods-get-a-tool) +- [Names](#names) +- [Arguments](#arguments) +- [The request](#the-request) +- [Schemas](#schemas) +- [Description and annotations](#description-and-annotations) +- [Extensions](#extensions) +- [Response schemas](#response-schemas) +- [Output schemas](#output-schemas) +- [The runtime](#the-runtime) +- [What clients see](#what-clients-see) + +## What it generates + +```text +tools/ + index.ts every tool, the API's base URL and version, and the runtime + README.md the number of tools, and each method without one and why + _/tool.ts the runtime: the Tool type, listing tools, building requests + schemas.ts the schema of each top-level model used in arguments + outputs.ts the schema of each top-level model used in responses + /tools.ts the tools of a group's methods + /schemas.ts the schema of each model placed in the group and used in arguments + /outputs.ts the schema of each model placed in the group and used in responses +``` + +Each `tools.ts` exports `tools`, an array of the group's tools sorted by name, and `index.ts` concatenates them. A group's files depend only on the group, so forge generates them in parallel (`generate_group`); the root's files come from one pass that only works out which methods get a tool. A tool is plain data plus one function: + +```ts +{ + name: 'dns_records_create', + title: 'Create DNS Record', + description: '…', + inputSchema: { type: 'object', properties: { path: { … }, body: { … } }, required: ['path', 'body'], $defs: { … } }, + annotations: { title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint }, + method: 'POST', + path: '/zones/{zone_id}/dns_records', + extensions: { 'x-api-token-group': [ … ] }, + responses: { schemas: { '200': { … }, '4XX': { … } }, $defs: { … } }, + outputSchema: { … , $defs: { … } }, + request: (args) => ({ path: `/zones/${pathParam(args, 'zone_id')}/dns_records`, body: { value: args['body'], mediaTypes: ['application/json'] } }), +} +``` + +The runtime imports nothing at runtime, only the SDK's types, so the tools load anywhere: in the server, or in a separate process that only lists them and plans requests. + +## Which methods get a tool + +Every method forge reads gets a tool, except: + +| Method | Why | +| ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | +| Deprecated (`deprecated: true`) | Left out silently. | +| A tool name over 128 characters | MCP's limit. | +| A tool name another method of its group already has, such as `x1b` and `x_1b`, both `x1b` in camelCase | Names must be unique across the server; the first method by name keeps it. See [Names](#names). | + +Each of these except deprecation is listed in `README.md` with the method, path and reason, so a change to the spec that loses a tool shows in review. Operations forge itself doesn't read, such as those with `x-fern-ignore`, never reach the generator. Hidden methods (`x-forge-hidden`) get a tool. + +## Names + +A tool is named after its groups and its method, each in camelCase, joined by `_`: + +| Group | Method | Tool | +| --------------------------------- | ----------- | ----------------------------------------- | +| `dns.records` | `create` | `dns_records_create` | +| `web_assets.discovery.operations` | `bulk_edit` | `webAssets_discovery_operations_bulkEdit` | +| `a.b` | `c` | `a_b_c` | +| `a` | `b_c` | `a_bC` | + +`_` only separates names, and forge treats `_` inside a name as a word break, so the words of a name never contain it. Group `a.b` method `c` and group `a` method `b_c` would both be `a_b_c` in snake_case; in camelCase they differ. Tools of different groups never share a name, so each group's tools are generated without looking at the others. OpenAI and Anthropic only accept `[a-zA-Z0-9_-]` in tool names, which rules out `.`. + +Two names can still have the same camelCase spelling, since a digit belongs to the word before it: `x1b` is one word and `x_1b` two, both `x1b`. Two methods of a group like that keep the first tool by name, and `README.md` lists the other. Two sibling groups like that share a directory, which forge refuses to write. The TypeScript SDK spells names the same way and has the same limit. + +The name comes from forge's group hierarchy and method names, as for every target, so it is stable while those are. Renaming a group renames its tools. + +## Arguments + +`inputSchema` is always an object, with an argument for each place a value goes: + +- **`path`**: an object of the path parameters, keyed by their names on the wire. Every one is required, and so is `path`. +- **`query`**: an object of the query parameters, likewise. Each is required if the spec says so, and `query` is required if one is. +- **`headers`**: an object of the header parameters, keyed by name in lower case, as HTTP/2 and `fetch` write them; `buildRequest` takes them in any case. Read-only headers are left out. +- **`body`**: the body, as the API takes it, with the body's description. It is required if the body is. Its schema is the spec's for its media type, except that a `format: binary` value, such as a file part, is its contents as text or `{ content, filename?, contentType? }`, as `FormData.append(name, blob, filename)` takes a file; a body in several media types is `anyOf` the schema of each, and `headers` gains `content-type`, an enum of them, which names the one sent. + +A place without parameters has no argument. `inputSchema` and each place set `additionalProperties: false`, so a client that validates arguments refuses a misspelled parameter, or one in the wrong place, before calling. The arguments mirror the HTTP request, so names never clash and are what the API documents: + +```json +{ "path": { "zone_id": "…" }, "query": { "page": 2 }, "body": { "name": "…" } } +``` + +## The request + +`request(args)` returns the request relative to the API's base URL: + +- `path`: the template with each parameter substituted. `pathParam` reads it from `args.path`, percent-encodes it, and throws `InvalidArguments` if it is missing or empty. +- `query`: each parameter's wire name, value and whether it is exploded. `param` reads it from `args.query`, and headers from `args.headers`, throwing `InvalidArguments` if the place isn't an object. +- `headers`: each header's wire name and value. +- `body`: the value and the media types the API takes it in. + +`buildRequest(tool, args, baseUrl)` turns that into the HTTP request, as plain data: `{ method, url, headers, body? }`, with lower-case header names and the body as a string. It crosses RPC or `postMessage` as it is, and `fetch(request.url, request)` sends it. It checks that every argument is one the tool takes, at the top level and inside `path`, `query` and `headers`, naming the place a parameter goes when it was passed in the wrong one (`per_page goes in query`) and what a place takes when a name is misspelled, and that the required arguments are present, including required parameters such as `query.page`, then: + +- serializes query parameters in OpenAPI's default `form` style (`a=1&a=2` exploded, `a=1,2` not; objects as `k=v` pairs or `name=k,v`); +- joins an array header with `,`; +- leaves out every query parameter and header that is `undefined` or `null`; +- sends `Accept: application/json`, and the body in the media type a `content-type` header argument names or else the one its value fits, as `fetch` picks one: a file's own type; for a string or file, the first that is neither JSON nor a form; for anything else, the first JSON one, else `multipart/form-data` or `application/x-www-form-urlencoded` for an object. It encodes the body as `fetch` would: a JSON media type as JSON; an object as `multipart/form-data` parts (a `{ content, filename?, contentType? }` as a file part, another object as a JSON part, anything else as a text part) or `application/x-www-form-urlencoded` pairs; and any other string as it is. A file as the whole body is its contents, as a `Blob` is. A string sent as `multipart/form-data` is a body already encoded; its first line names the boundary. Arguments are JSON, so a body of bytes, such as an image, can only be text. + +It doesn't validate values against the schema. The API does that, and its error says more than ours would. + +## Schemas + +Schemas are JSON Schema 2020-12, built from forge's model of the spec rather than copied from the OpenAPI document, so every target agrees on what a type is. + +| Forge type | Schema | +| -------------------------------------- | ------------------------------------------------------------------------------------------ | +| unknown | `{}` | +| null, boolean, string, integer, number | the `type` | +| uuid, date-time, date | `string` with that `format` | +| bytes | `string` with `contentEncoding: base64` | +| binary | `string` with `format: binary` | +| enum | `enum` of the values, with `type` when they share one | +| array | `items`, with `minItems`/`maxItems` | +| object | `properties`, `required`, and `additionalProperties` when the extra properties have a type | +| exclusive union / union | `oneOf` / `anyOf` | +| intersection | `allOf` | +| model | `{ "$ref": "#/$defs/" }` | + +A nullable type in the spec is already a union with `null` in forge, so it becomes `anyOf` with `{ "type": "null" }`. + +A model that is only a scalar or an enum, or a union or intersection of them, such as an identifier that is a string, is written where it is used: its schema is about as small as the reference. Every other model is a `$ref` into the schema's own `$defs`, so recursive models terminate, and each tool's `$defs` holds only the models its schema reaches, so every tool's schema stands alone. A model's schema is built once, as a constant in the `schemas.ts` or `outputs.ts` of the group forge places it in, and every tool that uses it imports the constant. The `$defs` key is the model's schema name, with each byte other than an ASCII letter, digit, `_` or `-` written as `.` and two hex digits (`Event$meta` is `Event.24meta`), so it needs no escaping in a JSON Pointer or URI fragment and no two models share one. The constant is named after the model's `$defs` key, with `-` written as `$$`, each `.XX` escape as `$XX`, and `$_` in front of a leading digit or a reserved word. Keys are unique, so constants are too, even for two models whose names have the same words, such as `logs-list` and `logs_list`; the names appear only in generated code. Both depend only on the model, so a group's files never need another group's. + +Arguments and responses are built separately, because a model differs between them: + +| | Arguments (`schemas.ts`) | Responses (`outputs.ts`) | +| ------------------------------------ | -------------------------------------- | ----------------------------------------------------- | +| Read-only fields | left out: a client doesn't send them | kept: the API returns them | +| Descriptions, defaults, `deprecated` | kept, for the model choosing arguments | left out: these schemas check data, nobody reads them | + +## Description and annotations + +`title` is the method's summary. `description` is the method's description, or its summary, followed by: + +- `This method is alpha.` (or `beta`, `in preview`, `deprecated`) when `x-fern-availability` says so; +- `Confirm with the user before calling this tool.` and the sentence from `x-forge-require-confirmation`, when the method has one; +- the method and path, `METHOD /path`, so a model can match the tool to the API's documentation. + +Descriptions are trimmed, here and in schemas. + +The annotations come from the HTTP method: + +| | `readOnlyHint` | `destructiveHint` | `idempotentHint` | +| ---------------------------------------------- | -------------- | ----------------- | ---------------- | +| `GET`, `HEAD`, `OPTIONS` | true | false | true | +| `POST`, `PATCH` | false | false | false | +| `PUT`, `DELETE` | false | true | true | +| any method with `x-forge-require-confirmation` | false | true | as above | + +`openWorldHint` is always true: every tool calls an API. The hints describe the method; they authorize nothing. + +## Extensions + +`extensions` holds the operation's `x-` extensions that forge doesn't read itself, as forge's `Method::extensions` returns them, such as `x-api-token-group`. They are for the server, for example to explain a refused call or to filter tools by permission, and are never listed to clients. + +## Response schemas + +`responses.schemas` has the schema of every response's JSON body, success or error, by status as the API declares it: a code (`200`), an uppercase range (`4XX`), or `default`. A response without a JSON body is `null`. `responses.$defs` holds the models they reach. + +They are for the server to check what the API returns against what the spec says: pick the status's schema, then its range, then `default`, and validate. A server can use the mismatches to find where the spec is wrong before it promises clients an output schema. They are never listed to clients. + +## Output schemas + +`outputSchema` is the JSON body schema of the method's successful (`2XX`) responses: the one schema when they agree, or `anyOf` the distinct ones, with `$defs` holding only the models those reach. A method with a successful response that has no JSON body gets none, since some calls would have no structured result. + +It is often not an object: a `$ref`, an `allOf`, an array or a scalar. MCP `2026-07-28` allows that; `2025` clients expect an object. + +`listTools` leaves output schemas out unless called with `{ outputSchema: true }`. A server that wants them lists them for `2026-07-28` requests and returns the parsed body as `structuredContent` itself. Until the spec's response schemas match what the API returns, an output schema can make a strict client reject a correct result, so serving them is the server's choice. + +## The runtime + +`_/tool.ts` is copied into every output. It is small enough to read: + +- `listTools(tools, { outputSchema })` returns the tools as `tools/list` sends them, without `request`, `method`, `path`, `extensions` and `responses`, and without `outputSchema` unless asked. The listing is built once per array of tools, so a server can answer `tools/list` with one handler rather than registering thousands of tools on every request. +- `toolsByName(tools)` indexes them for `tools/call`, once per array. +- `buildRequest(tool, args, baseUrl)` is described in [The request](#the-request). It throws `InvalidArguments` for arguments the tool can't be called with, which a server returns as a tool error. +- `checkArguments(tool, args)` throws `InvalidArguments` for an argument the tool doesn't take or a required one left out, as `buildRequest` does first. A server that fills in arguments, such as an account from the session, calls it before filling them in, so a misplaced argument isn't reported as missing. +- `param(args, place, name)` reads a parameter from the `path`, `query` or `headers` argument, for a server that inspects or fills in arguments before building the request. The generated `request` functions use it, and `pathParam` for the path. + +Everything else is the server's: sending the request with its credential, turning the response into a tool result, and anything it changes about a tool, such as making a parameter optional and filling it from the session. It can change a tool by mapping over `tools` before listing them, since a tool is plain data and its `request` reads only the arguments. + +## What clients see + +| Field | `tools/list` | Server only | +| ------------------------------------------------------------ | ------------ | ----------- | +| `name`, `title`, `description`, `inputSchema`, `annotations` | ✓ | | +| `outputSchema` | when asked | | +| `method`, `path`, `request` | | ✓ | +| `extensions` | | ✓ | +| `responses` | | ✓ | diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 6781dd8ea..c0d822076 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -2,6 +2,7 @@ //! //! ```text //! forge openapi.json --target ts --out sdk +//! forge openapi.json --target mcp --out tools //! forge openapi.json --target wasm://plugin.wasm --out sdk //! ``` @@ -13,6 +14,7 @@ use std::process::ExitCode; use clap::Parser; use forge::{Err, Forge}; +mod mcp; mod ts; /// Generates an SDK from an OpenAPI document. @@ -21,8 +23,9 @@ mod ts; struct Args { /// The OpenAPI JSON document, or - to read it from standard input. spec: PathBuf, - /// The language of the SDK: `ts` for TypeScript, or `wasm://PATH` for - /// the plugin compiled to a WebAssembly component at PATH. + /// The language of the SDK: `ts` for TypeScript, `mcp` for the tools of + /// an MCP server in TypeScript, or `wasm://PATH` for the plugin compiled + /// to a WebAssembly component at PATH. #[arg(long, value_parser = Target::parse)] target: Target, /// The directory to write the SDK to. @@ -35,6 +38,8 @@ struct Args { enum Target { /// The TypeScript generator. Ts, + /// The MCP generator, which generates the tools of an MCP server in TypeScript. + Mcp, /// A plugin compiled to a WebAssembly component, at the path. #[cfg(feature = "wasm")] Wasm(PathBuf), @@ -42,11 +47,13 @@ enum Target { impl Target { fn parse(s: &str) -> Result { - if s == "ts" { - return Ok(Target::Ts); + match s { + "ts" => return Ok(Target::Ts), + "mcp" => return Ok(Target::Mcp), + _ => {} } let Some(path) = s.strip_prefix("wasm://") else { - return Err("possible values: ts, wasm://PATH".to_owned()); + return Err("possible values: ts, mcp, wasm://PATH".to_owned()); }; if path.is_empty() { return Err("wasm:// needs the path of a plugin, as in wasm://plugin.wasm".to_owned()); @@ -75,6 +82,10 @@ fn run(args: Args) -> Result<(), Err> { let forge = read(&args.spec)?; forge::write(&args.out, forge::run(&forge, &ts::Generator)?) } + Target::Mcp => { + let forge = read(&args.spec)?; + forge::write(&args.out, forge::run(&forge, &mcp::Generator)?) + } #[cfg(feature = "wasm")] Target::Wasm(path) => { // Compiling the plugin and reading the document are both slow, diff --git a/src/bin/forge/mcp.rs b/src/bin/forge/mcp.rs new file mode 100644 index 000000000..2bde7fcc8 --- /dev/null +++ b/src/bin/forge/mcp.rs @@ -0,0 +1,1458 @@ +//! The MCP generator. It generates the tools of an MCP server in +//! TypeScript, as data a server lists and calls, typed for the official MCP +//! TypeScript SDK, `@modelcontextprotocol/server`. It generates no server: +//! each server lists and calls the tools in its own way. +//! +//! Every group with tools gets a `tools.ts` in its directory: the tools of +//! its methods, each with the JSON Schema of its arguments, its hints, the +//! JSON Schemas of its response bodies, its output schema, and a `request` +//! function that builds its API request. Response schemas are for the +//! server, to check responses against; `tools/list` doesn't send them. The +//! output schema is the schema of a successful response's JSON body, which +//! `listTools` sends only when asked. A group's `schemas.ts` and +//! `outputs.ts` hold the schemas of the models placed in it. The root gets +//! an `index.ts` with every tool and the runtime, a `README.md` that lists +//! the methods without a tool and why, the top-level models' schemas, and +//! the runtime itself in `_/tool.ts`: the tool type, listing tools, and +//! building a call's request as plain data. The runtime imports nothing, so +//! a server can load the tools anywhere. +//! +//! Each group's files depend only on the group, so they are generated in +//! parallel with [`Plugin::generate_group`]. A tool is named after its +//! groups and method, each in camelCase, joined by `_`, such as +//! `webAssets_discovery_operations_bulkEdit`, so tools of different groups +//! never share a name. Its arguments are objects by where they go: `path`, +//! `query` and `headers`, each keyed by the parameters' names on the wire, +//! and the JSON body as `body`, as the API takes it. + +use std::borrow::Cow; +use std::collections::HashSet; +use std::fmt::Write as _; + +use forge::ts::{self, Ident, Module, PREAMBLE, p}; +use forge::{Availability, Content, Err, Field, Forge, Group, HttpMethod, Location, Method, Model, Name, Plugin, Type}; +use serde_json::{Map, Value, json}; + +mod schema; + +use schema::{Direction, Refs, Schemas, reach}; + +/// Generates an MCP server. +pub struct Generator; + +impl Plugin for Generator { + type File = File; + + /// Returns the files of the root: `index.ts` with every tool, + /// `README.md`, the runtime, and the schemas of the top-level models. + fn generate(&self, forge: &Forge) -> Result, Err> { + let mut groups = Vec::new(); + let mut count = 0; + let mut skipped = Vec::new(); + for group in forge.groups() { + let before = count; + for (m, verdict) in verdicts(group) { + match verdict { + Ok(_) => count += 1, + Err(reason) => skipped.push((m, reason)), + } + } + if count > before { + groups.push(group); + } + } + + let mut files = model_files(Module::ROOT, forge.models(), forge.groups()); + files.push(File::Ts(index(forge, &groups))); + files.push(File::Text { + path: "README.md".to_owned(), + content: readme(count, groups.len(), &skipped), + }); + files.extend(RUNTIME.iter().map(|&(path, source)| File::Text { + path: RUNTIME_DIR.sub(path).as_str().to_owned(), + content: format!("{PREAMBLE}{source}"), + })); + Ok(files.into_iter()) + } + + /// Returns the files of `group`: a `tools.ts` with the tools of its + /// methods, and the schemas of the models placed in it. + fn generate_group(&self, group: Group<'_>) -> Result, Err> { + let mut tools = Vec::new(); + for (m, verdict) in verdicts(group) { + if let Ok(name) = verdict { + tools.push(Tool::new(m, name)?); + } + } + tools.sort_by(|a, b| a.name.cmp(&b.name)); + let mut files = Vec::new(); + if !tools.is_empty() { + files.push(File::Ts(tools_file(group, &tools))); + } + files.extend(model_files( + Module::group(group), + group.models(), + std::iter::once(group).chain(group.descendants()), + )); + Ok(files.into_iter()) + } +} + +/// A file of the server. +pub enum File { + /// A generated TypeScript file. + Ts(ts::File), + /// A file written as it is. + Text { path: String, content: String }, +} + +impl forge::File for File { + fn path(&self) -> &str { + match self { + File::Ts(f) => forge::File::path(f), + File::Text { path, .. } => path, + } + } + + fn content(&self) -> Result, Err> { + match self { + File::Ts(f) => forge::File::content(f), + File::Text { content, .. } => Ok(Cow::Borrowed(content.as_bytes())), + } + } +} + +/// The directory of the runtime library. +const RUNTIME_DIR: Module = Module::new("_"); + +/// The module of the runtime library that the generated files import: the +/// tool type, listing tools, and building their requests. +const RUNTIME_TOOL: Module = Module::new("_/tool.ts"); + +/// The sources of the runtime library, by path in lib/src. +const RUNTIME: [(&str, &str); 1] = [("tool.ts", include_str!("mcp/lib/src/tool.ts"))]; + +/// MCP tool names are at most 128 characters. +const MAX_NAME_LENGTH: usize = 128; + +/// Returns each method of `group` that isn't deprecated, with the name of +/// its tool, or why it has none. Deprecated methods are left out silently. +/// +/// Forge keeps the names of a group's methods unique, but two can still +/// have the same camelCase spelling, since a digit belongs to the word +/// before it: `x1b` and `x_1b`. The first method by name keeps the tool +/// name. Groups whose paths have the same spelling share a directory, which +/// [`forge::write`] refuses. +fn verdicts(group: Group<'_>) -> Vec<(Method<'_>, Result)> { + let mut names = HashSet::new(); + group + .methods() + .filter(|m| !m.is_deprecated()) + .map(|m| { + let verdict = check(m).and_then(|name| { + if names.insert(name.clone()) { + Ok(name) + } else { + Err(format!("another method has the tool name {name}")) + } + }); + (m, verdict) + }) + .collect() +} + +/// Returns the name of the tool of `m`, or why it can't have one, without +/// building its schemas. +fn check(m: Method<'_>) -> Result { + let name = tool_name(m); + if name.len() > MAX_NAME_LENGTH { + return Err(format!("the tool name is over {MAX_NAME_LENGTH} characters")); + } + segments(m.path(), &path_names(m))?; + Ok(name) +} + +/// Returns the wire names of the path parameters of `m`. +fn path_names(m: Method<'_>) -> Vec<&str> { + parameters(m.request()) + .into_iter() + .filter(|&f| m.location(f) == Some(Location::Path)) + .map(|f| f.wire_name()) + .collect() +} + +/// Returns the types of the arguments of `m`, whose models its input +/// schema refers to. +fn input_types(m: Method<'_>) -> Vec> { + let mut types: Vec> = parameters(m.request()) + .into_iter() + .filter(|&f| m.location(f).is_some()) + .map(|f| f.ty()) + .collect(); + types.extend( + m.headers() + .into_iter() + .flat_map(|h| h.fields()) + .filter(|f| !f.is_read_only()) + .map(|f| f.ty()), + ); + types.extend(m.body().into_iter().flat_map(|b| b.contents()).map(|c| c.ty())); + types +} + +/// Returns the types of the JSON bodies of the responses of `m`, whose +/// models its response schemas refer to. +fn output_types(m: Method<'_>) -> Vec> { + m.responses() + .chain(m.errors()) + .filter_map(|r| r.json().map(|c| c.ty())) + .collect() +} + +/// The tool of a method. +struct Tool<'f> { + method: Method<'f>, + name: String, + /// The properties of the input schema, and those that are required. + properties: Value, + required: Vec, + /// The models in the input schema's `$defs`, by key. + defs: std::collections::BTreeMap>, + /// The path template, split into text and the parameters that fill it. + path: Vec, + /// Each query parameter's wire name, and whether it is exploded. + query: Vec<(String, bool)>, + /// Each header parameter's wire name. + headers: Vec, + /// The media types the body can be sent in. + body: Option>, + /// The schema of each response's JSON body, by status, or `None` for a + /// response without one. + responses: Vec<(String, Option)>, + /// The models in the response schemas' `$defs`, by key. + response_defs: std::collections::BTreeMap>, + /// The output schema: the schema of a successful response's JSON body, + /// or `None` if a successful response has no JSON body. + output: Option, + /// The models in the output schema's `$defs`, by key. + output_defs: std::collections::BTreeMap>, +} + +/// A part of a path template. +enum Segment { + Text(String), + /// A path parameter, by wire name. + Param(String), +} + +/// The parameters of a tool in one place, `path`, `query` or `headers`: the +/// argument that holds them, an object keyed by their wire names. +#[derive(Default)] +struct Params { + properties: Map, + required: Vec, +} + +impl Params { + /// Adds `field`, a parameter named `key` that is required if `required`. + fn add<'f>(&mut self, key: String, field: Field<'f>, required: bool, refs: &mut Refs<'f>) { + self.properties.insert(key.clone(), Schemas::INPUT.field(field, refs)); + if required { + self.required.push(key); + } + } + + /// Adds the argument `key` holding the parameters to `properties`, and + /// to `required` if one of them is. A place without parameters has no + /// argument. + fn insert_into(self, key: &str, properties: &mut Map, required: &mut Vec) { + if self.properties.is_empty() { + return; + } + let mut schema = Map::new(); + schema.insert("type".to_owned(), json!("object")); + schema.insert("properties".to_owned(), Value::Object(self.properties)); + schema.insert("additionalProperties".to_owned(), json!(false)); + if !self.required.is_empty() { + schema.insert("required".to_owned(), json!(self.required)); + required.push(key.to_owned()); + } + properties.insert(key.to_owned(), Value::Object(schema)); + } +} + +impl<'f> Tool<'f> { + /// Returns the tool of `m` named `name`. [`check`] has accepted `m`. + fn new(m: Method<'f>, name: String) -> Result, String> { + let mut refs = Refs::new(); + let mut path_params = Params::default(); + let mut query_params = Params::default(); + let mut header_params = Params::default(); + let mut query = Vec::new(); + let mut headers = Vec::new(); + + for field in parameters(m.request()) { + match m.location(field) { + Some(Location::Path) => path_params.add(field.wire_name().to_owned(), field, true, &mut refs), + Some(Location::Query { exploded }) => { + query_params.add(field.wire_name().to_owned(), field, field.is_required(), &mut refs); + query.push((field.wire_name().to_owned(), exploded)); + } + None => {} + } + } + for field in m + .headers() + .into_iter() + .flat_map(|h| h.fields()) + .filter(|f| !f.is_read_only()) + { + // Header names are case-insensitive; lower case is how HTTP/2 + // and `fetch` write them. + let name = field.wire_name().to_ascii_lowercase(); + header_params.add(name.clone(), field, field.is_required(), &mut refs); + headers.push(name); + } + + // A body in more than one media type is in the one the + // `content-type` header names, or else the one its value fits. + let contents: Vec> = m.body().into_iter().flat_map(|b| b.contents()).collect(); + if contents.len() > 1 && !headers.iter().any(|h| h == "content-type") { + let types: Vec<&str> = contents.iter().map(|c| c.media_type()).collect(); + header_params.properties.insert( + "content-type".to_owned(), + json!({ "type": "string", "enum": types, "description": "The media type of `body`. Without it, a string is sent as the first text type, and an object as JSON if the API takes JSON, else as multipart/form-data." }), + ); + headers.push("content-type".to_owned()); + } + + let mut properties = Map::new(); + let mut required = Vec::new(); + path_params.insert_into("path", &mut properties, &mut required); + query_params.insert_into("query", &mut properties, &mut required); + header_params.insert_into("headers", &mut properties, &mut required); + + let mut body = None; + if let Some(b) = m.body() + && !contents.is_empty() + { + let mut schema = match &contents[..] { + [only] => Schemas::INPUT.ty(only.ty(), &mut refs), + _ => json!({ "anyOf": contents.iter().map(|c| { + let mut schema = Schemas::INPUT.ty(c.ty(), &mut refs); + if let Value::Object(map) = &mut schema { + map.insert("description".to_owned(), json!(format!("As {}.", c.media_type()))); + } + schema + }).collect::>() }), + }; + if let Value::Object(map) = &mut schema + && !b.description().trim().is_empty() + { + map.insert("description".to_owned(), json!(b.description().trim())); + } + properties.insert("body".to_owned(), schema); + if b.is_required() { + required.push("body".to_owned()); + } + body = Some(contents.iter().map(|c| c.media_type().to_owned()).collect()); + } + + let path = segments(m.path(), &path_names(m))?; + let mut response_refs = Refs::new(); + let mut success_refs = Refs::new(); + let mut successes = Vec::new(); + let mut responses = Vec::new(); + for r in m.responses().chain(m.errors()) { + let status = if r.status().eq_ignore_ascii_case("default") { + "default".to_owned() + } else { + r.status().to_ascii_uppercase() + }; + let mut refs = Refs::new(); + let schema = r.json().map(|c| Schemas::OUTPUT.ty(c.ty(), &mut refs)); + if status.starts_with('2') { + successes.push(schema.clone()); + success_refs.extend(refs.iter().copied()); + } + response_refs.extend(refs); + responses.push((status, schema)); + } + let output = output_schema(successes); + let output_defs = if output.is_some() { + Schemas::OUTPUT.defs(&success_refs) + } else { + std::collections::BTreeMap::new() + }; + + Ok(Tool { + method: m, + name, + properties: Value::Object(properties), + required, + defs: Schemas::INPUT.defs(&refs), + path, + query, + headers, + body, + responses, + response_defs: Schemas::OUTPUT.defs(&response_refs), + output, + output_defs, + }) + } + + /// Returns the hints of the tool, from the HTTP method. A method that + /// needs confirmation is never read-only, and always destructive. + fn annotations(&self) -> Value { + let confirm = self.method.confirmation().is_some(); + let method = self.method.http_method(); + let read_only = matches!(method, HttpMethod::Get | HttpMethod::Head | HttpMethod::Options) && !confirm; + let destructive = !read_only && (confirm || matches!(method, HttpMethod::Delete | HttpMethod::Put)); + let idempotent = matches!( + method, + HttpMethod::Get | HttpMethod::Head | HttpMethod::Options | HttpMethod::Put | HttpMethod::Delete + ); + let mut annotations = Map::new(); + let title = self.method.summary().trim(); + if !title.is_empty() { + annotations.insert("title".to_owned(), json!(title)); + } + annotations.insert("readOnlyHint".to_owned(), json!(read_only)); + annotations.insert("destructiveHint".to_owned(), json!(destructive)); + annotations.insert("idempotentHint".to_owned(), json!(idempotent)); + annotations.insert("openWorldHint".to_owned(), json!(true)); + Value::Object(annotations) + } +} + +/// Returns the output schema of a method whose successful responses have +/// the JSON body schemas `successes`: the one schema, or `anyOf` the distinct +/// ones. `None` if there are none, or one has no JSON body, since its result +/// would have no structured content. +fn output_schema(successes: Vec>) -> Option { + let mut distinct: Vec = Vec::new(); + for schema in successes { + let schema = schema?; + if !distinct.contains(&schema) { + distinct.push(schema); + } + } + match distinct.len() { + 0 => None, + 1 => distinct.pop(), + _ => Some(json!({ "anyOf": distinct })), + } +} + +/// Returns the fields of a method's request: an object of parameters, or an +/// intersection of the parameters and the body. +fn parameters(request: Option>) -> Vec> { + match request { + Some(Type::Object(object)) => object.fields().collect(), + Some(Type::Intersection(intersection)) => intersection + .members() + .flat_map(|member| parameters(Some(member))) + .collect(), + _ => Vec::new(), + } +} + +/// Returns `template` split into text and the path parameters that fill +/// it, given their wire names. +fn segments(template: &str, names: &[&str]) -> Result, String> { + let mut segments = Vec::new(); + let mut rest = template; + while let Some(start) = rest.find('{') { + let Some(len) = rest[start..].find('}') else { + break; + }; + let wire = &rest[start + 1..start + len]; + if !names.contains(&wire) { + return Err(format!("the path parameter {wire} has no argument")); + } + if start > 0 { + segments.push(Segment::Text(rest[..start].to_owned())); + } + segments.push(Segment::Param(wire.to_owned())); + rest = &rest[start + len + 1..]; + } + if !rest.is_empty() { + segments.push(Segment::Text(rest.to_owned())); + } + Ok(segments) +} + +/// Returns the name of the tool of `m`: the names of its groups and its +/// own, in camelCase, joined by `_`, such as `accounts_members_list` or +/// `webAssets_discovery_operations_bulkEdit`. `_` only ever separates +/// names, so tools of different groups can't share a name. +fn tool_name(m: Method<'_>) -> String { + let mut names: Vec = m.group().path().map(Name::camel).collect(); + names.push(m.name().camel()); + names.join("_") +} + +/// Returns the description of the tool of `m`: its description, or its +/// summary, then its stage of the API if it isn't generally available, and +/// that it needs confirmation. +fn description(m: Method<'_>) -> String { + let description = m.description().trim(); + let summary = m.summary().trim(); + let endpoint = format!("{} {}", m.http_method().as_str(), m.path()); + let mut paragraphs = Vec::new(); + if !description.is_empty() { + paragraphs.push(description.to_owned()); + } else if !summary.is_empty() { + paragraphs.push(summary.to_owned()); + } + let stage = match m.availability() { + Some(Availability::Alpha) => Some("alpha"), + Some(Availability::Beta) => Some("beta"), + Some(Availability::Preview) => Some("in preview"), + Some(Availability::Deprecated) => Some("deprecated"), + Some(Availability::GenerallyAvailable) | None => None, + }; + if let Some(stage) = stage { + paragraphs.push(format!("This method is {stage}.")); + } + if let Some(confirmation) = m.confirmation() { + let confirmation = confirmation.trim(); + let mut text = "Confirm with the user before calling this tool.".to_owned(); + if !confirmation.is_empty() { + text.push(' '); + text.push_str(confirmation); + } + paragraphs.push(text); + } + paragraphs.push(endpoint); + paragraphs.join("\n\n") +} + +/// The schemas a file of model constants holds. +#[derive(Clone, Copy)] +enum SchemaFile { + /// Models in arguments, in `schemas.ts`. + Input, + /// Models in response bodies, in `outputs.ts`, named with `Output` so a + /// `tools.ts` can import both. + Output, +} + +impl SchemaFile { + fn file(self) -> &'static str { + match self { + SchemaFile::Input => "schemas.ts", + SchemaFile::Output => "outputs.ts", + } + } + + fn schemas(self) -> Schemas { + match self { + SchemaFile::Input => Schemas::INPUT, + SchemaFile::Output => Schemas::OUTPUT, + } + } +} + +/// Returns the constant with the schema of `model` in a file of `kind`, +/// declared in its group's directory, or the root's for a top-level model. +/// Forge places each model, so any file can name its constant. +fn model_ident(model: Model<'_>, kind: SchemaFile) -> Ident { + let dir = model.group().map_or(Module::ROOT, Module::group); + dir.sub(kind.file()).ident(constant_name(&schema::key(model))) +} + +/// Returns the name of the constant of the model whose `$defs` key is `key`: +/// the key with `-` written as `$$` and each `.XX` escape as `$XX`, and `$_` +/// in front if it starts with a digit or is a reserved word. Keys are unique +/// and the encoding is reversible, so names are too, without looking at any +/// other model; they appear only in generated code. +fn constant_name(key: &str) -> String { + let mut name = String::with_capacity(key.len()); + for c in key.chars() { + match c { + '-' => name.push_str("$$"), + '.' => name.push('$'), + c => name.push(c), + } + } + if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) || RESERVED.contains(&name.as_str()) { + name.insert_str(0, "$_"); + } + name +} + +/// Returns the `schemas.ts` and `outputs.ts` of `dir`: a constant with the +/// schema of each model of `placed`, the models placed there, that a tool +/// of `groups` uses in its arguments or its responses. `groups` are the +/// groups whose methods can use the models: the group of `dir` and those +/// below it. +fn model_files<'f>( + dir: Module, + placed: impl Iterator>, + groups: impl Iterator>, +) -> Vec { + let placed: HashSet> = placed.collect(); + if placed.is_empty() { + return Vec::new(); + } + let mut inputs = Refs::new(); + let mut outputs = Refs::new(); + for group in groups { + for (m, verdict) in verdicts(group) { + if verdict.is_err() { + continue; + } + for t in input_types(m) { + reach(t, Direction::Input, &mut inputs); + } + for t in output_types(m) { + reach(t, Direction::Output, &mut outputs); + } + } + } + let mut files = Vec::new(); + for (kind, reached) in [(SchemaFile::Input, inputs), (SchemaFile::Output, outputs)] { + let mut models: Vec> = reached.into_iter().filter(|m| placed.contains(m)).collect(); + if models.is_empty() { + continue; + } + models.sort_by_cached_key(|&m| schema::key(m)); + files.push(File::Ts(schema_file(dir.sub(kind.file()), &models, kind))); + } + files +} + +/// Returns the file `module` with the constant of each of `models`, which +/// are placed in its directory. +fn schema_file(module: Module, models: &[Model<'_>], kind: SchemaFile) -> ts::File { + let mut f = ts::File::new(module); + let schema_type = RUNTIME_TOOL.ident("JsonSchema").ty(); + let doc = match kind { + SchemaFile::Input => "", + SchemaFile::Output => " in responses", + }; + for &model in models { + let ident = f.ident(model_ident(model, kind).name); + f.doc(&format!("The schema of `{}`{doc}.", model.schema_name())); + let json = serde_json::to_string_pretty(&kind.schemas().model(model)).unwrap_or_else(|_| "{}".to_owned()); + let mut lines = json.lines().peekable(); + let mut first = true; + while let Some(line) = lines.next() { + let prefix: Vec<&dyn ts::Print> = if first { + vec![&"export const ", &ident, &": ", &schema_type, &" = "] + } else { + Vec::new() + }; + let suffix = if lines.peek().is_none() { ";" } else { "" }; + let mut parts = prefix; + parts.push(&line); + parts.push(&suffix); + f.line(&parts); + first = false; + } + p!(f, ""); + } + f +} + +/// Words that can't name a constant. +const RESERVED: &[&str] = &[ + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "new", + "null", + "return", + "super", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", + "let", + "static", + "implements", + "interface", + "package", + "private", + "protected", + "public", + "await", +]; + +/// Returns the `tools.ts` of `group`. +fn tools_file(group: Group<'_>, tools: &[Tool<'_>]) -> ts::File { + let mut f = ts::File::new(Module::group(group).sub("tools.ts")); + let tool = RUNTIME_TOOL.ident("Tool").ty(); + let path_param = RUNTIME_TOOL.ident("pathParam"); + let param = RUNTIME_TOOL.ident("param"); + let dotted: Vec = group.path().map(Name::camel).collect(); + f.doc(&format!("The tools of `{}`.", dotted.join("."))); + p!(f, "export const tools: readonly ", tool, "[] = ["); + f.indent(|f| { + for t in tools { + p!(f, "{"); + f.indent(|f| { + let m = t.method; + p!(f, "name: ", string(&t.name), ","); + let title = m.summary().trim(); + if !title.is_empty() { + p!(f, "title: ", string(title), ","); + } + p!(f, "description: ", string(&description(m)), ","); + p!(f, "inputSchema: {"); + f.indent(|f| { + p!(f, "type: \"object\","); + json_property(f, "properties", &t.properties); + if !t.required.is_empty() { + json_property(f, "required", &json!(t.required)); + } + p!(f, "additionalProperties: false,"); + if !t.defs.is_empty() { + p!(f, "$defs: {"); + f.indent(|f| { + for (key, model) in &t.defs { + p!(f, string(key), ": ", model_ident(*model, SchemaFile::Input), ","); + } + }); + p!(f, "},"); + } + }); + p!(f, "},"); + if let Some(Value::Object(output)) = &t.output { + p!(f, "outputSchema: {"); + f.indent(|f| { + for (key, value) in output { + json_property(f, &string(key), value); + } + if !t.output_defs.is_empty() { + p!(f, "$defs: {"); + f.indent(|f| { + for (key, model) in &t.output_defs { + p!(f, string(key), ": ", model_ident(*model, SchemaFile::Output), ","); + } + }); + p!(f, "},"); + } + }); + p!(f, "},"); + } + json_property(f, "annotations", &t.annotations()); + p!(f, "method: ", string(m.http_method().as_str()), ","); + p!(f, "path: ", string(m.path()), ","); + let extensions: Map = m + .extensions() + .map(|(name, value)| (name.to_owned(), schema::value(value))) + .collect(); + if !extensions.is_empty() { + json_property(f, "extensions", &Value::Object(extensions)); + } + if !t.responses.is_empty() { + p!(f, "responses: {"); + f.indent(|f| { + let by_status: Map = t + .responses + .iter() + .map(|(status, schema)| (status.clone(), schema.clone().unwrap_or(Value::Null))) + .collect(); + json_property(f, "schemas", &Value::Object(by_status)); + if !t.response_defs.is_empty() { + p!(f, "$defs: {"); + f.indent(|f| { + for (key, model) in &t.response_defs { + p!(f, string(key), ": ", model_ident(*model, SchemaFile::Output), ","); + } + }); + p!(f, "},"); + } + }); + p!(f, "},"); + } + let args = if t.query.is_empty() + && t.headers.is_empty() + && t.body.is_none() + && !t.path.iter().any(|s| matches!(s, Segment::Param(_))) + { + "_args" + } else { + "args" + }; + p!(f, "request: (", args, ") => ({"); + f.indent(|f| { + let mut parts: Vec> = vec![Box::new("path: `".to_owned())]; + for segment in &t.path { + match segment { + Segment::Text(text) => parts.push(Box::new(template_text(text))), + Segment::Param(wire) => { + parts.push(Box::new("${".to_owned())); + parts.push(Box::new(path_param.clone())); + parts.push(Box::new(format!("(args, {})}}", string(wire)))); + } + } + } + parts.push(Box::new("`,".to_owned())); + let parts: Vec<&dyn ts::Print> = parts.iter().map(AsRef::as_ref).collect(); + f.line(&parts); + if !t.query.is_empty() { + p!(f, "query: ["); + f.indent(|f| { + for (wire, explode) in &t.query { + p!( + f, + "[", + string(wire), + ", ", + param.clone(), + "(args, \"query\", ", + string(wire), + "), ", + explode.to_string(), + "]," + ); + } + }); + p!(f, "],"); + } + if !t.headers.is_empty() { + p!(f, "headers: ["); + f.indent(|f| { + for wire in &t.headers { + p!( + f, + "[", + string(wire), + ", ", + param.clone(), + "(args, \"headers\", ", + string(wire), + ")]," + ); + } + }); + p!(f, "],"); + } + if let Some(media_types) = &t.body { + let types: Vec = media_types.iter().map(|t| string(t)).collect(); + p!( + f, + "body: { value: args[\"body\"], mediaTypes: [", + types.join(", "), + "] }," + ); + } + }); + p!(f, "}),"); + }); + p!(f, "},"); + } + }); + p!(f, "];"); + f +} + +/// Returns the `index.ts` of the root: the API, and every tool of `groups`, +/// the groups with tools. +fn index(forge: &Forge, groups: &[Group<'_>]) -> ts::File { + let mut f = ts::File::new(Module::ROOT.sub("index.ts")); + let tool = RUNTIME_TOOL.ident("Tool").ty(); + p!(f, "export * from './_/tool.js';"); + p!(f, ""); + f.doc("The API the tools call."); + p!(f, "export const api = {"); + f.indent(|f| { + p!(f, "baseUrl: ", string(&forge.base_url), ","); + p!(f, "version: ", string(&forge.version), ","); + p!(f, "description: ", string(forge.description.trim()), ","); + }); + p!(f, "} as const;"); + p!(f, ""); + f.doc("Every tool, by group and then by name."); + p!(f, "export const tools: readonly ", tool, "[] = ["); + f.indent(|f| { + for group in groups { + let alias: Vec = group.path().map(|name| name.camel()).collect(); + let ident = Module::group(*group) + .sub("tools.ts") + .ident("tools") + .alias(format!("{}Tools", ts_identifier(&alias.join("_")))); + p!(f, "...", ident, ","); + } + }); + p!(f, "];"); + f +} + +/// Returns the README.md of the server: how many tools it has, in how many +/// groups, and each method without one and why. +fn readme(count: usize, groups: usize, skipped: &[(Method<'_>, String)]) -> String { + let mut out = String::from("\n\n# MCP tools\n\n"); + let _ = writeln!( + out, + "{count} tools in {groups} groups. `index.ts` exports them as `tools`, with the runtime in `_/tool.ts` that lists them and builds their requests." + ); + if !skipped.is_empty() { + let _ = writeln!( + out, + "\n## Methods without a tool\n\n| Method | Path | Why |\n| --- | --- | --- |" + ); + for (m, reason) in skipped { + let _ = writeln!( + out, + "| `{}` | `{}` | {} |", + m.http_method().as_str(), + m.path(), + reason.replace('|', "\\|") + ); + } + } + out +} + +/// Prints the property `key` with the value `value`, as JSON over as many +/// lines as it needs. +fn json_property(f: &mut ts::File, key: &str, value: &Value) { + let json = serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_owned()); + let mut lines = json.lines().peekable(); + let mut first = true; + while let Some(line) = lines.next() { + let last = lines.peek().is_none(); + let prefix = if first { format!("{key}: ") } else { String::new() }; + let suffix = if last { "," } else { "" }; + p!(f, prefix, line.to_owned(), suffix); + first = false; + } +} + +/// Returns `s` as a TypeScript string literal. +fn string(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_owned()) +} + +/// Returns `text` escaped for the inside of a template literal. +fn template_text(text: &str) -> String { + text.replace('\\', "\\\\").replace('`', "\\`").replace("${", "\\${") +} + +/// Returns `s` with every character that can't be in an identifier replaced +/// by `_`, and `_` in front if it starts with a digit. +fn ts_identifier(s: &str) -> String { + let mut out: String = s + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '$' { + c + } else { + '_' + } + }) + .collect(); + if out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +#[cfg(test)] +mod tests { + use forge::{File as _, Forge}; + + use super::Generator; + + /// Returns the files the generator makes from `spec`, by path. + fn generate(spec: &str) -> Vec<(String, String)> { + let forge: Forge = serde_json::from_str(spec).unwrap(); + let mut files: Vec<(String, String)> = forge::run(&forge, &Generator) + .unwrap() + .into_iter() + .map(|f| { + let content = String::from_utf8(f.content().unwrap().into_owned()).unwrap(); + (f.path().to_owned(), content) + }) + .collect(); + files.sort(); + files + } + + /// Returns the file at `path`. + fn file(files: &[(String, String)], path: &str) -> String { + files + .iter() + .find(|(p, _)| p == path) + .unwrap_or_else(|| panic!("no {path} in {:?}", files.iter().map(|(p, _)| p).collect::>())) + .1 + .clone() + } + + const PETS: &str = r##"{"openapi": "3.1.0", "info": {"title": "Pets", "version": "2"}, + "servers": [{"url": "https://api.example.com/v1"}], + "paths": { + "/stores/{store_id}/pets": { + "get": {"x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "list", + "summary": "List pets", "x-api-token-group": ["Pets Read"], + "parameters": [ + {"name": "store_id", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "tag", "in": "query", "explode": false, + "schema": {"type": "array", "items": {"type": "string"}}}, + {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} + ], + "responses": { + "200": {"description": "ok", "content": {"application/json": { + "schema": {"type": "array", "items": {"$ref": "#/components/schemas/Pet"}}}}}, + "4XX": {"description": "failed"}}}, + "post": {"x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "create", + "x-forge-require-confirmation": "This adopts a pet.", + "parameters": [{"name": "store_id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"required": true, "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/Pet"}}}}, + "responses": {"200": {"description": "ok"}}} + }, + "/stores/{store_id}/pets/{pet_id}": { + "delete": {"x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "delete", + "parameters": [ + {"name": "store_id", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "pet_id", "in": "path", "required": true, "schema": {"type": "integer"}} + ], + "responses": {"200": {"description": "ok"}}}, + "put": {"x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "replace", "deprecated": true, + "parameters": [ + {"name": "store_id", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "pet_id", "in": "path", "required": true, "schema": {"type": "integer"}} + ], + "responses": {"200": {"description": "ok"}}} + }, + "/stores/{store_id}/photos": { + "post": {"x-fern-sdk-group-name": "pets.photos", "x-fern-sdk-method-name": "upload", + "parameters": [{"name": "store_id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"required": true, "content": {"image/png": {"schema": {"type": "string"}}}}, + "responses": {"200": {"description": "ok"}}} + } + }, + "components": {"schemas": {"Pet": {"type": "object", "required": ["name"], + "properties": {"id": {"type": "string", "readOnly": true}, "name": {"type": "string"}, + "friends": {"type": "array", "items": {"$ref": "#/components/schemas/Pet"}}}}}}}"##; + + #[test] + fn files() { + let paths: Vec = generate(PETS).into_iter().map(|(path, _)| path).collect(); + assert_eq!( + paths, + [ + "README.md", + "_/tool.ts", + "index.ts", + "pets/outputs.ts", + "pets/photos/tools.ts", + "pets/schemas.ts", + "pets/tools.ts" + ] + ); + } + + #[test] + fn index() { + let index = file(&generate(PETS), "index.ts"); + assert!( + index.contains(r#"import { tools as petsTools } from "./pets/tools.js";"#), + "{index}" + ); + assert!(index.contains(r#"baseUrl: "https://api.example.com/v1","#), "{index}"); + assert!(index.contains("...petsTools,"), "{index}"); + assert!(index.contains("export * from './_/tool.js';"), "{index}"); + } + + #[test] + fn parameters() { + let tools = file(&generate(PETS), "pets/tools.ts"); + assert!(tools.contains(r#"name: "pets_list","#), "{tools}"); + assert!(tools.contains(r#"title: "List pets","#), "{tools}"); + assert!( + tools.contains(r#"path: `/stores/${pathParam(args, "store_id")}/pets`,"#), + "{tools}" + ); + assert!( + tools.contains(r#"["tag", param(args, "query", "tag"), false],"#), + "{tools}" + ); + assert!( + tools.contains(r#"["x-trace", param(args, "headers", "x-trace")],"#), + "header names in lower case: {tools}" + ); + let list = &tools[tools.find(r#"name: "pets_list","#).unwrap()..]; + let input = &list[list.find("inputSchema: {").unwrap()..list.find("outputSchema").unwrap()]; + for place in [r#""path": {"#, r#""query": {"#, r#""headers": {"#] { + assert!(input.contains(place), "parameters by where they go: {input}"); + } + assert!(input.contains(r#""store_id": {"#), "keyed by wire name: {input}"); + assert!( + input.contains(r#""x-trace": {"#), + "a header by its name in lower case: {input}" + ); + assert!( + input.contains("required: [\n \"path\"\n ],"), + "only the path is required: {input}" + ); + assert!(tools.contains(r#""x-api-token-group": ["#), "{tools}"); + assert!(tools.contains(r#""readOnlyHint": true"#), "{tools}"); + } + + #[test] + fn body_and_shared_models() { + let files = generate(PETS); + let tools = file(&files, "pets/tools.ts"); + assert!( + tools.contains(r#"body: { value: args["body"], mediaTypes: ["application/json"] },"#), + "{tools}" + ); + assert!(tools.contains(r#""Pet": Pet,"#), "{tools}"); + assert!( + tools.contains("Confirm with the user before calling this tool. This adopts a pet."), + "{tools}" + ); + let schemas = file(&files, "pets/schemas.ts"); + assert!(schemas.contains("export const Pet: JsonSchema = {"), "{schemas}"); + assert!(schemas.contains(r##""$ref": "#/$defs/Pet""##), "{schemas}"); + assert!(!schemas.contains(r#""id""#), "read-only fields are left out: {schemas}"); + } + + #[test] + fn responses() { + let files = generate(PETS); + let tools = file(&files, "pets/tools.ts"); + assert!(tools.contains("responses: {"), "{tools}"); + assert!( + tools.contains(r#""4XX": null"#), + "a response without a JSON body: {tools}" + ); + assert!(tools.contains(r##""$ref": "#/$defs/Pet""##), "{tools}"); + assert!(tools.contains(r#""Pet": Pet,"#), "{tools}"); + let outputs = file(&files, "pets/outputs.ts"); + assert!(outputs.contains("export const Pet: JsonSchema = {"), "{outputs}"); + assert!( + outputs.contains(r#""id""#), + "responses keep read-only fields: {outputs}" + ); + assert!( + !outputs.contains("description"), + "responses have no descriptions: {outputs}" + ); + let schemas = file(&files, "pets/schemas.ts"); + assert!( + !schemas.contains(r#""id""#), + "arguments still leave read-only fields out: {schemas}" + ); + } + + #[test] + fn output_schema() { + let tools = file(&generate(PETS), "pets/tools.ts"); + let list = &tools[tools.find(r#"name: "pets_list","#).unwrap()..]; + let list = &list[..list.find("request: ").unwrap()]; + assert!( + list.contains("outputSchema: {\n \"items\": {\n \"$ref\": \"#/$defs/Pet\"\n },\n \"type\": \"array\",\n $defs: {\n \"Pet\": Pet,\n },\n },"), + "the 200 body, with its models: {list}" + ); + let create = &tools[tools.find(r#"name: "pets_create","#).unwrap()..]; + let create = &create[..create.find("request: ").unwrap()]; + assert!( + !create.contains("outputSchema"), + "a successful response without a JSON body: {create}" + ); + + let tools = file( + &generate( + r##"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "make", + "responses": { + "200": {"description": "ok", "content": {"application/json": {"schema": {"type": "string"}}}}, + "201": {"description": "made", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/A"}}}}, + "202": {"description": "same", "content": {"application/json": {"schema": {"type": "string"}}}}, + "4XX": {"description": "failed", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/E"}}}}}}}}, + "components": {"schemas": {"A": {"type": "object"}, "E": {"type": "object"}}}}"##, + ), + "a/tools.ts", + ); + let output = &tools[tools.find("outputSchema: {").unwrap()..tools.find("annotations: ").unwrap()]; + assert!(output.contains(r#""anyOf": ["#), "the distinct 2xx bodies: {output}"); + assert_eq!(output.matches(r#""type": "string""#).count(), 1, "{output}"); + assert!(output.contains(r#""A": A,"#), "{output}"); + assert!(!output.contains(r#""E":"#), "error models are left out: {output}"); + } + + #[test] + fn skips() { + let files = generate(PETS); + let tools = file(&files, "pets/tools.ts"); + assert!( + !tools.contains("pets_replace"), + "deprecated methods have no tool: {tools}" + ); + let readme = file(&files, "README.md"); + assert!(readme.contains("4 tools in 2 groups."), "{readme}"); + assert!( + !readme.contains("photos"), + "a body in any media type gets a tool: {readme}" + ); + } + + /// A body in several media types is in the one the `content-type` header + /// argument names, JSON by default, with the spec's schema for each; a + /// string body of any other media type is sent as it is. + #[test] + fn bodies_in_any_media_type() { + let files = generate( + r##"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": { + "/values/{key}": {"put": {"x-fern-sdk-group-name": "kv", "x-fern-sdk-method-name": "put", + "parameters": [{"name": "key", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"required": true, "content": { + "multipart/form-data": {"schema": {"type": "object", "required": ["value"], "properties": { + "value": {"type": "string", "format": "binary"}, + "metadata": {"type": "object"}}}}, + "application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}, + "responses": {"200": {"description": "ok"}}}}, + "/query": {"post": {"x-fern-sdk-group-name": "kv", "x-fern-sdk-method-name": "query", + "requestBody": {"required": true, "content": { + "text/plain": {"schema": {"type": "string"}}, + "application/json": {"schema": {"type": "object", "properties": {"sql": {"type": "string"}}}}}}, + "responses": {"200": {"description": "ok"}}}}, + "/sql": {"post": {"x-fern-sdk-group-name": "kv", "x-fern-sdk-method-name": "sql", + "requestBody": {"required": true, "content": {"text/plain": {"schema": {"type": "string"}}}}, + "responses": {"200": {"description": "ok"}}}}}}"##, + ); + let tools = file(&files, "kv/tools.ts"); + let compact: String = tools.split_whitespace().collect(); + assert!( + compact.contains(r#""enum":["application/octet-stream","multipart/form-data"]"#), + "the media types the body takes: {tools}" + ); + assert!(tools.contains(r#""description": "As multipart/form-data.""#), "{tools}"); + assert!( + tools.contains(r#""description": "As application/octet-stream.""#), + "{tools}" + ); + assert!(tools.contains(r#""metadata": {"#), "the spec's form fields: {tools}"); + assert!( + compact.contains(r#""filename":{"type":"string"}"#), + "a file can be named: {tools}" + ); + assert!( + compact.contains(r#""enum":["application/json","text/plain"]"#), + "the media types, in the spec's order: {tools}" + ); + assert!( + tools.contains(r#"["content-type", param(args, "headers", "content-type")],"#), + "sent as a header: {tools}" + ); + assert!( + tools.contains(r#"body: { value: args["body"], mediaTypes: ["application/json", "text/plain"] },"#), + "{tools}" + ); + assert!( + tools.contains(r#"body: { value: args["body"], mediaTypes: ["text/plain"] },"#), + "one media type: {tools}" + ); + assert_eq!( + tools.matches(r#""content-type": {"#).count(), + 2, + "only with a choice: {tools}" + ); + } + + #[test] + fn names() { + let files = generate( + r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "c", + "responses": {"200": {"description": "ok"}}}}, + "/b": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "b_c", + "responses": {"200": {"description": "ok"}}}}, + "/c": {"get": {"x-fern-sdk-group-name": "web_assets.discovery", "x-fern-sdk-method-name": "bulk_edit", + "responses": {"200": {"description": "ok"}}}} + }}"#, + ); + assert!(file(&files, "a/b/tools.ts").contains(r#"name: "a_b_c","#)); + assert!(file(&files, "a/tools.ts").contains(r#"name: "a_bC","#)); + assert!(file(&files, "webAssets/discovery/tools.ts").contains(r#"name: "webAssets_discovery_bulkEdit","#)); + assert!(!file(&files, "README.md").contains("Methods without a tool")); + } + + /// Two names of methods in a group can have the same camelCase + /// spelling, since a digit belongs to the word before it. + #[test] + fn same_spelling() { + let files = generate( + r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "x1b", + "responses": {"200": {"description": "ok"}}}}, + "/b": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "x_1b", + "responses": {"200": {"description": "ok"}}}} + }}"#, + ); + let tools = file(&files, "a/tools.ts"); + assert_eq!(tools.matches(r#"name: "a_x1b","#).count(), 1, "{tools}"); + let readme = file(&files, "README.md"); + assert!(readme.contains("1 tools in 1 groups."), "{readme}"); + assert!(readme.contains("another method has the tool name a_x1b"), "{readme}"); + } + + /// A model is a constant in the group it's placed in, which tools of + /// other groups import, and its key depends only on its schema name. + #[test] + fn models_across_groups() { + let files = generate( + r##"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a.x", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/Shared$1"}}}}}}}, + "/b": {"post": {"x-fern-sdk-group-name": "a.y", "x-fern-sdk-method-name": "make", + "requestBody": {"required": true, "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/Shared$1"}}}}, + "responses": {"200": {"description": "ok"}}}}, + "/c": {"get": {"x-fern-sdk-group-name": "b", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/Top"}}}}}}} + }, + "components": {"schemas": { + "Shared$1": {"type": "object", "properties": {"top": {"$ref": "#/components/schemas/Top"}}}, + "Top": {"type": "object", "properties": {"n": {"type": "integer"}}}}}}"##, + ); + let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); + assert!( + paths.contains(&"a/schemas.ts") && paths.contains(&"a/outputs.ts"), + "{paths:?}" + ); + assert!( + paths.contains(&"outputs.ts") && paths.contains(&"schemas.ts"), + "{paths:?}" + ); + assert!(!paths.contains(&"a/x/schemas.ts"), "only models a tool uses: {paths:?}"); + let x = file(&files, "a/x/tools.ts"); + assert!(x.contains(r#""Shared.241": Shared$241,"#), "{x}"); + assert!(x.contains(r#""Top": Top,"#), "{x}"); + assert!(x.contains(r#"from "../outputs.js""#), "{x}"); + assert!(x.contains(r#"from "../../outputs.js""#), "{x}"); + let shared = file(&files, "a/outputs.ts"); + assert!(shared.contains(r##""$ref": "#/$defs/Top""##), "{shared}"); + } + + /// Two models whose names have the same words, such as the schemas + /// `logs-list` and `logs_list`, still get a constant each: constants are + /// named after the `$defs` key, which is unique. + #[test] + fn models_with_the_same_name() { + let files = generate( + r##"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "make", + "requestBody": {"required": true, "content": {"application/json": {"schema": { + "type": "object", "properties": { + "x": {"$ref": "#/components/schemas/logs-list"}, + "y": {"$ref": "#/components/schemas/logs_list"}, + "z": {"$ref": "#/components/schemas/2fa"}}}}}}, + "responses": {"200": {"description": "ok"}}}}}, + "components": {"schemas": {"logs-list": {"type": "object"}, "logs_list": {"type": "object", "required": ["a"]}, + "2fa": {"type": "array"}}}}"##, + ); + let schemas = file(&files, "a/schemas.ts"); + assert!(schemas.contains("export const logs$$list: JsonSchema = {"), "{schemas}"); + assert!(schemas.contains("export const logs_list: JsonSchema = {"), "{schemas}"); + assert!(schemas.contains("export const $_2fa: JsonSchema = {"), "{schemas}"); + let tools = file(&files, "a/tools.ts"); + assert!(tools.contains(r#""logs-list": logs$$list,"#), "{tools}"); + assert!(tools.contains(r#""logs_list": logs_list,"#), "{tools}"); + } + + /// A model that is only a scalar or an enum is written where it is + /// used, not as a `$ref`; descriptions are trimmed; arguments can't have + /// properties the tool doesn't take; and the description ends with the + /// method and path. + #[test] + fn compact_input_schemas() { + let files = generate( + r##"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/zones/{zone_id}": {"get": {"x-fern-sdk-group-name": "zones", "x-fern-sdk-method-name": "get", + "description": "Gets a zone.\n", + "parameters": [ + {"name": "zone_id", "in": "path", "required": true, "description": "The zone.\n", + "schema": {"$ref": "#/components/schemas/identifier"}}, + {"name": "kind", "in": "query", "schema": {"$ref": "#/components/schemas/kind"}}], + "responses": {"200": {"description": "ok"}}}}}, + "components": {"schemas": { + "identifier": {"type": "string", "description": "Identifier."}, + "kind": {"type": "string", "enum": ["full", "partial"]}}}}"##, + ); + let tools = file(&files, "zones/tools.ts"); + assert!(!tools.contains("$defs"), "scalar models are inline: {tools}"); + assert!( + !files.iter().any(|(path, _)| path.ends_with("schemas.ts")), + "no constants for them" + ); + assert!( + tools.contains(r#""description": "The zone.","#), + "trimmed, the field's own: {tools}" + ); + assert!(tools.contains(r#""enum": ["#), "{tools}"); + assert_eq!( + tools.matches(r#""additionalProperties": false"#).count(), + 2, + "path and query: {tools}" + ); + assert!( + tools.contains("additionalProperties: false,"), + "and the arguments: {tools}" + ); + assert!( + tools.contains(r#"description: "Gets a zone.\n\nGET /zones/{zone_id}","#), + "{tools}" + ); + } + + /// Every source of the runtime library is embedded. + #[test] + fn runtime_is_complete() { + let dir: std::path::PathBuf = [env!("CARGO_MANIFEST_DIR"), "src/bin/forge/mcp/lib/src"] + .iter() + .collect(); + let mut paths: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + paths.sort(); + let embedded: Vec<&str> = super::RUNTIME.iter().map(|&(path, _)| path).collect(); + assert_eq!(paths, embedded); + } + + #[test] + fn template_text() { + assert_eq!(super::template_text("/a`b${c}\\"), "/a\\`b\\${c}\\\\"); + } +} diff --git a/src/bin/forge/mcp/lib/.gitignore b/src/bin/forge/mcp/lib/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/src/bin/forge/mcp/lib/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/src/bin/forge/mcp/lib/package.json b/src/bin/forge/mcp/lib/package.json new file mode 100644 index 000000000..cc219bc65 --- /dev/null +++ b/src/bin/forge/mcp/lib/package.json @@ -0,0 +1,25 @@ +{ + "name": "@internal/mcp-lib", + "version": "0.1.0", + "private": true, + "description": "The runtime that the generated MCP servers import.", + "type": "module", + "exports": { + ".": "./src/tool.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json", + "test": "node --import ./test/register.ts --test \"test/**/*.test.ts\"" + }, + "devDependencies": { + "@modelcontextprotocol/server": "2.3.1", + "@types/node": "24.19.0", + "typescript": "7.0.2" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.3.1" + }, + "engines": { + "node": ">=26" + } +} diff --git a/src/bin/forge/mcp/lib/src/tool.ts b/src/bin/forge/mcp/lib/src/tool.ts new file mode 100644 index 000000000..734827edc --- /dev/null +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -0,0 +1,474 @@ +import type { Tool as McpTool } from '@modelcontextprotocol/server'; + +/** + * The arguments of a tool call, as the client sent them: the parameters by + * where they go, `path`, `query` and `headers`, each an object keyed by the + * parameters' names on the wire, and the JSON body as `body`. + */ +export type Args = Readonly>; + +/** Where a parameter goes: the argument that holds it. */ +export type ParamLocation = 'path' | 'query' | 'headers'; + +/** A JSON Schema, as plain JSON. */ +export type JsonSchema = { readonly [key: string]: unknown }; + +/** The JSON Schema of a tool's arguments: always an object. */ +export type InputSchema = JsonSchema & { + readonly type: 'object'; + readonly properties?: { readonly [key: string]: JsonSchema }; + readonly required?: readonly string[]; +}; + +/** + * The JSON Schemas of a method's response bodies, for the server to check + * responses against. `tools/list` doesn't send them. + */ +export interface ToolResponses { + /** + * The schema of each response's JSON body, by status as the API declares it: + * a code such as `200`, a range such as `4XX`, or `default`. `null` is a + * response without a JSON body. + */ + readonly schemas: { readonly [status: string]: JsonSchema | null }; + /** The models the schemas refer to as `#/$defs/`. */ + readonly $defs?: { readonly [key: string]: JsonSchema }; +} + +/** What a tool says about calling it. The hints describe the API method; they don't authorize anything. */ +export interface ToolAnnotations { + readonly title?: string; + readonly readOnlyHint: boolean; + readonly destructiveHint: boolean; + readonly idempotentHint: boolean; + readonly openWorldHint: boolean; +} + +/** A query parameter: its name, its value, and whether an array or object value is exploded. */ +export type QueryParam = readonly [name: string, value: unknown, explode: boolean]; + +/** A header parameter: its name and its value. */ +export type HeaderParam = readonly [name: string, value: unknown]; + +/** + * An HTTP request as plain data, so it crosses RPC or `postMessage` as it is. + * `fetch(request.url, request)` sends it. Header names are lower case. + */ +export interface HttpRequest { + readonly method: string; + readonly url: string; + readonly headers: Readonly>; + readonly body?: string; +} + +/** + * The request a tool call makes, relative to the API's base URL. The path has + * its parameters substituted and encoded; query and header values are + * serialized by {@link buildRequest}, which leaves out those that are + * `undefined` or `null`. + */ +export interface ApiRequest { + readonly path: string; + readonly query?: readonly QueryParam[]; + readonly headers?: readonly HeaderParam[]; + /** The body, and the media types the API takes it in. */ + readonly body?: { readonly value: unknown; readonly mediaTypes: readonly string[] }; +} + +/** + * A generated tool: what `tools/list` sends for it, and the code that makes + * its API request. + */ +export interface Tool { + readonly name: string; + readonly title?: string; + readonly description: string; + readonly inputSchema: InputSchema; + /** + * The JSON Schema of the tool's structured result, the JSON body of a + * successful response, with the models it refers to in `$defs`. A tool + * without one has a successful response with no JSON body. `listTools` + * sends it only when asked. + */ + readonly outputSchema?: JsonSchema; + readonly annotations: ToolAnnotations; + /** The HTTP method of the API method, such as `GET`. */ + readonly method: string; + /** The path template of the API method, such as `/zones/{zone_id}`. */ + readonly path: string; + /** + * The OpenAPI extensions of the method's operation that forge doesn't read + * itself, such as `x-api-token-group`. For the server; clients never see them. + */ + readonly extensions?: { readonly [name: string]: unknown }; + /** The JSON Schemas of the method's response bodies. For the server; clients never see them. */ + readonly responses?: ToolResponses; + /** Returns the API request for `args`. Throws {@link InvalidArguments}. */ + request(args: Args): ApiRequest; +} + +/** Arguments a tool can't be called with, such as a missing path parameter. */ +export class InvalidArguments extends Error { + override name = 'InvalidArguments'; +} + +/** + * Returns the parameter `name` in `location`: `args[location][name]`, or + * `undefined` if either is missing. Throws {@link InvalidArguments} when + * `args[location]` isn't an object. + */ +export function param(args: Args, location: ParamLocation, name: string): unknown { + const values = args[location]; + if (values === undefined || values === null) { + return undefined; + } + if (typeof values !== 'object' || Array.isArray(values)) { + throw new InvalidArguments(`${location} must be an object`); + } + const record = values as Readonly>; + if (location !== 'headers' || Object.hasOwn(record, name)) { + return record[name]; + } + // Header names are case-insensitive: `Content-Type` is `content-type`. + const key = Object.keys(record).find((key) => key.toLowerCase() === name.toLowerCase()); + return key === undefined ? undefined : record[key]; +} + +/** + * Returns the path parameter `name`: a string, number or boolean, + * percent-encoded. Throws {@link InvalidArguments} when it is missing. + */ +export function pathParam(args: Args, name: string): string { + const value = param(args, 'path', name); + if (value === undefined || value === null || value === '') { + throw new InvalidArguments(`Missing path parameter: ${name}`); + } + return encodeURIComponent(scalar(name, value)); +} + +/** + * Throws {@link InvalidArguments} if `args` has an argument `tool` doesn't + * take: a top-level one, naming the place it goes when it is a parameter, such + * as `per_page` at the top level instead of in `query`, or one inside `path`, + * `query` or `headers`, such as a misspelled `query.nmae`. + */ +function checkUnknownArguments(tool: Tool, args: Args): void { + const properties = tool.inputSchema.properties ?? {}; + const placeParams = (place: string): readonly string[] => { + const params = properties[place]?.['properties']; + return typeof params === 'object' && params !== null ? Object.keys(params) : []; + }; + const problems = Object.keys(args) + .filter((key) => !Object.hasOwn(properties, key)) + .map((key) => { + const place = Object.keys(properties).find((name) => placeParams(name).includes(key)); + return place ? `${key} goes in ${place}: { "${place}": { "${key}": … } }` : `${key} is not an argument`; + }); + for (const place of ['path', 'query', 'headers'] as const) { + const given = args[place]; + if (given === null || typeof given !== 'object' || Array.isArray(given) || !Object.hasOwn(properties, place)) { + continue; + } + const takes = placeParams(place); + const known = (name: string) => + place === 'headers' ? takes.some((take) => take.toLowerCase() === name.toLowerCase()) : takes.includes(name); + for (const key of Object.keys(given).filter((name) => !known(name))) { + problems.push(`${place}.${key} is not an argument; ${place} takes ${takes.join(', ')}`); + } + } + if (problems.length > 0) { + const takes = Object.keys(properties); + throw new InvalidArguments( + `Unknown arguments for ${tool.name}: ${problems.join('; ')}. It takes ${takes.length > 0 ? takes.join(', ') : 'no arguments'}.`, + ); + } +} + +/** + * Throws {@link InvalidArguments} if `tool` can't be called with `args`: an + * argument it doesn't take, or a required one left out. {@link buildRequest} + * calls it; a server that fills in arguments, such as an account from the + * session, can call it first, so a misplaced argument isn't mistaken for a + * missing one. + */ +export function checkArguments(tool: Tool, args: Args): void { + checkUnknownArguments(tool, args); + const missing = missingArguments(tool, args); + if (missing.length > 0) { + throw new InvalidArguments(`Missing required arguments: ${missing.join(', ')}`); + } +} + +/** + * Returns the required arguments of `tool` that `args` leaves out: a + * top-level argument such as `body`, or a parameter such as `query.page` in + * a place `args` has. + */ +function missingArguments(tool: Tool, args: Args): string[] { + const missing = (tool.inputSchema.required ?? []).filter((key) => args[key] === undefined); + for (const location of ['path', 'query', 'headers'] as const) { + const required = tool.inputSchema.properties?.[location]?.['required']; + if (args[location] === undefined || !Array.isArray(required)) { + continue; + } + for (const name of required as readonly string[]) { + if (param(args, location, name) === undefined) { + missing.push(`${location}.${name}`); + } + } + } + return missing; +} + +/** + * Returns the HTTP request that calling `tool` with `args` makes, at + * `baseUrl`. Query parameters are serialized in OpenAPI's default `form` + * style. Values are not validated against the schema; the API does that. + * Throws {@link InvalidArguments}. + */ +export function buildRequest(tool: Tool, args: Args, baseUrl: string): HttpRequest { + checkArguments(tool, args); + const api = tool.request(args); + const url = new URL(withoutTrailingSlashes(baseUrl) + api.path); + for (const [name, value, explode] of api.query ?? []) { + appendQuery(url.searchParams, name, value, explode); + } + const headers = new Headers({ Accept: 'application/json' }); + for (const [name, value] of api.headers ?? []) { + if (value !== undefined && value !== null) { + headers.set(name, Array.isArray(value) ? value.map((item) => scalar(name, item)).join(',') : scalar(name, value)); + } + } + let body: string | undefined; + if (api.body && api.body.value !== undefined) { + // A content-type header argument names the media type, as with fetch. + const encoded = encodeBody( + api.body.value, + headers.get('Content-Type') ?? mediaTypeOf(api.body.value, api.body.mediaTypes), + ); + headers.set('Content-Type', encoded.contentType); + body = encoded.body; + } + return { + method: tool.method, + url: url.href, + headers: Object.fromEntries(headers), + ...(body !== undefined && { body }), + }; +} + +/** + * Encodes a body in `mediaType` as `fetch` would: JSON as JSON; an object as + * the parts of `multipart/form-data` (a {@link File} as a file part, another + * object as a JSON part, anything else as text), or the pairs of + * `application/x-www-form-urlencoded`; and any other string as it is. A + * string sent as `multipart/form-data` is a body already encoded, whose first + * line names its boundary unless the media type does. + */ +function encodeBody(value: unknown, mediaType: string): { body: string; contentType: string } { + const type = baseType(mediaType); + // A file as the whole body is its contents, as a Blob is to fetch. + if (isFile(value)) { + value = value.content; + } + if (isJsonType(type)) { + return { body: JSON.stringify(value), contentType: mediaType }; + } + if (typeof value === 'string') { + const boundary = + type === 'multipart/form-data' && !/boundary=/i.test(mediaType) && /^--([^\r\n]+)/.exec(value)?.[1]; + return { body: value, contentType: boundary ? `${mediaType}; boundary=${boundary}` : mediaType }; + } + const fields = + value !== null && typeof value === 'object' && !Array.isArray(value) ? Object.entries(value) : undefined; + if (fields && type === 'multipart/form-data') { + const boundary = `forge-${crypto.randomUUID()}`; + const parts = fields.flatMap(([name, field]) => + (Array.isArray(field) ? field : [field]).map((item) => [name, item] as const), + ); + const body = parts + .filter(([, item]) => item !== undefined && item !== null) + .map(([name, item]) => { + const quoted = (text: string) => `"${text.replace(/["\r\n]/g, encodeURIComponent)}"`; + let disposition = `form-data; name=${quoted(name)}`; + let type: string | undefined; + let content: string; + if (isFile(item)) { + if (item.filename !== undefined) { + disposition += `; filename=${quoted(item.filename)}`; + } + type = item.contentType; + content = item.content; + } else if (typeof item === 'object') { + type = 'application/json'; + content = JSON.stringify(item); + } else { + content = String(item); + } + const headers = `Content-Disposition: ${disposition}\r\n${type ? `Content-Type: ${type}\r\n` : ''}`; + return `--${boundary}\r\n${headers}\r\n${content}\r\n`; + }) + .join(''); + return { body: `${body}--${boundary}--\r\n`, contentType: `multipart/form-data; boundary=${boundary}` }; + } + if (fields && type === 'application/x-www-form-urlencoded') { + const pairs = fields + .filter(([, field]) => field !== undefined && field !== null) + .map(([name, field]) => [name, typeof field === 'object' ? JSON.stringify(field) : String(field)]); + return { body: new URLSearchParams(pairs).toString(), contentType: mediaType }; + } + return { body: JSON.stringify(value), contentType: mediaType }; +} + +/** + * The media type to send `value` in when no `content-type` argument names + * one, as `fetch` picks one from its body: a file's own type; for a string or + * file, the first of `mediaTypes` that is neither JSON nor a form; for any + * other value, the first JSON one, else `multipart/form-data` or + * `application/x-www-form-urlencoded` for an object; else the first. + */ +function mediaTypeOf(value: unknown, mediaTypes: readonly string[]): string { + const first = mediaTypes[0] ?? 'application/json'; + if (isFile(value) && value.contentType !== undefined) { + return value.contentType; + } + const form = (type: string) => ['multipart/form-data', 'application/x-www-form-urlencoded'].includes(baseType(type)); + if (typeof value === 'string' || isFile(value)) { + return mediaTypes.find((type) => !isJsonType(baseType(type)) && !form(type)) ?? first; + } + const object = value !== null && typeof value === 'object' && !Array.isArray(value); + return ( + mediaTypes.find((type) => isJsonType(baseType(type))) ?? + (object + ? (mediaTypes.find((type) => baseType(type) === 'multipart/form-data') ?? mediaTypes.find(form)) + : undefined) ?? + first + ); +} + +/** The media type without parameters, in lower case. */ +function baseType(mediaType: string): string { + return mediaType.split(';', 1)[0]!.trim().toLowerCase(); +} + +/** Whether `type`, without parameters, is JSON: `application/json` or a `+json` type. */ +function isJsonType(type: string): boolean { + return type === 'application/json' || type.endsWith('+json'); +} + +/** A file in a `multipart/form-data` body, as a `format: binary` argument takes one. */ +interface File { + readonly content: string; + readonly filename?: string; + readonly contentType?: string; +} + +/** Whether `value` is a {@link File}: `content`, and maybe `filename` and `contentType`, all strings. */ +function isFile(value: unknown): value is File { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const entries = Object.entries(value); + return ( + typeof (value as { content?: unknown }).content === 'string' && + entries.every(([key, item]) => ['content', 'filename', 'contentType'].includes(key) && typeof item === 'string') + ); +} + +/** + * Returns `url` without the `/`s it ends with. A loop rather than a regular + * expression, which would take quadratic time on many `/`s. + */ +function withoutTrailingSlashes(url: string): string { + let end = url.length; + while (end > 0 && url[end - 1] === '/') { + end--; + } + return url.slice(0, end); +} + +/** Appends a query parameter in the `form` style: `a=1&a=2` exploded, `a=1,2` not. */ +function appendQuery(query: URLSearchParams, name: string, value: unknown, explode: boolean): void { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + const items = value.map((item) => scalar(name, item)); + if (explode) { + for (const item of items) { + query.append(name, item); + } + } else { + query.append(name, items.join(',')); + } + return; + } + if (typeof value === 'object') { + const entries = Object.entries(value).filter(([, item]) => item !== undefined && item !== null); + if (explode) { + for (const [key, item] of entries) { + query.append(key, scalar(name, item)); + } + } else { + query.append(name, entries.flatMap(([key, item]) => [key, scalar(name, item)]).join(',')); + } + return; + } + query.append(name, scalar(name, value)); +} + +function scalar(name: string, 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(`${name} must be a string, number or boolean`); +} + +/** How {@link listTools} lists the tools. */ +export interface ListOptions { + /** Send each tool's output schema. Defaults to `false`. */ + readonly outputSchema?: boolean; +} + +const listings = new WeakMap(); +const listingsWithOutput = new WeakMap(); +const indexes = new WeakMap>(); + +/** + * Returns `tools` as `tools/list` sends them, without their request code, + * method, path, extensions and response schemas, and without their output + * schemas unless `options.outputSchema`. Each listing is built once for each + * array of tools, and the same array every time after. + */ +export function listTools(tools: readonly Tool[], { outputSchema = false }: ListOptions = {}): readonly McpTool[] { + const cache = outputSchema ? listingsWithOutput : listings; + let listing = cache.get(tools); + if (!listing) { + listing = tools.map( + ({ + request: _request, + method: _method, + path: _path, + extensions: _extensions, + responses: _responses, + outputSchema: output, + ...tool + }) => (outputSchema && output ? { ...tool, outputSchema: output } : tool) as McpTool, + ); + cache.set(tools, listing); + } + return listing; +} + +/** Returns `tools` by name. It is built once for each array of tools. */ +export function toolsByName(tools: readonly Tool[]): ReadonlyMap { + let index = indexes.get(tools); + if (!index) { + index = new Map(tools.map((tool) => [tool.name, tool])); + indexes.set(tools, index); + } + return index; +} diff --git a/src/bin/forge/mcp/lib/test/fixtures.ts b/src/bin/forge/mcp/lib/test/fixtures.ts new file mode 100644 index 000000000..d11e80539 --- /dev/null +++ b/src/bin/forge/mcp/lib/test/fixtures.ts @@ -0,0 +1,61 @@ +import { param, pathParam, type Tool } from '../src/tool.js'; + +/** Tools as the generator writes them. */ +export const tools: readonly Tool[] = [ + { + name: 'pets_list', + title: 'List pets', + description: 'Lists the pets of a store.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'object', properties: { store_id: { type: 'string' } }, required: ['store_id'] }, + query: { type: 'object', properties: { tag: { type: 'array' }, sort: { type: 'object' } } }, + headers: { type: 'object', properties: { 'x-trace': { type: 'string' } } }, + }, + required: ['path'], + }, + outputSchema: { + type: 'array', + items: { $ref: '#/$defs/Pet' }, + $defs: { Pet: { type: 'object', properties: { id: { type: 'string' } } } }, + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + method: 'GET', + path: '/stores/{store_id}/pets', + extensions: { 'x-api-token-group': ['Pets Read'] }, + responses: { + schemas: { '200': { type: 'array', items: { $ref: '#/$defs/Pet' } }, '4XX': null }, + $defs: { Pet: { type: 'object', properties: { id: { type: 'string' } } } }, + }, + request: (args) => ({ + path: `/stores/${pathParam(args, 'store_id')}/pets`, + query: [ + ['tag', param(args, 'query', 'tag'), true], + ['sort', param(args, 'query', 'sort'), false], + ], + headers: [['x-trace', param(args, 'headers', 'x-trace')]], + }), + }, + { + name: 'pets_create', + description: 'Adds a pet.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'object', properties: { store_id: { type: 'string' } }, required: ['store_id'] }, + query: { type: 'object', properties: { dry_run: { type: 'boolean' } }, required: ['dry_run'] }, + body: {}, + }, + required: ['path', 'query', 'body'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + method: 'POST', + path: '/stores/{store_id}/pets', + request: (args) => ({ + path: `/stores/${pathParam(args, 'store_id')}/pets`, + query: [['dry_run', param(args, 'query', 'dry_run'), true]], + body: { value: args['body'], mediaTypes: ['application/json'] }, + }), + }, +]; diff --git a/src/bin/forge/mcp/lib/test/register.ts b/src/bin/forge/mcp/lib/test/register.ts new file mode 100644 index 000000000..e9543429b --- /dev/null +++ b/src/bin/forge/mcp/lib/test/register.ts @@ -0,0 +1,21 @@ +import { registerHooks } from 'node:module'; + +// The sources import each other as `./name.js`, the way TypeScript emits +// them. Node runs the `.ts` files as they are, so a relative `.js` import +// that does not exist resolves to the `.ts` file next to it. +registerHooks({ + resolve(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context); + } catch (error) { + if ( + (specifier.startsWith('./') || specifier.startsWith('../')) && + specifier.endsWith('.js') && + (error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND' + ) { + return nextResolve(`${specifier.slice(0, -3)}.ts`, context); + } + throw error; + } + }, +}); diff --git a/src/bin/forge/mcp/lib/test/tool.test.ts b/src/bin/forge/mcp/lib/test/tool.test.ts new file mode 100644 index 000000000..3b8dfac4d --- /dev/null +++ b/src/bin/forge/mcp/lib/test/tool.test.ts @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { buildRequest, InvalidArguments, listTools, param, toolsByName } from '../src/tool.js'; +import { tools } from './fixtures.js'; + +const [list, create] = tools as [(typeof tools)[0], (typeof tools)[0]]; + +test('builds the path, query and headers', () => { + const request = buildRequest( + list, + { + path: { store_id: 'a b/c' }, + query: { tag: ['x', 'y'], sort: { by: 'name', dir: 'asc' } }, + headers: { 'x-trace': 7 }, + }, + 'https://api.example.com/v1/', + ); + assert.equal(request.method, 'GET'); + assert.equal(request.url, 'https://api.example.com/v1/stores/a%20b%2Fc/pets?tag=x&tag=y&sort=by%2Cname%2Cdir%2Casc'); + assert.deepEqual(request.headers, { accept: 'application/json', 'x-trace': '7' }); + assert.equal('body' in request, false); +}); + +test('joins a base URL that ends with slashes', () => { + const request = buildRequest(list, { path: { store_id: 's' } }, 'https://api.example.com/v1///'); + assert.equal(request.url, 'https://api.example.com/v1/stores/s/pets'); +}); + +test('leaves out missing optional arguments', () => { + const request = buildRequest(list, { path: { store_id: 's' }, query: { tag: null } }, 'https://api.example.com'); + assert.equal(request.url, 'https://api.example.com/stores/s/pets'); + assert.equal('x-trace' in request.headers, false); +}); + +test('sends the body as JSON', () => { + const request = buildRequest( + create, + { path: { store_id: 's' }, query: { dry_run: false }, body: { name: 'Rex' } }, + 'https://api.example.com', + ); + assert.equal(request.method, 'POST'); + assert.equal(request.url, 'https://api.example.com/stores/s/pets?dry_run=false'); + assert.equal(request.headers['content-type'], 'application/json'); + assert.equal(request.body, '{"name":"Rex"}'); +}); + +test('is plain data that fetch sends as it is', async () => { + const request = buildRequest( + create, + { path: { store_id: 's' }, query: { dry_run: true }, body: { name: 'Rex' } }, + 'https://api.example.com', + ); + assert.deepEqual(structuredClone(request), request); + const sent = new Request(request.url, request); + assert.equal(sent.method, 'POST'); + assert.equal(sent.headers.get('Content-Type'), 'application/json'); + assert.deepEqual(await sent.json(), { name: 'Rex' }); +}); + +test('refuses missing required arguments and values that are not scalars', () => { + assert.throws(() => buildRequest(create, { path: { store_id: 's' }, query: {} }, 'https://x'), { + name: 'InvalidArguments', + message: 'Missing required arguments: body, query.dry_run', + }); + assert.throws(() => buildRequest(create, {}, 'https://x'), { + message: 'Missing required arguments: path, query, body', + }); + assert.throws(() => buildRequest(list, { path: { store_id: '' } }, 'https://x'), InvalidArguments); + assert.throws(() => buildRequest(list, { path: { store_id: 's' }, query: { tag: [{}] } }, 'https://x'), { + message: 'tag must be a string, number or boolean', + }); +}); + +test('refuses an argument the tool does not take, saying where a parameter goes', () => { + assert.throws(() => buildRequest(list, { store_id: 's', tag: ['x'], verbose: true }, 'https://x'), { + name: 'InvalidArguments', + message: + 'Unknown arguments for pets_list: store_id goes in path: { "path": { "store_id": … } }; ' + + 'tag goes in query: { "query": { "tag": … } }; verbose is not an argument. It takes path, query, headers.', + }); +}); + +test('refuses a misspelled parameter inside a place, listing what it takes', () => { + assert.throws(() => buildRequest(list, { path: { store_id: 's' }, query: { tagz: ['x'] } }, 'https://x'), { + name: 'InvalidArguments', + message: + 'Unknown arguments for pets_list: query.tagz is not an argument; query takes tag, sort. It takes path, query, headers.', + }); +}); + +test('refuses a place that is not an object', () => { + assert.throws(() => buildRequest(list, { path: 's' }, 'https://x'), { + name: 'InvalidArguments', + message: 'path must be an object', + }); + assert.throws(() => buildRequest(list, { path: { store_id: 's' }, query: ['tag'] }, 'https://x'), { + message: 'query must be an object', + }); +}); + +test('lists the tools without their request code, once', () => { + const listing = listTools(tools); + assert.equal(listing, listTools(tools)); + assert.deepEqual(Object.keys(listing[0]!).toSorted(), ['annotations', 'description', 'inputSchema', 'name', 'title']); + assert.equal(toolsByName(tools).get('pets_create'), create); +}); + +test('lists the output schemas only when asked, once', () => { + const listing = listTools(tools, { outputSchema: true }); + assert.equal(listing, listTools(tools, { outputSchema: true })); + assert.notEqual(listing, listTools(tools)); + assert.deepEqual(listing[0]!.outputSchema, list.outputSchema); + assert.equal('outputSchema' in listing[1]!, false, 'a tool without one'); + assert.equal('outputSchema' in listTools(tools)[0]!, false); +}); + +/** A tool whose body is in the media type its `content-type` argument names, as the generator writes one. */ +const upload: (typeof tools)[0] = { + name: 'files_put', + description: 'Writes a file.', + inputSchema: { + type: 'object', + properties: { + headers: { type: 'object', properties: { 'content-type': { type: 'string' } } }, + body: {}, + }, + required: ['body'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + method: 'PUT', + path: '/files', + request: (args) => ({ + path: '/files', + headers: [['content-type', param(args, 'headers', 'content-type')]], + body: { + value: args['body'], + mediaTypes: ['application/octet-stream', 'multipart/form-data', 'text/csv', 'application/x-www-form-urlencoded'], + }, + }), +}; + +test('sends a string body as it is, in the media type the content-type argument names', () => { + assert.deepEqual(buildRequest(upload, { body: 'hello' }, 'https://x').body, 'hello'); + assert.equal( + buildRequest(upload, { body: 'hello' }, 'https://x').headers['content-type'], + 'application/octet-stream', + ); + const csv = buildRequest(upload, { headers: { 'content-type': 'text/csv' }, body: 'a,b' }, 'https://x'); + assert.equal(csv.headers['content-type'], 'text/csv'); + assert.equal(csv.body, 'a,b'); +}); + +test('sends an object as multipart/form-data, as fetch sends FormData', async () => { + const request = buildRequest( + upload, + { + headers: { 'content-type': 'multipart/form-data' }, + body: { value: 'hi', metadata: { a: 1 }, tags: ['x', 'y'], skip: null }, + }, + 'https://x', + ); + assert.match(request.headers['content-type']!, /^multipart\/form-data; boundary=forge-/); + const form = await new Response(request.body, { headers: request.headers }).formData(); + assert.equal(form.get('value'), 'hi'); + assert.equal(form.get('metadata'), '{"a":1}'); + assert.deepEqual(form.getAll('tags'), ['x', 'y']); + assert.equal(form.has('skip'), false); +}); + +test('sends a file part with its name and media type, as FormData.append(name, blob, filename) does', async () => { + const request = buildRequest( + upload, + { + headers: { 'content-type': 'multipart/form-data' }, + body: { + files: [{ content: 'export default {}', filename: 'index.js', contentType: 'application/javascript+module' }], + }, + }, + 'https://x', + ); + const file = (await new Response(request.body, { headers: request.headers }).formData()).get('files'); + assert.ok(file instanceof Blob); + assert.equal((file as File).name, 'index.js'); + assert.equal(file.type, 'application/javascript+module'); + assert.equal(await file.text(), 'export default {}'); +}); + +test('takes a header argument in any case, and its content-type over the default', () => { + const request = buildRequest(upload, { headers: { 'Content-Type': 'text/plain' }, body: 'hi' }, 'https://x'); + assert.equal(request.headers['content-type'], 'text/plain'); +}); + +test('without a content-type, sends a string as text and an object as form data, as fetch picks one', async () => { + const text = buildRequest(upload, { body: 'hello' }, 'https://x'); + assert.equal(text.headers['content-type'], 'application/octet-stream'); + const form = buildRequest(upload, { body: { value: 'hi' } }, 'https://x'); + assert.match(form.headers['content-type']!, /^multipart\/form-data; boundary=/); + assert.equal((await new Response(form.body, { headers: form.headers }).formData()).get('value'), 'hi'); + const json = buildRequest( + create, + { path: { store_id: 's' }, query: { dry_run: true }, body: { name: 'Rex' } }, + 'https://x', + ); + assert.equal(json.headers['content-type'], 'application/json'); +}); + +test('sends a file as the whole body as its contents, in its own media type', () => { + const request = buildRequest(upload, { body: { content: 'a,b', contentType: 'text/csv' } }, 'https://x'); + assert.equal(request.headers['content-type'], 'text/csv'); + assert.equal(request.body, 'a,b'); +}); + +test('sends a multipart body already encoded as it is, with the boundary from its first line', () => { + const body = '--b1\r\nContent-Disposition: form-data; name="value"\r\n\r\nhi\r\n--b1--\r\n'; + const request = buildRequest(upload, { headers: { 'content-type': 'multipart/form-data' }, body }, 'https://x'); + assert.equal(request.headers['content-type'], 'multipart/form-data; boundary=b1'); + assert.equal(request.body, body); +}); + +test('sends an object as form pairs for application/x-www-form-urlencoded', () => { + const request = buildRequest( + upload, + { headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: { a: 'b c', n: 1, o: { x: 1 } } }, + 'https://x', + ); + assert.equal(request.body, 'a=b+c&n=1&o=%7B%22x%22%3A1%7D'); +}); diff --git a/src/bin/forge/mcp/lib/tsconfig.json b/src/bin/forge/mcp/lib/tsconfig.json new file mode 100644 index 000000000..329a1730c --- /dev/null +++ b/src/bin/forge/mcp/lib/tsconfig.json @@ -0,0 +1,27 @@ +{ + // Checks the runtime against the web platform alone, as the generated + // servers run in Workers, Node and anywhere else with fetch. + "compilerOptions": { + "target": "esnext", + "lib": ["esnext", "dom"], + "types": [], + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/src/bin/forge/mcp/lib/tsconfig.test.json b/src/bin/forge/mcp/lib/tsconfig.test.json new file mode 100644 index 000000000..166106a44 --- /dev/null +++ b/src/bin/forge/mcp/lib/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + // Checks the tests, which run on Node, with the sources they import. + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/src/bin/forge/mcp/schema.rs b/src/bin/forge/mcp/schema.rs new file mode 100644 index 000000000..1f75f3d94 --- /dev/null +++ b/src/bin/forge/mcp/schema.rs @@ -0,0 +1,327 @@ +//! JSON Schemas (2020-12) of the arguments and responses of tools. +//! +//! A model is a `$ref` to `#/$defs/`, so recursive models terminate. +//! Each model's schema is a constant in its group's files, which every tool +//! that uses it shares, and a tool's `$defs` has only the models its +//! schemas reach, so each tool's schema stands alone. A model's key depends +//! only on the model, so each group's files are generated on their own. +//! +//! Arguments and responses are built separately, since a model differs +//! between them: arguments describe what a client sends, so read-only fields +//! are left out; responses describe what the API returns, so they keep them. +//! Response schemas carry no descriptions or defaults, since they are for +//! checking responses rather than showing to a model. + +use std::collections::{BTreeMap, HashSet}; +use std::fmt::Write as _; + +use forge::{EnumValue, Field, Json, Model, Object, Scalar, Type}; +use serde_json::{Map, Value, json}; + +/// The models a schema refers to. +pub(super) type Refs<'f> = HashSet>; + +/// Whether schemas describe what a client sends or what the API returns. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Direction { + /// A tool's arguments. + Input, + /// A method's response bodies. + Output, +} + +/// Builds the schemas of one direction. +#[derive(Clone, Copy)] +pub(super) struct Schemas { + direction: Direction, +} + +impl Schemas { + /// Builds the schemas of arguments. + pub(super) const INPUT: Schemas = Schemas { + direction: Direction::Input, + }; + /// Builds the schemas of response bodies. + pub(super) const OUTPUT: Schemas = Schemas { + direction: Direction::Output, + }; + + /// Returns the schema of `t`, adding the models it refers to to `refs`. + pub(super) fn ty<'f>(self, t: Type<'f>, refs: &mut Refs<'f>) -> Value { + match t { + Type::Scalar(Scalar::Binary) if self.direction == Direction::Input => file_schema(), + Type::Scalar(scalar) => scalar_schema(scalar), + Type::Enum(e) => { + let values: Vec = e + .values() + .map(|v| match v { + EnumValue::String(s) => json!(s), + EnumValue::Number(n) => number(n), + EnumValue::Boolean(b) => json!(b), + }) + .collect(); + let kinds: HashSet<&str> = values.iter().map(kind).collect(); + match kinds.into_iter().collect::>()[..] { + [only] => json!({ "type": only, "enum": values }), + _ => json!({ "enum": values }), + } + } + Type::Array(array) => { + let mut schema = Map::new(); + schema.insert("type".to_owned(), json!("array")); + schema.insert("items".to_owned(), self.ty(array.elem(), refs)); + if let Some(min) = array.min_items() { + schema.insert("minItems".to_owned(), json!(min)); + } + if let Some(max) = array.max_items() { + schema.insert("maxItems".to_owned(), json!(max)); + } + Value::Object(schema) + } + Type::Object(object) => self.object(object, refs), + Type::Union(union) => { + let members: Vec = union.members().map(|m| self.ty(m, refs)).collect(); + let keyword = if union.is_exclusive() { "oneOf" } else { "anyOf" }; + json!({ keyword: members }) + } + Type::Intersection(intersection) => { + let members: Vec = intersection.members().map(|m| self.ty(m, refs)).collect(); + json!({ "allOf": members }) + } + Type::Model(model) if is_scalar(model.ty()) => self.model(model), + Type::Model(model) => { + refs.insert(model); + json!({ "$ref": format!("#/$defs/{}", key(model)) }) + } + } + } + + /// Returns the schema of `field`: the schema of its type, with its + /// description, default and deprecation for arguments. + pub(super) fn field<'f>(self, field: Field<'f>, refs: &mut Refs<'f>) -> Value { + let mut schema = self.ty(field.ty(), refs); + if self.direction == Direction::Input + && let Value::Object(map) = &mut schema + { + let description = field.description().trim(); + if !description.is_empty() { + map.insert("description".to_owned(), json!(description)); + } + if let Some(default) = field.default() { + map.insert("default".to_owned(), value(default)); + } + if field.is_deprecated() { + map.insert("deprecated".to_owned(), json!(true)); + } + } + schema + } + + /// Returns the schema of `model` itself, which its constant holds. The + /// models it refers to are `$ref`s. + pub(super) fn model(self, model: Model<'_>) -> Value { + let mut schema = self.ty(model.ty(), &mut Refs::new()); + if self.direction == Direction::Input + && let Value::Object(map) = &mut schema + { + let description = model.description().trim(); + if !description.is_empty() && !map.contains_key("description") { + map.insert("description".to_owned(), json!(description)); + } + if model.is_deprecated() { + map.insert("deprecated".to_owned(), json!(true)); + } + } + schema + } + + /// Returns the `$defs` of a schema that refers to `refs`: each model they + /// refer to, directly or through other models, by key. + pub(super) fn defs<'f>(self, refs: &Refs<'f>) -> BTreeMap> { + let mut reached = Refs::new(); + for &model in refs { + reach(Type::Model(model), self.direction, &mut reached); + } + reached.into_iter().map(|model| (key(model), model)).collect() + } + + /// Returns the schema of `object`. + fn object<'f>(self, object: Object<'f>, refs: &mut Refs<'f>) -> Value { + let mut properties = Map::new(); + let mut required = Vec::new(); + for field in object.fields().filter(|&f| kept(f, self.direction)) { + properties.insert(field.wire_name().to_owned(), self.field(field, refs)); + if field.is_required() { + required.push(field.wire_name()); + } + } + let mut schema = Map::new(); + schema.insert("type".to_owned(), json!("object")); + if !properties.is_empty() { + schema.insert("properties".to_owned(), Value::Object(properties)); + } + if !required.is_empty() { + schema.insert("required".to_owned(), json!(required)); + } + if let Some(extra) = object.extra() + && !matches!(extra, Type::Scalar(Scalar::Unknown)) + { + schema.insert("additionalProperties".to_owned(), self.ty(extra, refs)); + } + Value::Object(schema) + } +} + +/// Reports whether a schema of `direction` has `field`: arguments leave out +/// read-only fields, since a client doesn't send them. +fn kept(field: Field<'_>, direction: Direction) -> bool { + direction == Direction::Output || !field.is_read_only() +} + +/// Adds to `out` each model that a schema of `t` in `direction` refers to, +/// directly or through other models, without building any schema. +pub(super) fn reach<'f>(t: Type<'f>, direction: Direction, out: &mut Refs<'f>) { + match t { + Type::Scalar(_) | Type::Enum(_) => {} + Type::Array(array) => reach(array.elem(), direction, out), + Type::Object(object) => { + for field in object.fields().filter(|&f| kept(f, direction)) { + reach(field.ty(), direction, out); + } + if let Some(extra) = object.extra() { + reach(extra, direction, out); + } + } + Type::Union(union) => union.members().for_each(|m| reach(m, direction, out)), + Type::Intersection(intersection) => intersection.members().for_each(|m| reach(m, direction, out)), + Type::Model(model) if is_scalar(model.ty()) => {} + Type::Model(model) => { + if out.insert(model) { + reach(model.ty(), direction, out); + } + } + } +} + +/// Reports whether `t` is a scalar or an enum, or a union or intersection of +/// them or of models that are, such as a nullable string or `allOf` an +/// identifier and `string`. A model of one is written inline where it is used +/// rather than as a `$ref`: its schema is about as small as the reference, and +/// it saves an entry in the `$defs` of every tool that uses it. +pub(super) fn is_scalar(t: Type<'_>) -> bool { + scalar_within(t, 8) +} + +/// [`is_scalar`], looking through at most `depth` models, so a model that +/// refers to itself ends the search. +fn scalar_within(t: Type<'_>, depth: u8) -> bool { + match t { + Type::Scalar(_) | Type::Enum(_) => true, + Type::Union(union) => union.members().all(|m| scalar_within(m, depth)), + Type::Intersection(intersection) => intersection.members().all(|m| scalar_within(m, depth)), + Type::Model(model) => depth > 0 && scalar_within(model.ty(), depth - 1), + Type::Array(_) | Type::Object(_) => false, + } +} + +/// Returns the key of `model` in `$defs`: its schema name, unique in the +/// document, with each byte other than an ASCII letter, digit, `_` or `-` +/// written as `.` and two hex digits, so the key needs no escaping in a +/// JSON Pointer or a URI fragment, and no two models share one. +pub(super) fn key(model: Model<'_>) -> String { + let name = model.schema_name(); + let mut key = String::with_capacity(name.len()); + for b in name.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-') { + key.push(char::from(b)); + } else { + let _ = write!(key, ".{b:02X}"); + } + } + key +} + +/// Returns the JSON Schema type of `value`, a value of an enum. +fn kind(value: &Value) -> &'static str { + match value { + Value::String(_) => "string", + Value::Bool(_) => "boolean", + Value::Number(n) if n.is_i64() || n.is_u64() => "integer", + _ => "number", + } +} + +/// Returns the schema of a `format: binary` argument, such as a file in a +/// `multipart/form-data` body: its contents as text, or as a file with a name +/// and a media type, as `FormData.append(name, blob, filename)` sends one. +/// Arguments are JSON, so the contents are text. +fn file_schema() -> Value { + json!({ + "anyOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "content": { "type": "string" }, + "filename": { "type": "string" }, + "contentType": { "type": "string" } + }, + "required": ["content"], + "additionalProperties": false + } + ] + }) +} + +/// Returns the schema of `scalar`. +fn scalar_schema(scalar: Scalar) -> Value { + match scalar { + Scalar::Unknown => json!({}), + Scalar::Null => json!({ "type": "null" }), + Scalar::Boolean => json!({ "type": "boolean" }), + Scalar::String => json!({ "type": "string" }), + Scalar::Uuid => json!({ "type": "string", "format": "uuid" }), + Scalar::Number(n) if n.is_integer() => json!({ "type": "integer" }), + Scalar::Number(_) => json!({ "type": "number" }), + Scalar::DateTime => json!({ "type": "string", "format": "date-time" }), + Scalar::Date => json!({ "type": "string", "format": "date" }), + Scalar::Bytes => json!({ "type": "string", "contentEncoding": "base64" }), + Scalar::Binary => json!({ "type": "string", "format": "binary" }), + } +} + +/// Returns `json` as a JSON value. +pub(super) fn value(json: &Json) -> Value { + match json { + Json::Null => Value::Null, + Json::Bool(b) => json!(b), + Json::Number(n) => number(*n), + Json::String(s) => json!(s), + Json::Array(values) => Value::Array(values.iter().map(value).collect()), + Json::Object(entries) => Value::Object(entries.iter().map(|(k, v)| (k.clone(), value(v))).collect()), + } +} + +/// Returns `n` as a JSON number, without a fraction when it is an integer: +/// `1` rather than `1.0`. +fn number(n: f64) -> Value { + if n.fract() == 0.0 && n.abs() < 2f64.powi(53) { + #[expect(clippy::cast_possible_truncation, reason = "n is an integer below 2^53")] + return json!(n as i64); + } + serde_json::Number::from_f64(n).map_or(Value::Null, Value::Number) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::number; + + #[test] + fn numbers() { + assert_eq!(number(1.0), json!(1)); + assert_eq!(number(-3.0), json!(-3)); + assert_eq!(number(1.5), json!(1.5)); + } +}