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..4e541d47a --- /dev/null +++ b/rfcs/mcp.md @@ -0,0 +1,203 @@ +# 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 + /tools.ts the tools of a group's methods + /schemas.ts the schema of each model used in arguments, placed in its group + /outputs.ts the schema of each model used in responses +``` + +Each `tools.ts` exports `tools`, an array of the group's tools sorted by name, and `index.ts` concatenates them. A tool is plain data plus one function: + +```ts +{ + name: 'dns_records_create', + title: 'Create DNS Record', + description: '…', + inputSchema: { type: 'object', properties: { … }, required: [ … ], $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'], mediaType: '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 required request body with no JSON media type (`multipart/form-data`, `application/octet-stream`, `text/plain`, …) | A tool's arguments are JSON. An optional non-JSON body is left off the tool, and the method still gets one. | +| A tool name over 128 characters | MCP's limit. | +| A tool name another method already has, such as group `a.b` method `c` and group `a` method `b_c`, both `a_b_c` | Names must be unique across the server; the first method keeps it. | +| Two arguments with the same name, even after [prefixing](#arguments) | The tool can't take both. | + +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 snake_case, joined by `_`: + +| Group | Method | Tool | +| ------------------ | -------- | ----------------------- | +| `dns.records` | `create` | `dns_records_create` | +| `accounts.members` | `list` | `accounts_members_list` | + +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. Its properties are: + +- **Path parameters**, named after the parameter in snake_case, always required. +- **Query parameters**, likewise, required if the spec says so. +- **Header parameters**, likewise. Read-only headers are left out. +- **The JSON body**, as one argument named `body` that takes the body as the API does, with the body's description. If a parameter is already called `body`, it is `request_body`. It is required if the body is. + +A parameter whose snake_case name is empty takes its wire name. When two parameters in different places would share a name, the later one gets its place in front: `query_name`, `header_name`. + +## The request + +`request(args)` returns the request relative to the API's base URL: + +- `path`: the template with each parameter substituted. `pathParam` percent-encodes it and throws `InvalidArguments` if it is missing or empty. +- `query`: each parameter's wire name, value and whether it is exploded. +- `headers`: each header's wire name and value. +- `body`: the value and its media type. + +`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 the required arguments are present, 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 as JSON with its media type. + +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" }`. + +Models are `$ref`s 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 its group's `schemas.ts` or `outputs.ts`, and every tool that uses it refers to the constant. The `$defs` key is the model's schema name with characters that a JSON Pointer or URI fragment would need escaped replaced by `_`, and a number added if two models would share one. + +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, or `METHOD /path`, 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 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. + +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..548568a10 --- /dev/null +++ b/src/bin/forge/mcp.rs @@ -0,0 +1,1072 @@ +//! 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. The root gets an `index.ts` with every +//! tool and the runtime, a `README.md` that lists the methods without a tool +//! and why, 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. +//! +//! A tool is named after its groups and method, in snake_case and joined by +//! `_`, such as `dns_records_create`. Its path, query and header parameters +//! are arguments named after the parameter in snake_case, and a JSON body is +//! the argument `body`, as the API takes it. + +use std::borrow::Cow; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt::Write as _; + +use forge::ts::{self, Ident, Module, PREAMBLE, p}; +use forge::{Availability, Err, Field, Forge, Group, HttpMethod, Location, Method, Model, Plugin, Type}; +use serde_json::{Map, Value, json}; + +mod schema; + +use schema::{Refs, Schemas}; + +/// Generates an MCP server. +pub struct Generator; + +impl Plugin for Generator { + type File = File; + + /// Returns every file. Tool names must be unique across groups, so the + /// files of the groups are generated here too, rather than one group at + /// a time. + fn generate(&self, forge: &Forge) -> Result, Err> { + let mut schemas = Schemas::default(); + let mut outputs = Schemas::output(); + let mut names = HashSet::new(); + let mut skipped = Vec::new(); + let mut groups = Vec::new(); + for group in forge.groups() { + let mut tools = Vec::new(); + for m in group.methods() { + if m.is_deprecated() { + continue; + } + match Tool::new(m, &mut schemas, &mut outputs) { + Ok(tool) if names.insert(tool.name.clone()) => tools.push(tool), + Ok(tool) => skipped.push((m, format!("another method has the tool name {}", tool.name))), + Err(reason) => skipped.push((m, reason)), + } + } + if !tools.is_empty() { + tools.sort_by(|a, b| a.name.cmp(&b.name)); + groups.push((group, tools)); + } + } + + let (input_files, idents) = schema_files(&schemas, SchemaFile::Input); + let (output_files, output_idents) = schema_files(&outputs, SchemaFile::Output); + let mut files: Vec = groups + .iter() + .map(|(group, tools)| File::Ts(tools_file(*group, tools, &idents, &output_idents))) + .collect(); + files.extend(input_files.into_iter().map(File::Ts)); + files.extend(output_files.into_iter().map(File::Ts)); + files.push(File::Ts(index(forge, &groups))); + files.push(File::Text { + path: "README.md".to_owned(), + content: readme(&groups, &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()) + } +} + +/// 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; + +/// 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: BTreeMap>, + /// The path template, split into text and the keys of the arguments + /// that fill its parameters. + path: Vec, + /// Each query parameter's wire name, argument key, and whether it is + /// exploded. + query: Vec<(String, String, bool)>, + /// Each header parameter's wire name and argument key. + headers: Vec<(String, String)>, + /// The argument key and media type of the JSON body. + body: Option<(String, String)>, + /// 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: 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: BTreeMap>, +} + +/// A part of a path template. +enum Segment { + Text(String), + /// A parameter, filled by the argument with this key. + Param(String), +} + +impl<'f> Tool<'f> { + /// Returns the tool of `m`, or why it has none. + fn new(m: Method<'f>, schemas: &mut Schemas<'f>, outputs: &mut Schemas<'f>) -> Result, String> { + let name = tool_name(m); + if name.len() > MAX_NAME_LENGTH { + return Err(format!("the tool name is over {MAX_NAME_LENGTH} characters")); + } + let mut refs = Refs::new(); + let mut properties = Map::new(); + let mut required = Vec::new(); + let mut keys = Keys::default(); + let mut path_keys = Vec::new(); + let mut query = Vec::new(); + let mut headers = Vec::new(); + + for field in parameters(m.request()) { + let Some(location) = m.location(field) else { + continue; + }; + let key = match location { + Location::Path => keys.add(field, "path")?, + Location::Query { .. } => keys.add(field, "query")?, + }; + properties.insert(key.clone(), schemas.field(field, &mut refs)); + if field.is_required() || location == Location::Path { + required.push(key.clone()); + } + match location { + Location::Path => path_keys.push((field.wire_name().to_owned(), key)), + Location::Query { exploded } => query.push((field.wire_name().to_owned(), key, exploded)), + } + } + for field in m + .headers() + .into_iter() + .flat_map(|h| h.fields()) + .filter(|f| !f.is_read_only()) + { + let key = keys.add(field, "header")?; + properties.insert(key.clone(), schemas.field(field, &mut refs)); + if field.is_required() { + required.push(key.clone()); + } + headers.push((field.wire_name().to_owned(), key)); + } + + let mut body = None; + if let Some(b) = m.body() { + if let Some(content) = b.json() { + let key = if keys.taken.contains("body") { + "request_body" + } else { + "body" + }; + if !keys.taken.insert(key.to_owned()) { + return Err(format!("two arguments are both named {key}")); + } + let mut schema = schemas.ty(content.ty(), &mut refs); + if let Value::Object(map) = &mut schema + && !b.description().is_empty() + { + map.insert("description".to_owned(), json!(b.description())); + } + properties.insert(key.to_owned(), schema); + if b.is_required() { + required.push(key.to_owned()); + } + body = Some((key.to_owned(), content.media_type().to_owned())); + } else if b.is_required() { + let types: Vec<&str> = b.contents().map(|c| c.media_type()).collect(); + return Err(format!("the request body is not JSON ({})", types.join(", "))); + } + } + + let path = segments(m.path(), &path_keys)?; + 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| outputs.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() { + outputs.defs(&success_refs) + } else { + BTreeMap::new() + }; + + Ok(Tool { + method: m, + name, + properties: Value::Object(properties), + required, + defs: schemas.defs(&refs), + path, + query, + headers, + body, + responses, + response_defs: outputs.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 })), + } +} + +/// The keys of a tool's arguments. +#[derive(Default)] +struct Keys { + taken: HashSet, +} + +impl Keys { + /// Returns the key of `field`, a parameter in `location`: its name in + /// snake_case, or its wire name if that has no words. If another + /// argument has it, the location goes in front, as in `query_name`. + fn add(&mut self, field: Field<'_>, location: &str) -> Result { + let snake = field.name().snake(); + let mut key = if snake.is_empty() { + field.wire_name().to_owned() + } else { + snake + }; + if self.taken.contains(&key) { + key = format!("{location}_{key}"); + } + if !self.taken.insert(key.clone()) { + return Err(format!("two arguments are both named {key}")); + } + Ok(key) + } +} + +/// 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 keys of the arguments that +/// fill its parameters, given each path parameter's wire name and key. +fn segments(template: &str, keys: &[(String, String)]) -> 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]; + let Some((_, key)) = keys.iter().find(|(name, _)| name == wire) else { + 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(key.clone())); + 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 snake_case, joined by `_`, such as `accounts_members_list`. +fn tool_name(m: Method<'_>) -> String { + let mut words: Vec = m.group().path().map(|name| name.snake()).collect(); + words.push(m.name().snake()); + words.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 mut paragraphs = vec![if !description.is_empty() { + description.to_owned() + } else if !summary.is_empty() { + summary.to_owned() + } else { + format!("{} {}", m.http_method().as_str(), m.path()) + }]; + 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.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, +} + +/// Returns a `schemas.ts`, or `outputs.ts`, for each directory with models: +/// a constant with the schema of each model placed there, which every tool +/// that uses the model shares. Also returns each model's constant. +fn schema_files<'f>(schemas: &Schemas<'f>, kind: SchemaFile) -> (Vec, HashMap, Ident>) { + let (file, suffix, doc) = match kind { + SchemaFile::Input => ("schemas.ts", "", ""), + SchemaFile::Output => ("outputs.ts", "_output", " in responses"), + }; + let mut modules: BTreeMap, &Value)>> = BTreeMap::new(); + for (model, key, schema) in schemas.built() { + let module = model.group().map_or(Module::ROOT, Module::group).sub(file); + modules + .entry(module.as_str().to_owned()) + .or_default() + .push((key, model, schema)); + } + let schema_type = RUNTIME_TOOL.ident("JsonSchema").ty(); + let mut idents = HashMap::new(); + let mut files = Vec::new(); + for (path, mut models) in modules { + models.sort_by_key(|&(key, ..)| key); + let mut f = ts::File::new(Module::from(path)); + let mut taken = HashSet::new(); + for (key, model, schema) in models { + let mut name = ts_identifier(key) + suffix; + if RESERVED.contains(&name.as_str()) { + name.push('_'); + } + while !taken.insert(name.clone()) { + name.push('$'); + } + let ident = f.ident(name.clone()); + f.doc(&format!("The schema of `{}`{doc}.", model.schema_name())); + let json = serde_json::to_string_pretty(schema).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, ""); + idents.insert(model, ident); + } + files.push(f); + } + (files, idents) +} + +/// 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<'_>], + schemas: &HashMap, Ident>, + outputs: &HashMap, Ident>, +) -> 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 dotted: Vec = group.path().map(|name| name.snake()).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)); + } + if !t.defs.is_empty() { + p!(f, "$defs: {"); + f.indent(|f| { + for (key, model) in &t.defs { + p!(f, string(key), ": ", schemas[model].clone(), ","); + } + }); + 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), ": ", outputs[model].clone(), ","); + } + }); + 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), ": ", outputs[model].clone(), ","); + } + }); + 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(key) => { + parts.push(Box::new("${".to_owned())); + parts.push(Box::new(path_param.clone())); + parts.push(Box::new(format!("(args, {})}}", string(key)))); + } + } + } + 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, key, explode) in &t.query { + p!( + f, + "[", + string(wire), + ", args[", + string(key), + "], ", + explode.to_string(), + "]," + ); + } + }); + p!(f, "],"); + } + if !t.headers.is_empty() { + p!(f, "headers: ["); + f.indent(|f| { + for (wire, key) in &t.headers { + p!(f, "[", string(wire), ", args[", string(key), "]],"); + } + }); + p!(f, "],"); + } + if let Some((key, media_type)) = &t.body { + p!( + f, + "body: { value: args[", + string(key), + "], mediaType: ", + string(media_type), + " }," + ); + } + }); + p!(f, "}),"); + }); + p!(f, "},"); + } + }); + p!(f, "];"); + f +} + +/// Returns the `index.ts` of the root: the API, and every tool. +fn index(forge: &Forge, groups: &[(Group<'_>, Vec>)]) -> 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, and each +/// method without one and why. +fn readme(groups: &[(Group<'_>, Vec>)], skipped: &[(Method<'_>, String)]) -> String { + let count: usize = groups.iter().map(|(_, tools)| tools.len()).sum(); + let mut out = String::from("\n\n# MCP tools\n\n"); + let _ = writeln!( + out, + "{count} tools in {} groups. `index.ts` exports them as `tools`, with the runtime in `_/tool.ts` that lists them and builds their requests.", + groups.len() + ); + 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, Plugin}; + + 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)> = Generator + .generate(&forge) + .unwrap() + .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/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", args["tag"], false],"#), "{tools}"); + assert!(tools.contains(r#"["X-Trace", args["x_trace"]],"#), "{tools}"); + 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"], mediaType: "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_output,"#), "{tools}"); + let outputs = file(&files, "pets/outputs.ts"); + assert!(outputs.contains("export const Pet_output: 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_output,\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,"#), "{output}"); + assert!(!output.contains("E_output"), "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}" + ); + assert!(!files.iter().any(|(path, _)| path == "pets/photos/tools.ts")); + let readme = file(&files, "README.md"); + assert!(readme.contains("3 tools in 1 groups."), "{readme}"); + assert!( + readme.contains("| `POST` | `/stores/{store_id}/photos` | the request body is not JSON (image/png) |"), + "{readme}" + ); + } + + #[test] + fn duplicate_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"}}}} + }}"#, + ); + let readme = file(&files, "README.md"); + assert!(readme.contains("another method has the tool name a_b_c"), "{readme}"); + } + + /// 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..d2c194b34 --- /dev/null +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -0,0 +1,250 @@ +import type { Tool as McpTool } from '@modelcontextprotocol/server'; + +/** The arguments of a tool call, as the client sent them. */ +export type Args = Readonly>; + +/** 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[]; + readonly body?: { readonly value: unknown; readonly mediaType: 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 argument `key`, for the path: a string, number or boolean, + * percent-encoded. Throws {@link InvalidArguments} when it is missing. + */ +export function pathParam(args: Args, key: string): string { + const value = args[key]; + if (value === undefined || value === null || value === '') { + throw new InvalidArguments(`Missing path parameter: ${key}`); + } + return encodeURIComponent(scalar(key, value)); +} + +/** + * 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 { + const missing = (tool.inputSchema.required ?? []).filter((key) => args[key] === undefined); + if (missing.length > 0) { + throw new InvalidArguments(`Missing required arguments: ${missing.join(', ')}`); + } + 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) { + headers.set('Content-Type', api.body.mediaType); + body = JSON.stringify(api.body.value); + } + return { + method: tool.method, + url: url.href, + headers: Object.fromEntries(headers), + ...(body !== undefined && { body }), + }; +} + +/** + * 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..f8873461b --- /dev/null +++ b/src/bin/forge/mcp/lib/test/fixtures.ts @@ -0,0 +1,52 @@ +import { 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: { store_id: { type: 'string' }, tag: { type: 'array' }, sort: { type: 'object' } }, + required: ['store_id'], + }, + 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', args['tag'], true], + ['sort', args['sort'], false], + ], + headers: [['X-Trace', args['x_trace']]], + }), + }, + { + name: 'pets_create', + description: 'Adds a pet.', + inputSchema: { + type: 'object', + properties: { store_id: { type: 'string' }, body: {} }, + required: ['store_id', '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`, + body: { value: args['body'], mediaType: '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..c26d5bd75 --- /dev/null +++ b/src/bin/forge/mcp/lib/test/tool.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { buildRequest, InvalidArguments, listTools, 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, + { store_id: 'a b/c', tag: ['x', 'y'], sort: { by: 'name', dir: 'asc' }, 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, { 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, { store_id: 's', 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, { store_id: 's', body: { name: 'Rex' } }, 'https://api.example.com'); + assert.equal(request.method, 'POST'); + 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, { store_id: 's', 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, { store_id: 's' }, 'https://x'), { + name: 'InvalidArguments', + message: 'Missing required arguments: body', + }); + assert.throws(() => buildRequest(list, { store_id: '' }, 'https://x'), InvalidArguments); + assert.throws(() => buildRequest(list, { store_id: 's', tag: [{}] }, 'https://x'), { + message: 'tag must be a string, number or boolean', + }); +}); + +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); +}); 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..47e686abe --- /dev/null +++ b/src/bin/forge/mcp/schema.rs @@ -0,0 +1,283 @@ +//! 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 built once for every tool, and a tool's `$defs` +//! has only the models its schemas reach, so each tool's schema stands +//! alone. +//! +//! 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, HashMap, HashSet}; + +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, Default, PartialEq, Eq)] +pub(super) enum Direction { + /// A tool's arguments. + #[default] + Input, + /// A method's response bodies. + Output, +} + +/// Builds schemas, keeping each model's. +#[derive(Default)] +pub(super) struct Schemas<'f> { + direction: Direction, + /// The key of each model in `$defs`. + keys: HashMap, String>, + /// Every key given out, so that each is unique. + used: HashSet, + /// The schema of each model built so far, and the models it refers to. + models: HashMap, (Value, Refs<'f>)>, +} + +impl<'f> Schemas<'f> { + /// Returns a builder of the schemas of response bodies. + pub(super) fn output() -> Schemas<'f> { + Schemas { + direction: Direction::Output, + ..Schemas::default() + } + } + + /// Returns the schema of `t`, adding the models it refers to to `refs`. + pub(super) fn ty(&mut self, t: Type<'f>, refs: &mut Refs<'f>) -> Value { + match t { + 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) => { + refs.insert(model); + json!({ "$ref": format!("#/$defs/{}", self.key(model)) }) + } + } + } + + /// Returns the schema of `field`: the schema of its type, with its + /// description, default and deprecation for arguments. + pub(super) fn field(&mut 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 + { + if !field.description().is_empty() { + map.insert("description".to_owned(), json!(field.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 `$defs` of a schema that refers to `refs`: each model they + /// refer to, directly or through other models, by key. + pub(super) fn defs(&mut self, refs: &Refs<'f>) -> BTreeMap> { + let mut defs = BTreeMap::new(); + let mut queue: Vec> = refs.iter().copied().collect(); + while let Some(model) = queue.pop() { + let key = self.key(model); + if defs.insert(key, model).is_some() { + continue; + } + self.model(model); + queue.extend(self.models[&model].1.iter().copied()); + } + defs + } + + /// Returns every model built so far, with its key and schema. + pub(super) fn built(&self) -> impl Iterator, &str, &Value)> { + self.models + .iter() + .map(|(model, (schema, _))| (*model, self.keys[model].as_str(), schema)) + } + + /// Builds the schema of `model`, the first time. + fn model(&mut self, model: Model<'f>) { + if self.models.contains_key(&model) { + return; + } + let mut refs = Refs::new(); + let mut schema = self.ty(model.ty(), &mut refs); + if self.direction == Direction::Input + && let Value::Object(map) = &mut schema + { + if !model.description().is_empty() && !map.contains_key("description") { + map.insert("description".to_owned(), json!(model.description())); + } + if model.is_deprecated() { + map.insert("deprecated".to_owned(), json!(true)); + } + } + self.models.insert(model, (schema, refs)); + } + + /// Returns the schema of `object`. + fn object(&mut self, object: Object<'f>, refs: &mut Refs<'f>) -> Value { + let mut properties = Map::new(); + let mut required = Vec::new(); + let input = self.direction == Direction::Input; + for field in object.fields().filter(|f| !(input && f.is_read_only())) { + 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) + } + + /// Returns the key of `model` in `$defs`: its schema name, with each + /// character that a JSON Pointer or a URI fragment would need escaped + /// replaced by `_`, and a number added if another model has it. + fn key(&mut self, model: Model<'f>) -> String { + if let Some(key) = self.keys.get(&model) { + return key.clone(); + } + let base: String = model + .schema_name() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { + c + } else { + '_' + } + }) + .collect(); + let base = if base.is_empty() { "model".to_owned() } else { base }; + let mut key = base.clone(); + let mut n = 2; + while self.used.contains(&key) { + key = format!("{base}_{n}"); + n += 1; + } + self.used.insert(key.clone()); + self.keys.insert(model, key.clone()); + 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 `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)); + } +}