From e3e7c8cbbea905f3532d9127302b4a99cc46d80d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 20 Oct 2026 14:09:26 +0100 Subject: [PATCH 01/18] feat(mcp): generate an MCP server in TypeScript `forge openapi.json --target mcp --out server` generates TypeScript for the official MCP TypeScript SDK (@modelcontextprotocol/server): - /tools.ts: the tools of the group's methods, each with the JSON Schema of its arguments, its hints, the method's extensions, and a request function that builds its API request - /schemas.ts: a constant for each model's schema, shared by every tool whose arguments use it - index.ts: every tool, and the API's base URL and version - README.md: the methods without a tool, and why - _/: the runtime, which serves the tools with low-level tools/list and tools/call handlers and sends their requests Tools are named after their groups and method in snake_case. Hidden methods get tools, since a client can only call the tools it lists; deprecated ones don't. Methods whose required body isn't JSON, or whose tool name is taken or too long, are listed in README.md instead. --- pnpm-lock.yaml | 29 + pnpm-workspace.yaml | 1 + src/bin/forge/main.rs | 21 +- src/bin/forge/mcp.rs | 881 ++++++++++++++++++++++ src/bin/forge/mcp/lib/.gitignore | 1 + src/bin/forge/mcp/lib/package.json | 25 + src/bin/forge/mcp/lib/src/index.ts | 16 + src/bin/forge/mcp/lib/src/server.ts | 153 ++++ src/bin/forge/mcp/lib/src/tool.ts | 180 +++++ src/bin/forge/mcp/lib/test/fixtures.ts | 43 ++ src/bin/forge/mcp/lib/test/register.ts | 21 + src/bin/forge/mcp/lib/test/server.test.ts | 102 +++ src/bin/forge/mcp/lib/test/tool.test.ts | 50 ++ src/bin/forge/mcp/lib/tsconfig.json | 27 + src/bin/forge/mcp/lib/tsconfig.test.json | 8 + src/bin/forge/mcp/schema.rs | 254 +++++++ 16 files changed, 1807 insertions(+), 5 deletions(-) create mode 100644 src/bin/forge/mcp.rs create mode 100644 src/bin/forge/mcp/lib/.gitignore create mode 100644 src/bin/forge/mcp/lib/package.json create mode 100644 src/bin/forge/mcp/lib/src/index.ts create mode 100644 src/bin/forge/mcp/lib/src/server.ts create mode 100644 src/bin/forge/mcp/lib/src/tool.ts create mode 100644 src/bin/forge/mcp/lib/test/fixtures.ts create mode 100644 src/bin/forge/mcp/lib/test/register.ts create mode 100644 src/bin/forge/mcp/lib/test/server.test.ts create mode 100644 src/bin/forge/mcp/lib/test/tool.test.ts create mode 100644 src/bin/forge/mcp/lib/tsconfig.json create mode 100644 src/bin/forge/mcp/lib/tsconfig.test.json create mode 100644 src/bin/forge/mcp/schema.rs 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/src/bin/forge/main.rs b/src/bin/forge/main.rs index 6781dd8ea..c59eb19b4 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 server //! 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 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 server generator, which generates 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..981e561cb --- /dev/null +++ b/src/bin/forge/mcp.rs @@ -0,0 +1,881 @@ +//! The MCP server generator. It generates TypeScript for the official MCP +//! TypeScript SDK, `@modelcontextprotocol/server`. +//! +//! 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, and +//! a `request` function that builds its API request. The root gets an +//! `index.ts` with every tool, a `README.md` that lists the methods without +//! a tool and why, and the runtime library in `_/`, which serves the tools +//! on an `McpServer` and sends their requests. +//! +//! 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 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) { + 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 (schema_files, idents) = schema_files(&schemas); + let mut files: Vec = groups + .iter() + .map(|(group, tools)| File::Ts(tools_file(*group, tools, &idents))) + .collect(); + files.extend(schema_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 exports it. +const RUNTIME_INDEX: Module = Module::new("_/index.ts"); + +/// The sources of the runtime library, by path in lib/src. +const RUNTIME: [(&str, &str); 3] = [ + ("index.ts", include_str!("mcp/lib/src/index.ts")), + ("server.ts", include_str!("mcp/lib/src/server.ts")), + ("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)>, +} + +/// 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>) -> 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(", "))); + } + } + + Ok(Tool { + method: m, + name, + properties: Value::Object(properties), + required, + defs: schemas.defs(&refs), + path: segments(m.path(), &path_keys)?, + query, + headers, + body, + }) + } + + /// 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) + } +} + +/// 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") +} + +/// Returns a `schemas.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>) -> (Vec, HashMap, Ident>) { + 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("schemas.ts"); + modules + .entry(module.as_str().to_owned()) + .or_default() + .push((key, model, schema)); + } + let schema_type = RUNTIME_INDEX.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); + 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 `{}`.", 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>) -> ts::File { + let mut f = ts::File::new(Module::group(group).sub("tools.ts")); + let tool = RUNTIME_INDEX.ident("Tool").ty(); + let path_param = RUNTIME_INDEX.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, "},"); + 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)); + } + 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_INDEX.ident("Tool").ty(); + p!(f, "export * from './_/index.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`, and the runtime in `_/` that serves them.", + 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"}}}, + "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", + "_/index.ts", + "_/server.ts", + "_/tool.ts", + "index.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 './_/index.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 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..b34c966c6 --- /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/index.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/index.ts b/src/bin/forge/mcp/lib/src/index.ts new file mode 100644 index 000000000..55af548fb --- /dev/null +++ b/src/bin/forge/mcp/lib/src/index.ts @@ -0,0 +1,16 @@ +export { + buildRequest, + InvalidArguments, + listTools, + pathParam, + toolsByName, + type ApiRequest, + type Args, + type HeaderParam, + type InputSchema, + type JsonSchema, + type QueryParam, + type Tool, + type ToolAnnotations, +} from './tool.js'; +export { callTool, createHandler, readBody, serveTools, type CallContext, type HandlerOptions } from './server.js'; diff --git a/src/bin/forge/mcp/lib/src/server.ts b/src/bin/forge/mcp/lib/src/server.ts new file mode 100644 index 000000000..50cb7ba58 --- /dev/null +++ b/src/bin/forge/mcp/lib/src/server.ts @@ -0,0 +1,153 @@ +import { + createMcpHandler, + McpServer, + type CallToolResult, + type Implementation, + type Tool as McpTool, +} from '@modelcontextprotocol/server'; + +import { buildRequest, InvalidArguments, listTools, toolsByName, type Args, type Tool } from './tool.js'; + +/** What a tool call needs besides the tool and its arguments. */ +export interface CallContext { + /** The API's base URL. */ + readonly baseUrl: string; + /** Headers every API request sends, such as `Authorization`. */ + readonly headers?: HeadersInit; + readonly fetch?: (request: Request) => Promise; + /** + * Returns the text of the result. By default a JSON body is pretty-printed, + * text is kept, and an error starts with the status. + */ + readonly formatResult?: (body: unknown, response: Response) => string; +} + +/** + * Serves `tools` on `server` with low-level `tools/list` and `tools/call` + * handlers, rather than registering each tool, which for thousands of tools + * costs more than the request. `call` makes the API request, and `extra` + * tools the server serves itself are listed first. + */ +export function serveTools( + server: McpServer, + tools: readonly Tool[], + call: (tool: Tool, args: Args) => Promise, + extra: readonly McpTool[] = [], +): void { + const listing = extra.length > 0 ? [...extra, ...listTools(tools)] : listTools(tools); + const byName = toolsByName(tools); + server.server.registerCapabilities({ tools: { listChanged: false } }); + // The SDK types the list as mutable; it only serializes it. + server.server.setRequestHandler('tools/list', () => ({ tools: listing as McpTool[] })); + server.server.setRequestHandler('tools/call', async (request) => { + const tool = byName.get(request.params.name); + if (!tool) { + return errorResult(`Unknown tool: ${request.params.name}`); + } + return call(tool, request.params.arguments ?? {}); + }); +} + +/** + * Calls `tool`: builds its request, sends it, and returns the response as the + * result. A response that isn't 2xx is a result with `isError: true` rather + * than a protocol error, so the model sees what the API said. + */ +export async function callTool(tool: Tool, args: Args, context: CallContext): Promise { + let request: Request; + try { + request = buildRequest(tool, args, context.baseUrl); + } catch (err) { + if (err instanceof InvalidArguments) { + return errorResult(err.message); + } + throw err; + } + for (const [name, value] of new Headers(context.headers)) { + request.headers.set(name, value); + } + const response = await (context.fetch ?? fetch)(request); + const body = await readBody(response); + const text = (context.formatResult ?? defaultFormat)(body, response); + return response.ok ? { content: [{ type: 'text', text }] } : errorResult(text); +} + +/** + * Returns the body of `response`: parsed JSON, text, or for anything else a + * sentence saying what it was, since a tool result can't carry the bytes. + */ +export async function readBody(response: Response): Promise { + const type = response.headers.get('Content-Type') ?? ''; + if (/json/i.test(type)) { + const text = await response.text(); + try { + return text ? JSON.parse(text) : text; + } catch { + return text; + } + } + if (type === '' || /^text\/|xml|javascript|yaml|csv/i.test(type)) { + return response.text(); + } + const size = (await response.arrayBuffer()).byteLength; + return `The response is ${size} bytes of ${type}, which this tool can't show.`; +} + +function defaultFormat(body: unknown, response: Response): string { + const text = typeof body === 'string' ? body : JSON.stringify(body, null, 2); + return response.ok ? text : `${response.status} ${response.statusText}\n${text}`.trim(); +} + +function errorResult(text: string): CallToolResult { + return { content: [{ type: 'text', text }], isError: true }; +} + +/** What {@link createHandler} serves. */ +export interface HandlerOptions { + readonly tools: readonly Tool[]; + /** The API's base URL. */ + readonly baseUrl: string; + readonly serverInfo: Implementation; + readonly instructions?: string; + /** + * Called before each MCP request is served. Returns the headers its API + * requests send, such as the caller's `Authorization`, or a response that + * refuses it. + */ + readonly authorize?: ( + request: Request, + ) => HeadersInit | Response | undefined | Promise; + readonly fetch?: (request: Request) => Promise; + readonly formatResult?: CallContext['formatResult']; +} + +/** + * Returns a `fetch` that serves `tools` over MCP's Streamable HTTP transport: + * for a Worker, or any server with web-standard requests. Each request gets a + * fresh, stateless server. + */ +export function createHandler(options: HandlerOptions): { fetch(request: Request): Promise } { + return { + async fetch(request) { + const authorized = options.authorize ? await options.authorize(request) : undefined; + if (authorized instanceof Response) { + return authorized; + } + const context: CallContext = { + baseUrl: options.baseUrl, + ...(authorized && { headers: authorized }), + ...(options.fetch && { fetch: options.fetch }), + ...(options.formatResult && { formatResult: options.formatResult }), + }; + const handler = createMcpHandler(() => { + const server = new McpServer( + options.serverInfo, + options.instructions ? { instructions: options.instructions } : {}, + ); + serveTools(server, options.tools, (tool, args) => callTool(tool, args, context)); + return server; + }); + return handler.fetch(request); + }, + }; +} 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..bfd5e9ca2 --- /dev/null +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -0,0 +1,180 @@ +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[]; +}; + +/** 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]; + +/** + * 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; + 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 }; + /** 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. + */ +export function buildRequest(tool: Tool, args: Args, baseUrl: string): Request { + 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(baseUrl.replace(/\/+$/, '') + 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)); + } + } + const init: RequestInit = { method: tool.method, headers }; + if (api.body && api.body.value !== undefined) { + headers.set('Content-Type', api.body.mediaType); + init.body = JSON.stringify(api.body.value); + } + return new Request(url, init); +} + +/** 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`); +} + +const listings = new WeakMap(); +const indexes = new WeakMap>(); + +/** + * Returns `tools` as `tools/list` sends them, without their request code, + * method, path and extensions. It is built once for each array of tools, and + * the same array every time after. + */ +export function listTools(tools: readonly Tool[]): readonly McpTool[] { + let listing = listings.get(tools); + if (!listing) { + listing = tools.map( + ({ request: _request, method: _method, path: _path, extensions: _extensions, ...tool }) => tool as McpTool, + ); + listings.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..d95e39dec --- /dev/null +++ b/src/bin/forge/mcp/lib/test/fixtures.ts @@ -0,0 +1,43 @@ +import { pathParam, type Tool } from '../src/index.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'], + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + method: 'GET', + path: '/stores/{store_id}/pets', + extensions: { 'x-api-token-group': ['Pets Read'] }, + 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/server.test.ts b/src/bin/forge/mcp/lib/test/server.test.ts new file mode 100644 index 000000000..6db531c2f --- /dev/null +++ b/src/bin/forge/mcp/lib/test/server.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createHandler, readBody } from '../src/index.js'; +import { tools } from './fixtures.js'; + +/** Posts one JSON-RPC request to `handler` the way a 2026-07-28 client does, and returns its result. */ +async function rpc( + handler: ReturnType, + method: string, + params: { name?: string; arguments?: object } = {}, +) { + const response = await handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': '2026-07-28', + 'Mcp-Method': method, + ...(params.name && { 'Mcp-Name': params.name }), + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params: { + ...params, + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'test', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {}, + }, + }, + }), + }), + ); + const text = await response.text(); + const json = text.startsWith('{') + ? text + : text + .split('\n') + .find((line) => line.startsWith('data:'))! + .slice(5); + return JSON.parse(json) as { result?: any; error?: any }; +} + +function handler(fetch: (request: Request) => Promise) { + return createHandler({ + tools, + baseUrl: 'https://api.example.com', + serverInfo: { name: 'pets', version: '1.0.0' }, + authorize: (request) => + request.headers.get('X-Refuse') ? new Response('no', { status: 401 }) : { Authorization: 'Bearer t' }, + fetch, + }); +} + +test('lists the tools', async () => { + const { result } = await rpc(handler(fetch), 'tools/list'); + assert.deepEqual( + result.tools.map((tool: { name: string }) => tool.name), + ['pets_list', 'pets_create'], + ); + assert.equal(result.tools[0].path, undefined); +}); + +test('calls a tool with the authorized headers', async () => { + let sent: Request | undefined; + const { result } = await rpc( + handler(async (request) => { + sent = request; + return Response.json({ pets: ['Rex'] }); + }), + 'tools/call', + { name: 'pets_list', arguments: { store_id: 's1' } }, + ); + assert.equal(sent?.url, 'https://api.example.com/stores/s1/pets'); + assert.equal(sent?.headers.get('Authorization'), 'Bearer t'); + assert.equal(result.isError, undefined); + assert.deepEqual(JSON.parse(result.content[0].text), { pets: ['Rex'] }); +}); + +test('returns API errors and bad arguments as tool errors', async () => { + const api = handler(async () => Response.json({ error: 'nope' }, { status: 403, statusText: 'Forbidden' })); + const refused = await rpc(api, 'tools/call', { name: 'pets_list', arguments: { store_id: 's1' } }); + assert.equal(refused.result.isError, true); + assert.match(refused.result.content[0].text, /^403 Forbidden\n/); + + const missing = await rpc(api, 'tools/call', { name: 'pets_create', arguments: { store_id: 's1' } }); + assert.deepEqual(missing.result.content, [{ type: 'text', text: 'Missing required arguments: body' }]); + assert.equal(missing.result.isError, true); + + const unknown = await rpc(api, 'tools/call', { name: 'nope', arguments: {} }); + assert.equal(unknown.result.content[0].text, 'Unknown tool: nope'); +}); + +test('describes a body it cannot show', async () => { + const body = await readBody(new Response(new Uint8Array(3), { headers: { 'Content-Type': 'image/png' } })); + assert.equal(body, "The response is 3 bytes of image/png, which this tool can't show."); + assert.equal(await readBody(new Response('a,b', { headers: { 'Content-Type': 'text/csv' } })), 'a,b'); +}); 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..ebee501a7 --- /dev/null +++ b/src/bin/forge/mcp/lib/test/tool.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { buildRequest, InvalidArguments, listTools, toolsByName } from '../src/index.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.equal(request.headers.get('X-Trace'), '7'); + assert.equal(request.headers.get('Accept'), 'application/json'); +}); + +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(request.headers.has('X-Trace'), false); +}); + +test('sends the body as JSON', async () => { + const request = buildRequest(create, { store_id: 's', body: { name: 'Rex' } }, 'https://api.example.com'); + assert.equal(request.method, 'POST'); + assert.equal(request.headers.get('Content-Type'), 'application/json'); + assert.deepEqual(await request.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); +}); 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..d699816ac --- /dev/null +++ b/src/bin/forge/mcp/schema.rs @@ -0,0 +1,254 @@ +//! JSON Schemas (2020-12) of the arguments 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 arguments reach, so each tool's schema stands +//! alone. Read-only fields are left out, since the schemas describe what a +//! client sends. + +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>; + +/// Builds schemas, keeping each model's. +#[derive(Default)] +pub(super) struct Schemas<'f> { + /// 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 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. + pub(super) fn field(&mut self, field: Field<'f>, refs: &mut Refs<'f>) -> Value { + let mut schema = self.ty(field.ty(), refs); + if 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 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(); + for field in object.fields().filter(|f| !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)); + } +} From c74e8595869aa5387b434ec1eb10c897fa5e06bb Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 20 Oct 2026 15:28:44 +0100 Subject: [PATCH 02/18] fix(mcp): import the tools without the MCP SDK The generated files import the tool type and pathParam from _/tool.ts, and index.ts re-exports only that half of the runtime, so a server can import the tools, for example lazily or as a module of its own, without pulling in @modelcontextprotocol/server. _/server.ts still serves them. --- src/bin/forge/mcp.rs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/bin/forge/mcp.rs b/src/bin/forge/mcp.rs index 981e561cb..8105fd27a 100644 --- a/src/bin/forge/mcp.rs +++ b/src/bin/forge/mcp.rs @@ -6,7 +6,9 @@ //! a `request` function that builds its API request. The root gets an //! `index.ts` with every tool, a `README.md` that lists the methods without //! a tool and why, and the runtime library in `_/`, which serves the tools -//! on an `McpServer` and sends their requests. +//! on an `McpServer` and sends their requests. `index.ts` re-exports only +//! the half of the runtime that doesn't import the MCP SDK, `_/tool.ts`, so +//! a server can load the tools as a module of their own. //! //! 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 @@ -103,8 +105,9 @@ impl forge::File for File { /// The directory of the runtime library. const RUNTIME_DIR: Module = Module::new("_"); -/// The module of the runtime library that exports it. -const RUNTIME_INDEX: Module = Module::new("_/index.ts"); +/// The module of the runtime library that the generated files import: the +/// tool type and request helpers, which don't import the MCP SDK. +const RUNTIME_TOOL: Module = Module::new("_/tool.ts"); /// The sources of the runtime library, by path in lib/src. const RUNTIME: [(&str, &str); 3] = [ @@ -375,7 +378,7 @@ fn schema_files<'f>(schemas: &Schemas<'f>) -> (Vec, HashMap, .or_default() .push((key, model, schema)); } - let schema_type = RUNTIME_INDEX.ident("JsonSchema").ty(); + let schema_type = RUNTIME_TOOL.ident("JsonSchema").ty(); let mut idents = HashMap::new(); let mut files = Vec::new(); for (path, mut models) in modules { @@ -469,8 +472,8 @@ const RESERVED: &[&str] = &[ /// Returns the `tools.ts` of `group`. fn tools_file(group: Group<'_>, tools: &[Tool<'_>], schemas: &HashMap, Ident>) -> ts::File { let mut f = ts::File::new(Module::group(group).sub("tools.ts")); - let tool = RUNTIME_INDEX.ident("Tool").ty(); - let path_param = RUNTIME_INDEX.ident("pathParam"); + 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, "[] = ["); @@ -588,8 +591,11 @@ fn tools_file(group: Group<'_>, tools: &[Tool<'_>], schemas: &HashMap, /// 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_INDEX.ident("Tool").ty(); - p!(f, "export * from './_/index.js';"); + let tool = RUNTIME_TOOL.ident("Tool").ty(); + // Only the half of the runtime without dependencies, so that importing the + // tools doesn't import the MCP SDK. The other half, which serves them, is + // in `./_/server.js`. + p!(f, "export * from './_/tool.js';"); p!(f, ""); f.doc("The API the tools call."); p!(f, "export const api = {"); @@ -623,7 +629,7 @@ fn readme(groups: &[(Group<'_>, Vec>)], skipped: &[(Method<'_>, String) let mut out = String::from("\n\n# MCP tools\n\n"); let _ = writeln!( out, - "{count} tools in {} groups. `index.ts` exports them as `tools`, and the runtime in `_/` that serves them.", + "{count} tools in {} groups. `index.ts` exports them as `tools`, with the runtime in `_/tool.ts` that builds their requests; `_/server.ts` serves them on an `McpServer`.", groups.len() ); if !skipped.is_empty() { @@ -790,7 +796,7 @@ mod tests { ); assert!(index.contains(r#"baseUrl: "https://api.example.com/v1","#), "{index}"); assert!(index.contains("...petsTools,"), "{index}"); - assert!(index.contains("export * from './_/index.js';"), "{index}"); + assert!(index.contains("export * from './_/tool.js';"), "{index}"); } #[test] From 4025954c9d93cabdc7f2a31011ccaf88748d4738 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 21 Oct 2026 11:52:18 +0100 Subject: [PATCH 03/18] feat(mcp): generate each tool's response schemas Each tool gets responses: the JSON Schema of every response body the method declares, by status (2XX, error ranges and default), with a $defs of the models they reach, from shared outputs.ts constants. Response schemas keep read-only fields and leave out descriptions and defaults: they're for a server to check responses against, so listTools leaves them out of tools/list. --- src/bin/forge/mcp.rs | 133 +++++++++++++++++++++---- src/bin/forge/mcp/lib/src/index.ts | 1 + src/bin/forge/mcp/lib/src/tool.ts | 22 +++- src/bin/forge/mcp/lib/test/fixtures.ts | 4 + src/bin/forge/mcp/schema.rs | 45 +++++++-- 5 files changed, 178 insertions(+), 27 deletions(-) diff --git a/src/bin/forge/mcp.rs b/src/bin/forge/mcp.rs index 8105fd27a..7453e7d9b 100644 --- a/src/bin/forge/mcp.rs +++ b/src/bin/forge/mcp.rs @@ -2,8 +2,10 @@ //! TypeScript SDK, `@modelcontextprotocol/server`. //! //! 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, and -//! a `request` function that builds its API request. The root gets an +//! its methods, each with the JSON Schema of its arguments, its hints, the +//! JSON Schemas of its response bodies, 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 root gets an //! `index.ts` with every tool, a `README.md` that lists the methods without //! a tool and why, and the runtime library in `_/`, which serves the tools //! on an `McpServer` and sends their requests. `index.ts` re-exports only @@ -38,6 +40,7 @@ impl Plugin for Generator { /// 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(); @@ -47,7 +50,7 @@ impl Plugin for Generator { if m.is_deprecated() { continue; } - match Tool::new(m, &mut schemas) { + 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)), @@ -59,12 +62,14 @@ impl Plugin for Generator { } } - let (schema_files, idents) = schema_files(&schemas); + 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))) + .map(|(group, tools)| File::Ts(tools_file(*group, tools, &idents, &output_idents))) .collect(); - files.extend(schema_files.into_iter().map(File::Ts)); + 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(), @@ -138,6 +143,11 @@ struct Tool<'f> { 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>, } /// A part of a path template. @@ -149,7 +159,7 @@ enum Segment { impl<'f> Tool<'f> { /// Returns the tool of `m`, or why it has none. - fn new(m: Method<'f>, schemas: &mut Schemas<'f>) -> Result, String> { + 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")); @@ -221,16 +231,33 @@ impl<'f> Tool<'f> { } } + let path = segments(m.path(), &path_keys)?; + let mut response_refs = Refs::new(); + let responses = m + .responses() + .chain(m.errors()) + .map(|r| { + let status = if r.status().eq_ignore_ascii_case("default") { + "default".to_owned() + } else { + r.status().to_ascii_uppercase() + }; + (status, r.json().map(|c| outputs.ty(c.ty(), &mut response_refs))) + }) + .collect(); + Ok(Tool { method: m, name, properties: Value::Object(properties), required, defs: schemas.defs(&refs), - path: segments(m.path(), &path_keys)?, + path, query, headers, body, + responses, + response_defs: outputs.defs(&response_refs), }) } @@ -366,13 +393,27 @@ fn description(m: Method<'_>) -> String { paragraphs.join("\n\n") } -/// Returns a `schemas.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>) -> (Vec, HashMap, Ident>) { +/// 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("schemas.ts"); + let module = model.group().map_or(Module::ROOT, Module::group).sub(file); modules .entry(module.as_str().to_owned()) .or_default() @@ -386,7 +427,7 @@ fn schema_files<'f>(schemas: &Schemas<'f>) -> (Vec, HashMap, 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); + let mut name = ts_identifier(key) + suffix; if RESERVED.contains(&name.as_str()) { name.push('_'); } @@ -394,7 +435,7 @@ fn schema_files<'f>(schemas: &Schemas<'f>) -> (Vec, HashMap, name.push('$'); } let ident = f.ident(name.clone()); - f.doc(&format!("The schema of `{}`.", model.schema_name())); + 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; @@ -470,7 +511,12 @@ const RESERVED: &[&str] = &[ ]; /// Returns the `tools.ts` of `group`. -fn tools_file(group: Group<'_>, tools: &[Tool<'_>], schemas: &HashMap, Ident>) -> ts::File { +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"); @@ -516,6 +562,27 @@ fn tools_file(group: Group<'_>, tools: &[Tool<'_>], schemas: &HashMap, 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() @@ -737,7 +804,10 @@ mod tests { "schema": {"type": "array", "items": {"type": "string"}}}, {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} ], - "responses": {"200": {"description": "ok"}}}, + "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"}}], @@ -781,6 +851,7 @@ mod tests { "_/server.ts", "_/tool.ts", "index.ts", + "pets/outputs.ts", "pets/schemas.ts", "pets/tools.ts" ] @@ -833,6 +904,34 @@ mod tests { 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 skips() { let files = generate(PETS); diff --git a/src/bin/forge/mcp/lib/src/index.ts b/src/bin/forge/mcp/lib/src/index.ts index 55af548fb..c4524d8c2 100644 --- a/src/bin/forge/mcp/lib/src/index.ts +++ b/src/bin/forge/mcp/lib/src/index.ts @@ -12,5 +12,6 @@ export { type QueryParam, type Tool, type ToolAnnotations, + type ToolResponses, } from './tool.js'; export { callTool, createHandler, readBody, serveTools, type CallContext, type HandlerOptions } from './server.js'; diff --git a/src/bin/forge/mcp/lib/src/tool.ts b/src/bin/forge/mcp/lib/src/tool.ts index bfd5e9ca2..a9e9fab3a 100644 --- a/src/bin/forge/mcp/lib/src/tool.ts +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -13,6 +13,21 @@ export type InputSchema = 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; @@ -60,6 +75,8 @@ export interface Tool { * 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; } @@ -155,14 +172,15 @@ const indexes = new WeakMap>(); /** * Returns `tools` as `tools/list` sends them, without their request code, - * method, path and extensions. It is built once for each array of tools, and + * method, path, extensions and response schemas. It is built once for each array of tools, and * the same array every time after. */ export function listTools(tools: readonly Tool[]): readonly McpTool[] { let listing = listings.get(tools); if (!listing) { listing = tools.map( - ({ request: _request, method: _method, path: _path, extensions: _extensions, ...tool }) => tool as McpTool, + ({ request: _request, method: _method, path: _path, extensions: _extensions, responses: _responses, ...tool }) => + tool as McpTool, ); listings.set(tools, listing); } diff --git a/src/bin/forge/mcp/lib/test/fixtures.ts b/src/bin/forge/mcp/lib/test/fixtures.ts index d95e39dec..e81c66ed6 100644 --- a/src/bin/forge/mcp/lib/test/fixtures.ts +++ b/src/bin/forge/mcp/lib/test/fixtures.ts @@ -15,6 +15,10 @@ export const tools: readonly Tool[] = [ 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: [ diff --git a/src/bin/forge/mcp/schema.rs b/src/bin/forge/mcp/schema.rs index d699816ac..47e686abe 100644 --- a/src/bin/forge/mcp/schema.rs +++ b/src/bin/forge/mcp/schema.rs @@ -1,10 +1,15 @@ -//! JSON Schemas (2020-12) of the arguments of tools. +//! 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 arguments reach, so each tool's schema stands -//! alone. Read-only fields are left out, since the schemas describe what a -//! client sends. +//! 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}; @@ -14,9 +19,20 @@ 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. @@ -26,6 +42,14 @@ pub(super) struct Schemas<'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 { @@ -75,10 +99,12 @@ impl<'f> Schemas<'f> { } /// Returns the schema of `field`: the schema of its type, with its - /// description, default and deprecation. + /// 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 let Value::Object(map) = &mut schema { + if self.direction == Direction::Input + && let Value::Object(map) = &mut schema + { if !field.description().is_empty() { map.insert("description".to_owned(), json!(field.description())); } @@ -122,7 +148,9 @@ impl<'f> Schemas<'f> { } let mut refs = Refs::new(); let mut schema = self.ty(model.ty(), &mut refs); - if let Value::Object(map) = &mut schema { + 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())); } @@ -137,7 +165,8 @@ impl<'f> Schemas<'f> { fn object(&mut 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| !f.is_read_only()) { + 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()); From c8b6dc6fcda199ab55c852cee9a71af5c5ec9310 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 22 Oct 2026 16:38:10 +0100 Subject: [PATCH 04/18] feat(mcp): generate each tool's output schema Each tool gets an outputSchema: the JSON body schema of its successful (2xx) responses, or anyOf the distinct ones, with $defs holding only the models those reach. A tool with a 2xx response that has no JSON body gets none. listTools leaves it out unless called with { outputSchema: true }. --- src/bin/forge/mcp.rs | 124 +++++++++++++++++++++--- src/bin/forge/mcp/lib/src/tool.ts | 37 +++++-- src/bin/forge/mcp/lib/test/fixtures.ts | 5 + src/bin/forge/mcp/lib/test/tool.test.ts | 9 ++ 4 files changed, 153 insertions(+), 22 deletions(-) diff --git a/src/bin/forge/mcp.rs b/src/bin/forge/mcp.rs index 7453e7d9b..16347f014 100644 --- a/src/bin/forge/mcp.rs +++ b/src/bin/forge/mcp.rs @@ -3,9 +3,11 @@ //! //! 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, 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 root gets an +//! 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, a `README.md` that lists the methods without //! a tool and why, and the runtime library in `_/`, which serves the tools //! on an `McpServer` and sends their requests. `index.ts` re-exports only @@ -148,6 +150,11 @@ struct Tool<'f> { 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. @@ -233,18 +240,30 @@ impl<'f> Tool<'f> { let path = segments(m.path(), &path_keys)?; let mut response_refs = Refs::new(); - let responses = m - .responses() - .chain(m.errors()) - .map(|r| { - let status = if r.status().eq_ignore_ascii_case("default") { - "default".to_owned() - } else { - r.status().to_ascii_uppercase() - }; - (status, r.json().map(|c| outputs.ty(c.ty(), &mut response_refs))) - }) - .collect(); + 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, @@ -258,6 +277,8 @@ impl<'f> Tool<'f> { body, responses, response_defs: outputs.defs(&response_refs), + output, + output_defs, }) } @@ -285,6 +306,25 @@ impl<'f> Tool<'f> { } } +/// 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 { @@ -552,6 +592,24 @@ fn tools_file( } }); 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()), ","); @@ -932,6 +990,42 @@ mod tests { ); } + #[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); diff --git a/src/bin/forge/mcp/lib/src/tool.ts b/src/bin/forge/mcp/lib/src/tool.ts index a9e9fab3a..58af4cfbf 100644 --- a/src/bin/forge/mcp/lib/src/tool.ts +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -65,6 +65,13 @@ export interface Tool { 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; @@ -167,22 +174,38 @@ function scalar(name: string, value: unknown): string { 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. It is built once for each array of tools, and - * the same array every time after. + * 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[]): readonly McpTool[] { - let listing = listings.get(tools); +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, ...tool }) => - tool as McpTool, + ({ + request: _request, + method: _method, + path: _path, + extensions: _extensions, + responses: _responses, + outputSchema: output, + ...tool + }) => (outputSchema && output ? { ...tool, outputSchema: output } : tool) as McpTool, ); - listings.set(tools, listing); + cache.set(tools, listing); } return listing; } diff --git a/src/bin/forge/mcp/lib/test/fixtures.ts b/src/bin/forge/mcp/lib/test/fixtures.ts index e81c66ed6..b03e013db 100644 --- a/src/bin/forge/mcp/lib/test/fixtures.ts +++ b/src/bin/forge/mcp/lib/test/fixtures.ts @@ -11,6 +11,11 @@ export const tools: readonly Tool[] = [ 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', diff --git a/src/bin/forge/mcp/lib/test/tool.test.ts b/src/bin/forge/mcp/lib/test/tool.test.ts index ebee501a7..88acb7f6e 100644 --- a/src/bin/forge/mcp/lib/test/tool.test.ts +++ b/src/bin/forge/mcp/lib/test/tool.test.ts @@ -48,3 +48,12 @@ test('lists the tools without their request code, once', () => { 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); +}); From d921a6038a371c32e50705ba6edce6f5834d9297 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 18:40:18 +0000 Subject: [PATCH 05/18] docs(mcp): describe how the MCP target maps the API onto MCP rfcs/mcp.md says which methods get a tool, how tools and arguments are named, how a call becomes a request, how schemas are built for arguments, responses and output, the hints and extensions, and what the runtime does. --- rfcs/mcp.md | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 rfcs/mcp.md diff --git a/rfcs/mcp.md b/rfcs/mcp.md new file mode 100644 index 000000000..3cab1af69 --- /dev/null +++ b/rfcs/mcp.md @@ -0,0 +1,198 @@ +# The MCP target + +`forge openapi.json --target mcp --out server` generates 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, what it generates, and what a server built on it does. It describes the generator as it is. + +The server targets the official MCP TypeScript SDK, `@modelcontextprotocol/server`, and the `2026-07-28` revision of the protocol, which also serves `2025` clients. + +## 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 +server/ + index.ts every tool, the API's base URL and version, and the runtime's tool half + README.md the number of tools, and each method without one and why + _/ the runtime library, copied in as it is + tool.ts the Tool type, building requests, listing tools; no imports at runtime + server.ts serving tools on an McpServer, calling them, an HTTP handler + index.ts both + /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' } }), +} +``` + +`index.ts` re-exports only `_/tool.ts`, which imports nothing at runtime. A server can load the tools without the MCP SDK, for example in a process that only plans requests, and import `_/server.ts` where it serves them. + +## 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 | 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. | +| A path parameter with no argument | The tool couldn't fill the path. | + +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 `x-fern-sdk-group-name` and `x-fern-sdk-method-name`, 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 a `Request`. 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 }`, and `serveTools` and `callTool` never send them or `structuredContent`. 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 + +`_/` is copied into every server. 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. +- `toolsByName(tools)` indexes them, once per array. +- `buildRequest(tool, args, baseUrl)` is described in [The request](#the-request). +- `serveTools(server, tools, call, extra)` serves the tools on an `McpServer` with one `tools/list` and one `tools/call` handler, rather than registering each tool, which for thousands of tools costs more than the request. It declares `tools.listChanged: false`. `extra` lists tools the server serves itself, first. +- `callTool(tool, args, context)` builds the request, adds the context's headers (such as `Authorization`), sends it, and returns the body as text: JSON pretty-printed, text as it is, anything else as a sentence giving its size and type. A non-2xx response is a result with `isError: true`, starting with the status, so the model reads what the API said. An `InvalidArguments` is an error result too. +- `createHandler(options)` returns a `fetch` that serves the tools over Streamable HTTP, with a fresh stateless server for each request. `authorize(request)` returns the headers the API requests send, or a `Response` that refuses the request. + +Servers with their own policy, such as making a parameter optional or filling it from the session, build on `listTools`, `toolsByName` and `buildRequest` and leave the rest. + +## What clients see + +| Field | `tools/list` | Server only | +| ------------------------------------------------------------ | ------------ | ----------- | +| `name`, `title`, `description`, `inputSchema`, `annotations` | ✓ | | +| `outputSchema` | when asked | | +| `method`, `path`, `request` | | ✓ | +| `extensions` | | ✓ | +| `responses` | | ✓ | From 171309de5fa14f3487329b4bb524b11cc8dc882c Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 18:45:35 +0000 Subject: [PATCH 06/18] refactor(mcp): keep serveTools inside the runtime Only createHandler serves the tools on an McpServer, so serveTools is no longer exported, and its extra tools, which nothing passed, are gone. --- rfcs/mcp.md | 7 +++---- src/bin/forge/mcp/lib/src/index.ts | 2 +- src/bin/forge/mcp/lib/src/server.ts | 9 ++++----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/rfcs/mcp.md b/rfcs/mcp.md index 3cab1af69..4b5f56bbc 100644 --- a/rfcs/mcp.md +++ b/rfcs/mcp.md @@ -27,7 +27,7 @@ server/ README.md the number of tools, and each method without one and why _/ the runtime library, copied in as it is tool.ts the Tool type, building requests, listing tools; no imports at runtime - server.ts serving tools on an McpServer, calling them, an HTTP handler + server.ts calling tools, and createHandler, which serves them over HTTP index.ts both /tools.ts the tools of a group's methods /schemas.ts the schema of each model used in arguments, placed in its group @@ -172,7 +172,7 @@ They are for the server to check what the API returns against what the spec says 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 }`, and `serveTools` and `callTool` never send them or `structuredContent`. 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. +`listTools` leaves output schemas out unless called with `{ outputSchema: true }`, and `callTool` and `createHandler` never send them or `structuredContent`. 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 @@ -181,9 +181,8 @@ It is often not an object: a `$ref`, an `allOf`, an array or a scalar. MCP `2026 - `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. - `toolsByName(tools)` indexes them, once per array. - `buildRequest(tool, args, baseUrl)` is described in [The request](#the-request). -- `serveTools(server, tools, call, extra)` serves the tools on an `McpServer` with one `tools/list` and one `tools/call` handler, rather than registering each tool, which for thousands of tools costs more than the request. It declares `tools.listChanged: false`. `extra` lists tools the server serves itself, first. - `callTool(tool, args, context)` builds the request, adds the context's headers (such as `Authorization`), sends it, and returns the body as text: JSON pretty-printed, text as it is, anything else as a sentence giving its size and type. A non-2xx response is a result with `isError: true`, starting with the status, so the model reads what the API said. An `InvalidArguments` is an error result too. -- `createHandler(options)` returns a `fetch` that serves the tools over Streamable HTTP, with a fresh stateless server for each request. `authorize(request)` returns the headers the API requests send, or a `Response` that refuses the request. +- `createHandler(options)` returns a `fetch` that serves the tools over Streamable HTTP, with a fresh stateless server for each request. Each server answers `tools/list` and `tools/call` with one handler each, rather than registering every tool, which for thousands of tools would cost more than the request. It declares `tools.listChanged: false`. `authorize(request)` returns the headers the API requests send, or a `Response` that refuses the request. Servers with their own policy, such as making a parameter optional or filling it from the session, build on `listTools`, `toolsByName` and `buildRequest` and leave the rest. diff --git a/src/bin/forge/mcp/lib/src/index.ts b/src/bin/forge/mcp/lib/src/index.ts index c4524d8c2..f83b2bd04 100644 --- a/src/bin/forge/mcp/lib/src/index.ts +++ b/src/bin/forge/mcp/lib/src/index.ts @@ -14,4 +14,4 @@ export { type ToolAnnotations, type ToolResponses, } from './tool.js'; -export { callTool, createHandler, readBody, serveTools, type CallContext, type HandlerOptions } from './server.js'; +export { callTool, createHandler, readBody, type CallContext, type HandlerOptions } from './server.js'; diff --git a/src/bin/forge/mcp/lib/src/server.ts b/src/bin/forge/mcp/lib/src/server.ts index 50cb7ba58..e01c57304 100644 --- a/src/bin/forge/mcp/lib/src/server.ts +++ b/src/bin/forge/mcp/lib/src/server.ts @@ -25,16 +25,15 @@ export interface CallContext { /** * Serves `tools` on `server` with low-level `tools/list` and `tools/call` * handlers, rather than registering each tool, which for thousands of tools - * costs more than the request. `call` makes the API request, and `extra` - * tools the server serves itself are listed first. + * costs more than the request, since each request gets a fresh server. + * `call` makes the API request. */ -export function serveTools( +function serveTools( server: McpServer, tools: readonly Tool[], call: (tool: Tool, args: Args) => Promise, - extra: readonly McpTool[] = [], ): void { - const listing = extra.length > 0 ? [...extra, ...listTools(tools)] : listTools(tools); + const listing = listTools(tools); const byName = toolsByName(tools); server.server.registerCapabilities({ tools: { listChanged: false } }); // The SDK types the list as mutable; it only serializes it. From e4d9a060d4d372d5544b7c21a1c840b13cc9a046 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 18:52:27 +0000 Subject: [PATCH 07/18] feat(mcp): generate the server as a Worker module worker.ts default-exports createHandler with every tool. It calls the API with the MCP request's Authorization header and refuses requests from another site's Origin, so the generated server deploys as a Worker with no code of its own. --- rfcs/mcp.md | 19 ++++++++++++- src/bin/forge/mcp.rs | 65 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/rfcs/mcp.md b/rfcs/mcp.md index 4b5f56bbc..87d5d9c87 100644 --- a/rfcs/mcp.md +++ b/rfcs/mcp.md @@ -2,7 +2,13 @@ `forge openapi.json --target mcp --out server` generates 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, what it generates, and what a server built on it does. It describes the generator as it is. -The server targets the official MCP TypeScript SDK, `@modelcontextprotocol/server`, and the `2026-07-28` revision of the protocol, which also serves `2025` clients. +The server targets the official MCP TypeScript SDK, `@modelcontextprotocol/server`, and the `2026-07-28` revision of the protocol, which also serves `2025` clients. `worker.ts` is the whole server as a Worker module, so with the SDK installed it deploys as it is: + +```sh +forge openapi.json --target mcp --out server +npm install @modelcontextprotocol/server +npx wrangler deploy server/worker.ts --name my-api-mcp --compatibility-date 2026-06-17 +``` ## Contents @@ -16,6 +22,7 @@ The server targets the official MCP TypeScript SDK, `@modelcontextprotocol/serve - [Extensions](#extensions) - [Response schemas](#response-schemas) - [Output schemas](#output-schemas) +- [The Worker](#the-worker) - [The runtime](#the-runtime) - [What clients see](#what-clients-see) @@ -23,6 +30,7 @@ The server targets the official MCP TypeScript SDK, `@modelcontextprotocol/serve ```text server/ + worker.ts the server as a Worker module index.ts every tool, the API's base URL and version, and the runtime's tool half README.md the number of tools, and each method without one and why _/ the runtime library, copied in as it is @@ -174,6 +182,15 @@ It is often not an object: a `$ref`, an `allOf`, an array or a scalar. MCP `2026 `listTools` leaves output schemas out unless called with `{ outputSchema: true }`, and `callTool` and `createHandler` never send them or `structuredContent`. 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 Worker + +`worker.ts` default-exports `createHandler(...)` with every tool, the API's base URL, and the API's title and version as the server's name and version. It is a Worker module and needs no bindings, configuration or compatibility flags. It serves MCP at every path: + +- Each tool call sends the MCP request's `Authorization` header to the API, so a client connects with its own API credential, and the Worker holds none. A request without one calls the API without one. +- A request with an `Origin` header that isn't the Worker's own origin gets `403`. MCP requires servers to check `Origin`. MCP clients don't send one, so this only stops a web page on another site from calling the Worker from its visitors' browsers. + +A server that needs more, such as OAuth or a credential of its own, writes its own module around `createHandler` or the pieces below. + ## The runtime `_/` is copied into every server. It is small enough to read: diff --git a/src/bin/forge/mcp.rs b/src/bin/forge/mcp.rs index 16347f014..c140dca60 100644 --- a/src/bin/forge/mcp.rs +++ b/src/bin/forge/mcp.rs @@ -12,7 +12,9 @@ //! a tool and why, and the runtime library in `_/`, which serves the tools //! on an `McpServer` and sends their requests. `index.ts` re-exports only //! the half of the runtime that doesn't import the MCP SDK, `_/tool.ts`, so -//! a server can load the tools as a module of their own. +//! a server can load the tools as a module of their own. `worker.ts` is the +//! whole server as a Worker module, which calls the API with the caller's +//! `Authorization` header. //! //! 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 @@ -73,6 +75,10 @@ impl Plugin for Generator { 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: "worker.ts".to_owned(), + content: worker(forge), + }); files.push(File::Text { path: "README.md".to_owned(), content: readme(&groups, &skipped), @@ -747,6 +753,42 @@ fn index(forge: &Forge, groups: &[(Group<'_>, Vec>)]) -> ts::File { f } +/// Returns the `worker.ts` of the root: a Worker module whose default export +/// serves every tool over Streamable HTTP, at any path, and calls the API with +/// the `Authorization` header of the MCP request. It refuses a request whose +/// `Origin` is another site's, as MCP requires of servers. The server is named +/// after the API's title. +fn worker(forge: &Forge) -> String { + let title = forge.description.split("\n\n").next().unwrap_or("").trim(); + let name = if title.is_empty() { "mcp" } else { title }; + format!( + r#"{PREAMBLE}import {{ createHandler }} from './_/server.js'; +import {{ api, tools }} from './index.js'; + +/** + * The server as a Worker: MCP over Streamable HTTP, at any path. Each tool + * call sends the MCP request's `Authorization` header to the API, so a client + * authenticates with its own API credential. A request from a web page on + * another site is refused. + */ +export default createHandler({{ + tools, + baseUrl: api.baseUrl, + serverInfo: {{ name: {name}, version: api.version }}, + authorize(request) {{ + const origin = request.headers.get('Origin'); + if (origin !== null && origin !== new URL(request.url).origin) {{ + return new Response('Forbidden', {{ status: 403 }}); + }} + const authorization = request.headers.get('Authorization'); + return authorization ? {{ Authorization: authorization }} : undefined; + }}, +}}); +"#, + name = string(name) + ) +} + /// 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 { @@ -754,7 +796,7 @@ fn readme(groups: &[(Group<'_>, Vec>)], skipped: &[(Method<'_>, String) 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 builds their requests; `_/server.ts` serves them on an `McpServer`.", + "{count} tools in {} groups. `index.ts` exports them as `tools`, with the runtime in `_/tool.ts` that builds their requests. `worker.ts` serves them as a Worker, calling the API with the caller's `Authorization` header.", groups.len() ); if !skipped.is_empty() { @@ -911,7 +953,8 @@ mod tests { "index.ts", "pets/outputs.ts", "pets/schemas.ts", - "pets/tools.ts" + "pets/tools.ts", + "worker.ts" ] ); } @@ -928,6 +971,22 @@ mod tests { assert!(index.contains("export * from './_/tool.js';"), "{index}"); } + #[test] + fn worker() { + let worker = file(&generate(PETS), "worker.ts"); + assert!( + worker.contains("import { createHandler } from './_/server.js';"), + "{worker}" + ); + assert!(worker.contains("export default createHandler({"), "{worker}"); + assert!( + worker.contains(r#"serverInfo: { name: "Pets", version: api.version },"#), + "{worker}" + ); + assert!(worker.contains("request.headers.get('Authorization')"), "{worker}"); + assert!(worker.contains("origin !== new URL(request.url).origin"), "{worker}"); + } + #[test] fn parameters() { let tools = file(&generate(PETS), "pets/tools.ts"); From dc86ee0992faf9b83d0bfa7b49bf2c4a1595f87a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 19:00:57 +0000 Subject: [PATCH 08/18] refactor(mcp): generate tools, not a server Every server around the tools differs in how it authenticates and what it serves alongside them, so the runtime keeps only what every server needs: the Tool type, listTools, toolsByName and buildRequest, in _/tool.ts. worker.ts, createHandler, serveTools and callTool are gone, and nothing imports the MCP SDK at runtime. buildRequest returns the request as plain data, { method, url, headers, body? }, which crosses RPC as it is and fetch(request.url, request) sends. --- rfcs/mcp.md | 56 ++++---- src/bin/forge/main.rs | 10 +- src/bin/forge/mcp.rs | 93 ++----------- src/bin/forge/mcp/lib/package.json | 2 +- src/bin/forge/mcp/lib/src/index.ts | 17 --- src/bin/forge/mcp/lib/src/server.ts | 152 ---------------------- src/bin/forge/mcp/lib/src/tool.ts | 25 +++- src/bin/forge/mcp/lib/test/fixtures.ts | 2 +- src/bin/forge/mcp/lib/test/server.test.ts | 102 --------------- src/bin/forge/mcp/lib/test/tool.test.ts | 23 +++- 10 files changed, 80 insertions(+), 402 deletions(-) delete mode 100644 src/bin/forge/mcp/lib/src/index.ts delete mode 100644 src/bin/forge/mcp/lib/src/server.ts delete mode 100644 src/bin/forge/mcp/lib/test/server.test.ts diff --git a/rfcs/mcp.md b/rfcs/mcp.md index 87d5d9c87..403f727ba 100644 --- a/rfcs/mcp.md +++ b/rfcs/mcp.md @@ -1,13 +1,19 @@ # The MCP target -`forge openapi.json --target mcp --out server` generates 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, what it generates, and what a server built on it does. It describes the generator as it is. +`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. -The server targets the official MCP TypeScript SDK, `@modelcontextprotocol/server`, and the `2026-07-28` revision of the protocol, which also serves `2025` clients. `worker.ts` is the whole server as a Worker module, so with the SDK installed it deploys 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`. -```sh -forge openapi.json --target mcp --out server -npm install @modelcontextprotocol/server -npx wrangler deploy server/worker.ts --name my-api-mcp --compatibility-date 2026-06-17 +```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 @@ -22,21 +28,16 @@ npx wrangler deploy server/worker.ts --name my-api-mcp --compatibility-date 2026 - [Extensions](#extensions) - [Response schemas](#response-schemas) - [Output schemas](#output-schemas) -- [The Worker](#the-worker) - [The runtime](#the-runtime) - [What clients see](#what-clients-see) ## What it generates ```text -server/ - worker.ts the server as a Worker module - index.ts every tool, the API's base URL and version, and the runtime's tool half +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 - _/ the runtime library, copied in as it is - tool.ts the Tool type, building requests, listing tools; no imports at runtime - server.ts calling tools, and createHandler, which serves them over HTTP - index.ts both + _/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 @@ -60,7 +61,7 @@ Each `tools.ts` exports `tools`, an array of the group's tools sorted by name, a } ``` -`index.ts` re-exports only `_/tool.ts`, which imports nothing at runtime. A server can load the tools without the MCP SDK, for example in a process that only plans requests, and import `_/server.ts` where it serves them. +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 @@ -108,7 +109,7 @@ A parameter whose snake_case name is empty takes its wire name. When two paramet - `headers`: each header's wire name and value. - `body`: the value and its media type. -`buildRequest(tool, args, baseUrl)` turns that into a `Request`. It checks the required arguments are present, then: +`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 `,`; @@ -180,28 +181,17 @@ They are for the server to check what the API returns against what the spec says 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 }`, and `callTool` and `createHandler` never send them or `structuredContent`. 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 Worker - -`worker.ts` default-exports `createHandler(...)` with every tool, the API's base URL, and the API's title and version as the server's name and version. It is a Worker module and needs no bindings, configuration or compatibility flags. It serves MCP at every path: - -- Each tool call sends the MCP request's `Authorization` header to the API, so a client connects with its own API credential, and the Worker holds none. A request without one calls the API without one. -- A request with an `Origin` header that isn't the Worker's own origin gets `403`. MCP requires servers to check `Origin`. MCP clients don't send one, so this only stops a web page on another site from calling the Worker from its visitors' browsers. - -A server that needs more, such as OAuth or a credential of its own, writes its own module around `createHandler` or the pieces below. +`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 -`_/` is copied into every server. It is small enough to read: +`_/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. -- `toolsByName(tools)` indexes them, once per array. -- `buildRequest(tool, args, baseUrl)` is described in [The request](#the-request). -- `callTool(tool, args, context)` builds the request, adds the context's headers (such as `Authorization`), sends it, and returns the body as text: JSON pretty-printed, text as it is, anything else as a sentence giving its size and type. A non-2xx response is a result with `isError: true`, starting with the status, so the model reads what the API said. An `InvalidArguments` is an error result too. -- `createHandler(options)` returns a `fetch` that serves the tools over Streamable HTTP, with a fresh stateless server for each request. Each server answers `tools/list` and `tools/call` with one handler each, rather than registering every tool, which for thousands of tools would cost more than the request. It declares `tools.listChanged: false`. `authorize(request)` returns the headers the API requests send, or a `Response` that refuses the request. +- `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. -Servers with their own policy, such as making a parameter optional or filling it from the session, build on `listTools`, `toolsByName` and `buildRequest` and leave the rest. +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 diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index c59eb19b4..c0d822076 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -2,7 +2,7 @@ //! //! ```text //! forge openapi.json --target ts --out sdk -//! forge openapi.json --target mcp --out server +//! forge openapi.json --target mcp --out tools //! forge openapi.json --target wasm://plugin.wasm --out sdk //! ``` @@ -23,9 +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, `mcp` for an MCP - /// server in 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. @@ -38,7 +38,7 @@ struct Args { enum Target { /// The TypeScript generator. Ts, - /// The MCP server generator, which generates TypeScript. + /// 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")] diff --git a/src/bin/forge/mcp.rs b/src/bin/forge/mcp.rs index c140dca60..548568a10 100644 --- a/src/bin/forge/mcp.rs +++ b/src/bin/forge/mcp.rs @@ -1,5 +1,7 @@ -//! The MCP server generator. It generates TypeScript for the official MCP -//! TypeScript SDK, `@modelcontextprotocol/server`. +//! 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 @@ -7,14 +9,11 @@ //! 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, a `README.md` that lists the methods without -//! a tool and why, and the runtime library in `_/`, which serves the tools -//! on an `McpServer` and sends their requests. `index.ts` re-exports only -//! the half of the runtime that doesn't import the MCP SDK, `_/tool.ts`, so -//! a server can load the tools as a module of their own. `worker.ts` is the -//! whole server as a Worker module, which calls the API with the caller's -//! `Authorization` header. +//! `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 @@ -75,10 +74,6 @@ impl Plugin for Generator { 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: "worker.ts".to_owned(), - content: worker(forge), - }); files.push(File::Text { path: "README.md".to_owned(), content: readme(&groups, &skipped), @@ -119,15 +114,11 @@ impl forge::File for File { const RUNTIME_DIR: Module = Module::new("_"); /// The module of the runtime library that the generated files import: the -/// tool type and request helpers, which don't import the MCP SDK. +/// 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); 3] = [ - ("index.ts", include_str!("mcp/lib/src/index.ts")), - ("server.ts", include_str!("mcp/lib/src/server.ts")), - ("tool.ts", include_str!("mcp/lib/src/tool.ts")), -]; +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; @@ -723,9 +714,6 @@ fn tools_file( 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(); - // Only the half of the runtime without dependencies, so that importing the - // tools doesn't import the MCP SDK. The other half, which serves them, is - // in `./_/server.js`. p!(f, "export * from './_/tool.js';"); p!(f, ""); f.doc("The API the tools call."); @@ -753,42 +741,6 @@ fn index(forge: &Forge, groups: &[(Group<'_>, Vec>)]) -> ts::File { f } -/// Returns the `worker.ts` of the root: a Worker module whose default export -/// serves every tool over Streamable HTTP, at any path, and calls the API with -/// the `Authorization` header of the MCP request. It refuses a request whose -/// `Origin` is another site's, as MCP requires of servers. The server is named -/// after the API's title. -fn worker(forge: &Forge) -> String { - let title = forge.description.split("\n\n").next().unwrap_or("").trim(); - let name = if title.is_empty() { "mcp" } else { title }; - format!( - r#"{PREAMBLE}import {{ createHandler }} from './_/server.js'; -import {{ api, tools }} from './index.js'; - -/** - * The server as a Worker: MCP over Streamable HTTP, at any path. Each tool - * call sends the MCP request's `Authorization` header to the API, so a client - * authenticates with its own API credential. A request from a web page on - * another site is refused. - */ -export default createHandler({{ - tools, - baseUrl: api.baseUrl, - serverInfo: {{ name: {name}, version: api.version }}, - authorize(request) {{ - const origin = request.headers.get('Origin'); - if (origin !== null && origin !== new URL(request.url).origin) {{ - return new Response('Forbidden', {{ status: 403 }}); - }} - const authorization = request.headers.get('Authorization'); - return authorization ? {{ Authorization: authorization }} : undefined; - }}, -}}); -"#, - name = string(name) - ) -} - /// 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 { @@ -796,7 +748,7 @@ fn readme(groups: &[(Group<'_>, Vec>)], skipped: &[(Method<'_>, String) 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 builds their requests. `worker.ts` serves them as a Worker, calling the API with the caller's `Authorization` header.", + "{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() { @@ -947,14 +899,11 @@ mod tests { paths, [ "README.md", - "_/index.ts", - "_/server.ts", "_/tool.ts", "index.ts", "pets/outputs.ts", "pets/schemas.ts", - "pets/tools.ts", - "worker.ts" + "pets/tools.ts" ] ); } @@ -971,22 +920,6 @@ mod tests { assert!(index.contains("export * from './_/tool.js';"), "{index}"); } - #[test] - fn worker() { - let worker = file(&generate(PETS), "worker.ts"); - assert!( - worker.contains("import { createHandler } from './_/server.js';"), - "{worker}" - ); - assert!(worker.contains("export default createHandler({"), "{worker}"); - assert!( - worker.contains(r#"serverInfo: { name: "Pets", version: api.version },"#), - "{worker}" - ); - assert!(worker.contains("request.headers.get('Authorization')"), "{worker}"); - assert!(worker.contains("origin !== new URL(request.url).origin"), "{worker}"); - } - #[test] fn parameters() { let tools = file(&generate(PETS), "pets/tools.ts"); diff --git a/src/bin/forge/mcp/lib/package.json b/src/bin/forge/mcp/lib/package.json index b34c966c6..cc219bc65 100644 --- a/src/bin/forge/mcp/lib/package.json +++ b/src/bin/forge/mcp/lib/package.json @@ -5,7 +5,7 @@ "description": "The runtime that the generated MCP servers import.", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/tool.ts" }, "scripts": { "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json", diff --git a/src/bin/forge/mcp/lib/src/index.ts b/src/bin/forge/mcp/lib/src/index.ts deleted file mode 100644 index f83b2bd04..000000000 --- a/src/bin/forge/mcp/lib/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - buildRequest, - InvalidArguments, - listTools, - pathParam, - toolsByName, - type ApiRequest, - type Args, - type HeaderParam, - type InputSchema, - type JsonSchema, - type QueryParam, - type Tool, - type ToolAnnotations, - type ToolResponses, -} from './tool.js'; -export { callTool, createHandler, readBody, type CallContext, type HandlerOptions } from './server.js'; diff --git a/src/bin/forge/mcp/lib/src/server.ts b/src/bin/forge/mcp/lib/src/server.ts deleted file mode 100644 index e01c57304..000000000 --- a/src/bin/forge/mcp/lib/src/server.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { - createMcpHandler, - McpServer, - type CallToolResult, - type Implementation, - type Tool as McpTool, -} from '@modelcontextprotocol/server'; - -import { buildRequest, InvalidArguments, listTools, toolsByName, type Args, type Tool } from './tool.js'; - -/** What a tool call needs besides the tool and its arguments. */ -export interface CallContext { - /** The API's base URL. */ - readonly baseUrl: string; - /** Headers every API request sends, such as `Authorization`. */ - readonly headers?: HeadersInit; - readonly fetch?: (request: Request) => Promise; - /** - * Returns the text of the result. By default a JSON body is pretty-printed, - * text is kept, and an error starts with the status. - */ - readonly formatResult?: (body: unknown, response: Response) => string; -} - -/** - * Serves `tools` on `server` with low-level `tools/list` and `tools/call` - * handlers, rather than registering each tool, which for thousands of tools - * costs more than the request, since each request gets a fresh server. - * `call` makes the API request. - */ -function serveTools( - server: McpServer, - tools: readonly Tool[], - call: (tool: Tool, args: Args) => Promise, -): void { - const listing = listTools(tools); - const byName = toolsByName(tools); - server.server.registerCapabilities({ tools: { listChanged: false } }); - // The SDK types the list as mutable; it only serializes it. - server.server.setRequestHandler('tools/list', () => ({ tools: listing as McpTool[] })); - server.server.setRequestHandler('tools/call', async (request) => { - const tool = byName.get(request.params.name); - if (!tool) { - return errorResult(`Unknown tool: ${request.params.name}`); - } - return call(tool, request.params.arguments ?? {}); - }); -} - -/** - * Calls `tool`: builds its request, sends it, and returns the response as the - * result. A response that isn't 2xx is a result with `isError: true` rather - * than a protocol error, so the model sees what the API said. - */ -export async function callTool(tool: Tool, args: Args, context: CallContext): Promise { - let request: Request; - try { - request = buildRequest(tool, args, context.baseUrl); - } catch (err) { - if (err instanceof InvalidArguments) { - return errorResult(err.message); - } - throw err; - } - for (const [name, value] of new Headers(context.headers)) { - request.headers.set(name, value); - } - const response = await (context.fetch ?? fetch)(request); - const body = await readBody(response); - const text = (context.formatResult ?? defaultFormat)(body, response); - return response.ok ? { content: [{ type: 'text', text }] } : errorResult(text); -} - -/** - * Returns the body of `response`: parsed JSON, text, or for anything else a - * sentence saying what it was, since a tool result can't carry the bytes. - */ -export async function readBody(response: Response): Promise { - const type = response.headers.get('Content-Type') ?? ''; - if (/json/i.test(type)) { - const text = await response.text(); - try { - return text ? JSON.parse(text) : text; - } catch { - return text; - } - } - if (type === '' || /^text\/|xml|javascript|yaml|csv/i.test(type)) { - return response.text(); - } - const size = (await response.arrayBuffer()).byteLength; - return `The response is ${size} bytes of ${type}, which this tool can't show.`; -} - -function defaultFormat(body: unknown, response: Response): string { - const text = typeof body === 'string' ? body : JSON.stringify(body, null, 2); - return response.ok ? text : `${response.status} ${response.statusText}\n${text}`.trim(); -} - -function errorResult(text: string): CallToolResult { - return { content: [{ type: 'text', text }], isError: true }; -} - -/** What {@link createHandler} serves. */ -export interface HandlerOptions { - readonly tools: readonly Tool[]; - /** The API's base URL. */ - readonly baseUrl: string; - readonly serverInfo: Implementation; - readonly instructions?: string; - /** - * Called before each MCP request is served. Returns the headers its API - * requests send, such as the caller's `Authorization`, or a response that - * refuses it. - */ - readonly authorize?: ( - request: Request, - ) => HeadersInit | Response | undefined | Promise; - readonly fetch?: (request: Request) => Promise; - readonly formatResult?: CallContext['formatResult']; -} - -/** - * Returns a `fetch` that serves `tools` over MCP's Streamable HTTP transport: - * for a Worker, or any server with web-standard requests. Each request gets a - * fresh, stateless server. - */ -export function createHandler(options: HandlerOptions): { fetch(request: Request): Promise } { - return { - async fetch(request) { - const authorized = options.authorize ? await options.authorize(request) : undefined; - if (authorized instanceof Response) { - return authorized; - } - const context: CallContext = { - baseUrl: options.baseUrl, - ...(authorized && { headers: authorized }), - ...(options.fetch && { fetch: options.fetch }), - ...(options.formatResult && { formatResult: options.formatResult }), - }; - const handler = createMcpHandler(() => { - const server = new McpServer( - options.serverInfo, - options.instructions ? { instructions: options.instructions } : {}, - ); - serveTools(server, options.tools, (tool, args) => callTool(tool, args, context)); - return server; - }); - return handler.fetch(request); - }, - }; -} diff --git a/src/bin/forge/mcp/lib/src/tool.ts b/src/bin/forge/mcp/lib/src/tool.ts index 58af4cfbf..d02478c4d 100644 --- a/src/bin/forge/mcp/lib/src/tool.ts +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -43,6 +43,17 @@ export type QueryParam = readonly [name: string, value: unknown, explode: boolea /** 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 @@ -109,8 +120,9 @@ export function pathParam(args: Args, key: string): string { * 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): Request { +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(', ')}`); @@ -126,12 +138,17 @@ export function buildRequest(tool: Tool, args: Args, baseUrl: string): Request { headers.set(name, Array.isArray(value) ? value.map((item) => scalar(name, item)).join(',') : scalar(name, value)); } } - const init: RequestInit = { method: tool.method, headers }; + let body: string | undefined; if (api.body && api.body.value !== undefined) { headers.set('Content-Type', api.body.mediaType); - init.body = JSON.stringify(api.body.value); + body = JSON.stringify(api.body.value); } - return new Request(url, init); + return { + method: tool.method, + url: url.href, + headers: Object.fromEntries(headers), + ...(body !== undefined && { body }), + }; } /** Appends a query parameter in the `form` style: `a=1&a=2` exploded, `a=1,2` not. */ diff --git a/src/bin/forge/mcp/lib/test/fixtures.ts b/src/bin/forge/mcp/lib/test/fixtures.ts index b03e013db..f8873461b 100644 --- a/src/bin/forge/mcp/lib/test/fixtures.ts +++ b/src/bin/forge/mcp/lib/test/fixtures.ts @@ -1,4 +1,4 @@ -import { pathParam, type Tool } from '../src/index.js'; +import { pathParam, type Tool } from '../src/tool.js'; /** Tools as the generator writes them. */ export const tools: readonly Tool[] = [ diff --git a/src/bin/forge/mcp/lib/test/server.test.ts b/src/bin/forge/mcp/lib/test/server.test.ts deleted file mode 100644 index 6db531c2f..000000000 --- a/src/bin/forge/mcp/lib/test/server.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; - -import { createHandler, readBody } from '../src/index.js'; -import { tools } from './fixtures.js'; - -/** Posts one JSON-RPC request to `handler` the way a 2026-07-28 client does, and returns its result. */ -async function rpc( - handler: ReturnType, - method: string, - params: { name?: string; arguments?: object } = {}, -) { - const response = await handler.fetch( - new Request('http://localhost/mcp', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'MCP-Protocol-Version': '2026-07-28', - 'Mcp-Method': method, - ...(params.name && { 'Mcp-Name': params.name }), - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method, - params: { - ...params, - _meta: { - 'io.modelcontextprotocol/protocolVersion': '2026-07-28', - 'io.modelcontextprotocol/clientInfo': { name: 'test', version: '1.0.0' }, - 'io.modelcontextprotocol/clientCapabilities': {}, - }, - }, - }), - }), - ); - const text = await response.text(); - const json = text.startsWith('{') - ? text - : text - .split('\n') - .find((line) => line.startsWith('data:'))! - .slice(5); - return JSON.parse(json) as { result?: any; error?: any }; -} - -function handler(fetch: (request: Request) => Promise) { - return createHandler({ - tools, - baseUrl: 'https://api.example.com', - serverInfo: { name: 'pets', version: '1.0.0' }, - authorize: (request) => - request.headers.get('X-Refuse') ? new Response('no', { status: 401 }) : { Authorization: 'Bearer t' }, - fetch, - }); -} - -test('lists the tools', async () => { - const { result } = await rpc(handler(fetch), 'tools/list'); - assert.deepEqual( - result.tools.map((tool: { name: string }) => tool.name), - ['pets_list', 'pets_create'], - ); - assert.equal(result.tools[0].path, undefined); -}); - -test('calls a tool with the authorized headers', async () => { - let sent: Request | undefined; - const { result } = await rpc( - handler(async (request) => { - sent = request; - return Response.json({ pets: ['Rex'] }); - }), - 'tools/call', - { name: 'pets_list', arguments: { store_id: 's1' } }, - ); - assert.equal(sent?.url, 'https://api.example.com/stores/s1/pets'); - assert.equal(sent?.headers.get('Authorization'), 'Bearer t'); - assert.equal(result.isError, undefined); - assert.deepEqual(JSON.parse(result.content[0].text), { pets: ['Rex'] }); -}); - -test('returns API errors and bad arguments as tool errors', async () => { - const api = handler(async () => Response.json({ error: 'nope' }, { status: 403, statusText: 'Forbidden' })); - const refused = await rpc(api, 'tools/call', { name: 'pets_list', arguments: { store_id: 's1' } }); - assert.equal(refused.result.isError, true); - assert.match(refused.result.content[0].text, /^403 Forbidden\n/); - - const missing = await rpc(api, 'tools/call', { name: 'pets_create', arguments: { store_id: 's1' } }); - assert.deepEqual(missing.result.content, [{ type: 'text', text: 'Missing required arguments: body' }]); - assert.equal(missing.result.isError, true); - - const unknown = await rpc(api, 'tools/call', { name: 'nope', arguments: {} }); - assert.equal(unknown.result.content[0].text, 'Unknown tool: nope'); -}); - -test('describes a body it cannot show', async () => { - const body = await readBody(new Response(new Uint8Array(3), { headers: { 'Content-Type': 'image/png' } })); - assert.equal(body, "The response is 3 bytes of image/png, which this tool can't show."); - assert.equal(await readBody(new Response('a,b', { headers: { 'Content-Type': 'text/csv' } })), 'a,b'); -}); diff --git a/src/bin/forge/mcp/lib/test/tool.test.ts b/src/bin/forge/mcp/lib/test/tool.test.ts index 88acb7f6e..bc948113b 100644 --- a/src/bin/forge/mcp/lib/test/tool.test.ts +++ b/src/bin/forge/mcp/lib/test/tool.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { buildRequest, InvalidArguments, listTools, toolsByName } from '../src/index.js'; +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]]; @@ -14,21 +14,30 @@ test('builds the path, query and headers', () => { ); 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.equal(request.headers.get('X-Trace'), '7'); - assert.equal(request.headers.get('Accept'), 'application/json'); + assert.deepEqual(request.headers, { accept: 'application/json', 'x-trace': '7' }); + assert.equal('body' in request, false); }); 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(request.headers.has('X-Trace'), false); + assert.equal('x-trace' in request.headers, false); }); -test('sends the body as JSON', async () => { +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.get('Content-Type'), 'application/json'); - assert.deepEqual(await request.json(), { name: 'Rex' }); + 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', () => { From 6de519b55a32581a7905dc358f4b256904c2d2c1 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 19:09:53 +0000 Subject: [PATCH 09/18] fix(mcp): trim the base URL's trailing slashes without a regular expression CodeQL flagged /\/+$/ as polynomial on many slashes. --- src/bin/forge/mcp/lib/src/tool.ts | 14 +++++++++++++- src/bin/forge/mcp/lib/test/tool.test.ts | 5 +++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/bin/forge/mcp/lib/src/tool.ts b/src/bin/forge/mcp/lib/src/tool.ts index d02478c4d..d2c194b34 100644 --- a/src/bin/forge/mcp/lib/src/tool.ts +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -128,7 +128,7 @@ export function buildRequest(tool: Tool, args: Args, baseUrl: string): HttpReque throw new InvalidArguments(`Missing required arguments: ${missing.join(', ')}`); } const api = tool.request(args); - const url = new URL(baseUrl.replace(/\/+$/, '') + api.path); + const url = new URL(withoutTrailingSlashes(baseUrl) + api.path); for (const [name, value, explode] of api.query ?? []) { appendQuery(url.searchParams, name, value, explode); } @@ -151,6 +151,18 @@ export function buildRequest(tool: Tool, args: Args, baseUrl: string): HttpReque }; } +/** + * 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) { diff --git a/src/bin/forge/mcp/lib/test/tool.test.ts b/src/bin/forge/mcp/lib/test/tool.test.ts index bc948113b..c26d5bd75 100644 --- a/src/bin/forge/mcp/lib/test/tool.test.ts +++ b/src/bin/forge/mcp/lib/test/tool.test.ts @@ -18,6 +18,11 @@ test('builds the path, query and headers', () => { 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'); From 580804fccf9c3f04d0449d2bc22506a2f2a8e419 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 28 Oct 2026 09:57:00 +0000 Subject: [PATCH 10/18] docs(mcp): answer review on names and path parameters Show which names collide, name tools after forge's groups rather than the extensions forge reads them from, and drop the path parameter row: forge gives every placeholder a parameter. --- rfcs/mcp.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/rfcs/mcp.md b/rfcs/mcp.md index 403f727ba..4e541d47a 100644 --- a/rfcs/mcp.md +++ b/rfcs/mcp.md @@ -72,9 +72,8 @@ Every method forge reads gets a tool, except: | Deprecated (`deprecated: true`) | Left out silently. | | A required request body with no JSON media type (`multipart/form-data`, `application/octet-stream`, `text/plain`, …) | A tool's arguments are JSON. An optional non-JSON body is left off the tool, and the method still gets one. | | A tool name over 128 characters | MCP's limit. | -| A tool name another method already has | Names must be unique across the server; the first method keeps it. | +| 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. | -| A path parameter with no argument | The tool couldn't fill the path. | 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. @@ -87,7 +86,7 @@ A tool is named after its groups and its method, each in snake_case, joined by ` | `dns.records` | `create` | `dns_records_create` | | `accounts.members` | `list` | `accounts_members_list` | -The name comes from `x-fern-sdk-group-name` and `x-fern-sdk-method-name`, as for every target, so it is stable while those are. Renaming a group renames its tools. +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 From 8bd67ce7c57f14c2ac3e57645c9d5ee15cb80454 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 30 Oct 2026 13:06:33 +0000 Subject: [PATCH 11/18] refactor(mcp): generate each group on its own, camelCase names, arguments by place Tool names join each group and the method in camelCase with `_`, so `_` only separates names and tools of different groups can't collide: `webAssets_discovery_operations_bulkEdit`. Two methods of a group with the same camelCase spelling (`x1b`, `x_1b`) keep the first by name, and README.md lists the other. Each group's tools.ts, schemas.ts and outputs.ts now come from generate_group, in parallel. A model's $defs key is its schema name with bytes outside [A-Za-z0-9_-] written as `.XX`, and its constant is its PascalCase name, so neither depends on another model. The root's index.ts and README.md come from one pass that only checks which methods get a tool. Arguments are grouped by where they go: `path`, `query` and `headers`, each keyed by wire name, next to `body`. Names can't clash, so the `query_`/`header_` prefixes and `request_body` are gone. The runtime reads them with `param` and `pathParam`, and buildRequest reports missing required parameters such as `query.page`. Generating the Cloudflare API takes 0.57s instead of 0.78s, with the same 3,041 tools and response schemas. --- rfcs/mcp.md | 53 +- src/bin/forge/mcp.rs | 764 +++++++++++++++--------- src/bin/forge/mcp/lib/src/tool.ts | 58 +- src/bin/forge/mcp/lib/test/fixtures.ts | 25 +- src/bin/forge/mcp/lib/test/tool.test.ts | 44 +- src/bin/forge/mcp/schema.rs | 168 +++--- 6 files changed, 691 insertions(+), 421 deletions(-) diff --git a/rfcs/mcp.md b/rfcs/mcp.md index 4e541d47a..cdb4ae67a 100644 --- a/rfcs/mcp.md +++ b/rfcs/mcp.md @@ -38,19 +38,21 @@ 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 used in arguments, placed in its group - /outputs.ts the schema of each model used in responses + /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 tool is plain data plus one function: +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: { … }, required: [ … ], $defs: { … } }, + inputSchema: { type: 'object', properties: { path: { … }, body: { … } }, required: ['path', 'body'], $defs: { … } }, annotations: { title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint }, method: 'POST', path: '/zones/{zone_id}/dns_records', @@ -72,43 +74,52 @@ Every method forge reads gets a tool, except: | 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. | +| 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 snake_case, joined by `_`: +A tool is named after its groups and its method, each in camelCase, joined by `_`: -| Group | Method | Tool | -| ------------------ | -------- | ----------------------- | -| `dns.records` | `create` | `dns_records_create` | -| `accounts.members` | `list` | `accounts_members_list` | +| 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. Its properties are: +`inputSchema` is always an object, with an argument for each place a value goes: -- **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. +- **`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, likewise. Read-only headers are left out. +- **`body`**: the JSON body, as the API takes it, with the body's description. 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`. +A place without parameters has no argument. 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` percent-encodes it and throws `InvalidArguments` if it is missing or empty. -- `query`: each parameter's wire name, value and whether it is exploded. +- `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 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: +`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, 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 `,`; @@ -137,7 +148,7 @@ Schemas are JSON Schema 2020-12, built from forge's model of the spec rather tha 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. +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 the `schemas.ts` or `outputs.ts` of the group forge places it in, and every tool that uses it imports the constant. The constant is the model's name in PascalCase, as the TypeScript SDK names it, with `Output` after it in `outputs.ts`; two models in a group with the same name are an error, as in the SDK. 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. 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: diff --git a/src/bin/forge/mcp.rs b/src/bin/forge/mcp.rs index 548568a10..d8510ce2e 100644 --- a/src/bin/forge/mcp.rs +++ b/src/bin/forge/mcp.rs @@ -9,28 +9,33 @@ //! 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. +//! `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. //! -//! 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. +//! 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::{BTreeMap, HashMap, HashSet}; +use std::collections::{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 forge::{Availability, Err, Field, Forge, Group, HttpMethod, Location, Method, Model, Name, Plugin, Type}; use serde_json::{Map, Value, json}; mod schema; -use schema::{Refs, Schemas}; +use schema::{Direction, Refs, Schemas, reach}; /// Generates an MCP server. pub struct Generator; @@ -38,45 +43,30 @@ 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. + /// 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 schemas = Schemas::default(); - let mut outputs = Schemas::output(); - let mut names = HashSet::new(); - let mut skipped = Vec::new(); let mut groups = Vec::new(); + let mut count = 0; + let mut skipped = 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))), + let before = count; + for (m, verdict) in verdicts(group) { + match verdict { + Ok(_) => count += 1, Err(reason) => skipped.push((m, reason)), } } - if !tools.is_empty() { - tools.sort_by(|a, b| a.name.cmp(&b.name)); - groups.push((group, tools)); + if count > before { + groups.push(group); } } - 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)); + 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(&groups, &skipped), + content: readme(count, groups.len(), &skipped), }); files.extend(RUNTIME.iter().map(|&(path, source)| File::Text { path: RUNTIME_DIR.sub(path).as_str().to_owned(), @@ -84,6 +74,28 @@ impl Plugin for Generator { })); 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. @@ -123,6 +135,91 @@ const RUNTIME: [(&str, &str); 1] = [("tool.ts", include_str!("mcp/lib/src/tool.t /// 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")); + } + if let Some(b) = m.body() + && b.json().is_none() + && 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(", "))); + } + 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()), + ); + if let Some(b) = m.body() + && let Some(content) = b.json() + { + types.push(content.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>, @@ -131,66 +228,88 @@ struct Tool<'f> { 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. + defs: std::collections::BTreeMap>, + /// The path template, split into text and the parameters that fill it. 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)>, + /// 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 type of the JSON body. + 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: BTreeMap>, + 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: BTreeMap>, + output_defs: std::collections::BTreeMap>, } /// A part of a path template. enum Segment { Text(String), - /// A parameter, filled by the argument with this key. + /// A path parameter, by wire name. 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")); +/// 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 that is required if `required`. + fn add<'f>(&mut self, field: Field<'f>, required: bool, refs: &mut Refs<'f>) { + let key = field.wire_name().to_owned(); + 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)); + 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 properties = Map::new(); - let mut required = Vec::new(); - let mut keys = Keys::default(); - let mut path_keys = Vec::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()) { - 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)), + match m.location(field) { + Some(Location::Path) => path_params.add(field, true, &mut refs), + Some(Location::Query { exploded }) => { + query_params.add(field, field.is_required(), &mut refs); + query.push((field.wire_name().to_owned(), exploded)); + } + None => {} } } for field in m @@ -199,43 +318,34 @@ impl<'f> Tool<'f> { .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)); + header_params.add(field, field.is_required(), &mut refs); + headers.push(field.wire_name().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() { - 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(", "))); + if let Some(b) = m.body() + && let Some(content) = b.json() + { + let mut schema = Schemas::INPUT.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("body".to_owned(), schema); + if b.is_required() { + required.push("body".to_owned()); + } + body = Some(content.media_type().to_owned()); } - let path = segments(m.path(), &path_keys)?; + 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(); @@ -247,7 +357,7 @@ impl<'f> Tool<'f> { r.status().to_ascii_uppercase() }; let mut refs = Refs::new(); - let schema = r.json().map(|c| outputs.ty(c.ty(), &mut refs)); + 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()); @@ -257,9 +367,9 @@ impl<'f> Tool<'f> { } let output = output_schema(successes); let output_defs = if output.is_some() { - outputs.defs(&success_refs) + Schemas::OUTPUT.defs(&success_refs) } else { - BTreeMap::new() + std::collections::BTreeMap::new() }; Ok(Tool { @@ -267,13 +377,13 @@ impl<'f> Tool<'f> { name, properties: Value::Object(properties), required, - defs: schemas.defs(&refs), + defs: Schemas::INPUT.defs(&refs), path, query, headers, body, responses, - response_defs: outputs.defs(&response_refs), + response_defs: Schemas::OUTPUT.defs(&response_refs), output, output_defs, }) @@ -322,33 +432,6 @@ fn output_schema(successes: Vec>) -> Option { } } -/// 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> { @@ -362,9 +445,9 @@ fn parameters(request: Option>) -> Vec> { } } -/// 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> { +/// 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('{') { @@ -372,13 +455,13 @@ fn segments(template: &str, keys: &[(String, String)]) -> Result, S break; }; let wire = &rest[start + 1..start + len]; - let Some((_, key)) = keys.iter().find(|(name, _)| name == wire) else { + 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(key.clone())); + segments.push(Segment::Param(wire.to_owned())); rest = &rest[start + len + 1..]; } if !rest.is_empty() { @@ -388,11 +471,13 @@ fn segments(template: &str, keys: &[(String, String)]) -> Result, S } /// 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`. +/// 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 words: Vec = m.group().path().map(|name| name.snake()).collect(); - words.push(m.name().snake()); - words.join("_") + 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 @@ -435,129 +520,138 @@ fn description(m: Method<'_>) -> String { enum SchemaFile { /// Models in arguments, in `schemas.ts`. Input, - /// Models in response bodies, in `outputs.ts`, named with `_output` so a + /// 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"), +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`: +/// the model's name in PascalCase, as the TypeScript SDK names it, with +/// `Output` after it in `outputs.ts`, 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 name = Ident::model(model).name; + let name = match kind { + SchemaFile::Input => name.into_owned(), + SchemaFile::Output => format!("{name}Output"), }; - 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 dir = model.group().map_or(Module::ROOT, Module::group); + dir.sub(kind.file()).ident(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>, +) -> Result, Err> { + let placed: HashSet> = placed.collect(); + if placed.is_empty() { + return Ok(Vec::new()); } - 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('_'); + 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; } - while !taken.insert(name.clone()) { - name.push('$'); + for t in input_types(m) { + reach(t, Direction::Input, &mut inputs); } - 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; + for t in output_types(m) { + reach(t, Direction::Output, &mut outputs); } - p!(f, ""); - idents.insert(model, ident); } - files.push(f); } - (files, idents) + 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)?)); + } + Ok(files) } -/// 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 file `module` with the constant of each of `models`, which +/// are placed in its directory. Two models in a group with the same name +/// are an error, as in the TypeScript SDK, as is a model whose name has no +/// letters or digits. +fn schema_file(module: Module, models: &[Model<'_>], kind: SchemaFile) -> Result { + 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", + }; + let mut names: HashMap> = HashMap::new(); + for &model in models { + let name = model_ident(model, kind).name.into_owned(); + if model.name().is_empty() { + return Err(format!("schema {:?}: the name has no letters or digits", model.schema_name()).into()); + } + if let Some(other) = names.insert(name.clone(), model) { + return Err(format!( + "schemas {:?} and {:?} have the same name {name:?}", + other.schema_name(), + model.schema_name() + ) + .into()); + } + let ident = f.ident(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, ""); + } + Ok(f) +} /// Returns the `tools.ts` of `group`. -fn tools_file( - group: Group<'_>, - tools: &[Tool<'_>], - schemas: &HashMap, Ident>, - outputs: &HashMap, Ident>, -) -> ts::File { +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 dotted: Vec = group.path().map(|name| name.snake()).collect(); + 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| { @@ -582,7 +676,7 @@ fn tools_file( p!(f, "$defs: {"); f.indent(|f| { for (key, model) in &t.defs { - p!(f, string(key), ": ", schemas[model].clone(), ","); + p!(f, string(key), ": ", model_ident(*model, SchemaFile::Input), ","); } }); p!(f, "},"); @@ -599,7 +693,7 @@ fn tools_file( p!(f, "$defs: {"); f.indent(|f| { for (key, model) in &t.output_defs { - p!(f, string(key), ": ", outputs[model].clone(), ","); + p!(f, string(key), ": ", model_ident(*model, SchemaFile::Output), ","); } }); p!(f, "},"); @@ -630,7 +724,7 @@ fn tools_file( p!(f, "$defs: {"); f.indent(|f| { for (key, model) in &t.response_defs { - p!(f, string(key), ": ", outputs[model].clone(), ","); + p!(f, string(key), ": ", model_ident(*model, SchemaFile::Output), ","); } }); p!(f, "},"); @@ -653,10 +747,10 @@ fn tools_file( for segment in &t.path { match segment { Segment::Text(text) => parts.push(Box::new(template_text(text))), - Segment::Param(key) => { + Segment::Param(wire) => { 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(format!("(args, {})}}", string(wire)))); } } } @@ -666,14 +760,16 @@ fn tools_file( if !t.query.is_empty() { p!(f, "query: ["); f.indent(|f| { - for (wire, key, explode) in &t.query { + for (wire, explode) in &t.query { p!( f, "[", string(wire), - ", args[", - string(key), - "], ", + ", ", + param.clone(), + "(args, \"query\", ", + string(wire), + "), ", explode.to_string(), "]," ); @@ -684,18 +780,25 @@ fn tools_file( if !t.headers.is_empty() { p!(f, "headers: ["); f.indent(|f| { - for (wire, key) in &t.headers { - p!(f, "[", string(wire), ", args[", string(key), "]],"); + for wire in &t.headers { + p!( + f, + "[", + string(wire), + ", ", + param.clone(), + "(args, \"headers\", ", + string(wire), + ")]," + ); } }); p!(f, "],"); } - if let Some((key, media_type)) = &t.body { + if let Some(media_type) = &t.body { p!( f, - "body: { value: args[", - string(key), - "], mediaType: ", + "body: { value: args[\"body\"], mediaType: ", string(media_type), " }," ); @@ -710,8 +813,9 @@ fn tools_file( f } -/// Returns the `index.ts` of the root: the API, and every tool. -fn index(forge: &Forge, groups: &[(Group<'_>, Vec>)]) -> ts::File { +/// 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';"); @@ -728,7 +832,7 @@ fn index(forge: &Forge, groups: &[(Group<'_>, Vec>)]) -> ts::File { f.doc("Every tool, by group and then by name."); p!(f, "export const tools: readonly ", tool, "[] = ["); f.indent(|f| { - for (group, _) in groups { + for group in groups { let alias: Vec = group.path().map(|name| name.camel()).collect(); let ident = Module::group(*group) .sub("tools.ts") @@ -741,15 +845,13 @@ fn index(forge: &Forge, groups: &[(Group<'_>, Vec>)]) -> ts::File { 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(); +/// 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. `index.ts` exports them as `tools`, with the runtime in `_/tool.ts` that lists them and builds their requests.", - groups.len() + "{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!( @@ -815,16 +917,16 @@ fn ts_identifier(s: &str) -> String { #[cfg(test)] mod tests { - use forge::{File as _, Forge, Plugin}; + 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)> = Generator - .generate(&forge) + 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) @@ -929,8 +1031,25 @@ mod tests { 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#"["tag", param(args, "query", "tag"), false],"#), + "{tools}" + ); + assert!( + tools.contains(r#"["X-Trace", param(args, "headers", "X-Trace")],"#), + "{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": {"#), "keyed by wire name: {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}"); } @@ -964,9 +1083,9 @@ mod tests { "a response without a JSON body: {tools}" ); assert!(tools.contains(r##""$ref": "#/$defs/Pet""##), "{tools}"); - assert!(tools.contains(r#""Pet": Pet_output,"#), "{tools}"); + assert!(tools.contains(r#""Pet": PetOutput,"#), "{tools}"); let outputs = file(&files, "pets/outputs.ts"); - assert!(outputs.contains("export const Pet_output: JsonSchema = {"), "{outputs}"); + assert!(outputs.contains("export const PetOutput: JsonSchema = {"), "{outputs}"); assert!( outputs.contains(r#""id""#), "responses keep read-only fields: {outputs}" @@ -988,7 +1107,7 @@ mod tests { 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 },"), + list.contains("outputSchema: {\n \"items\": {\n \"$ref\": \"#/$defs/Pet\"\n },\n \"type\": \"array\",\n $defs: {\n \"Pet\": PetOutput,\n },\n },"), "the 200 body, with its models: {list}" ); let create = &tools[tools.find(r#"name: "pets_create","#).unwrap()..]; @@ -1014,8 +1133,8 @@ mod tests { 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}"); + assert!(output.contains(r#""A": AOutput,"#), "{output}"); + assert!(!output.contains("EOutput"), "error models are left out: {output}"); } #[test] @@ -1036,18 +1155,83 @@ mod tests { } #[test] - fn duplicate_names() { + 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("another method has the tool name a_b_c"), "{readme}"); + 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": "string"}}}}"##, + ); + 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": Shared1Output,"#), "{x}"); + assert!(x.contains(r#""Top": TopOutput,"#), "{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}"); } /// Every source of the runtime library is embedded. diff --git a/src/bin/forge/mcp/lib/src/tool.ts b/src/bin/forge/mcp/lib/src/tool.ts index d2c194b34..446c9d37b 100644 --- a/src/bin/forge/mcp/lib/src/tool.ts +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -1,8 +1,15 @@ import type { Tool as McpTool } from '@modelcontextprotocol/server'; -/** The arguments of a tool call, as the client sent them. */ +/** + * 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 }; @@ -105,15 +112,52 @@ export class InvalidArguments extends Error { } /** - * Returns the argument `key`, for the path: a string, number or boolean, + * 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`); + } + return (values as Readonly>)[name]; +} + +/** + * Returns the path parameter `name`: 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]; +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: ${key}`); + throw new InvalidArguments(`Missing path parameter: ${name}`); } - return encodeURIComponent(scalar(key, value)); + return encodeURIComponent(scalar(name, value)); +} + +/** + * 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; } /** @@ -123,7 +167,7 @@ export function pathParam(args: Args, key: string): string { * Throws {@link InvalidArguments}. */ export function buildRequest(tool: Tool, args: Args, baseUrl: string): HttpRequest { - const missing = (tool.inputSchema.required ?? []).filter((key) => args[key] === undefined); + const missing = missingArguments(tool, args); if (missing.length > 0) { throw new InvalidArguments(`Missing required arguments: ${missing.join(', ')}`); } diff --git a/src/bin/forge/mcp/lib/test/fixtures.ts b/src/bin/forge/mcp/lib/test/fixtures.ts index f8873461b..62232caae 100644 --- a/src/bin/forge/mcp/lib/test/fixtures.ts +++ b/src/bin/forge/mcp/lib/test/fixtures.ts @@ -1,4 +1,4 @@ -import { pathParam, type Tool } from '../src/tool.js'; +import { param, pathParam, type Tool } from '../src/tool.js'; /** Tools as the generator writes them. */ export const tools: readonly Tool[] = [ @@ -8,8 +8,12 @@ export const tools: readonly Tool[] = [ description: 'Lists the pets of a store.', inputSchema: { type: 'object', - properties: { store_id: { type: 'string' }, tag: { type: 'array' }, sort: { type: 'object' } }, - required: ['store_id'], + 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', @@ -27,10 +31,10 @@ export const tools: readonly Tool[] = [ request: (args) => ({ path: `/stores/${pathParam(args, 'store_id')}/pets`, query: [ - ['tag', args['tag'], true], - ['sort', args['sort'], false], + ['tag', param(args, 'query', 'tag'), true], + ['sort', param(args, 'query', 'sort'), false], ], - headers: [['X-Trace', args['x_trace']]], + headers: [['X-Trace', param(args, 'headers', 'X-Trace')]], }), }, { @@ -38,14 +42,19 @@ export const tools: readonly Tool[] = [ description: 'Adds a pet.', inputSchema: { type: 'object', - properties: { store_id: { type: 'string' }, body: {} }, - required: ['store_id', 'body'], + 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'], mediaType: 'application/json' }, }), }, diff --git a/src/bin/forge/mcp/lib/test/tool.test.ts b/src/bin/forge/mcp/lib/test/tool.test.ts index c26d5bd75..dbabf2b6a 100644 --- a/src/bin/forge/mcp/lib/test/tool.test.ts +++ b/src/bin/forge/mcp/lib/test/tool.test.ts @@ -9,7 +9,11 @@ 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 }, + { + 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'); @@ -19,25 +23,34 @@ test('builds the path, query and headers', () => { }); test('joins a base URL that ends with slashes', () => { - const request = buildRequest(list, { store_id: 's' }, 'https://api.example.com/v1///'); + 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, { store_id: 's', tag: null }, 'https://api.example.com'); + 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, { store_id: 's', body: { name: 'Rex' } }, 'https://api.example.com'); + 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, { store_id: 's', body: { name: 'Rex' } }, 'https://api.example.com'); + 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'); @@ -46,16 +59,29 @@ test('is plain data that fetch sends as it is', async () => { }); test('refuses missing required arguments and values that are not scalars', () => { - assert.throws(() => buildRequest(create, { store_id: 's' }, 'https://x'), { + assert.throws(() => buildRequest(create, { path: { store_id: 's' }, query: {} }, 'https://x'), { name: 'InvalidArguments', - message: 'Missing required arguments: body', + 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, { store_id: '' }, 'https://x'), InvalidArguments); - assert.throws(() => buildRequest(list, { store_id: 's', tag: [{}] }, 'https://x'), { + 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 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)); diff --git a/src/bin/forge/mcp/schema.rs b/src/bin/forge/mcp/schema.rs index 47e686abe..4866566ca 100644 --- a/src/bin/forge/mcp/schema.rs +++ b/src/bin/forge/mcp/schema.rs @@ -1,9 +1,10 @@ //! 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. +//! 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 @@ -11,7 +12,8 @@ //! 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 std::collections::{BTreeMap, HashSet}; +use std::fmt::Write as _; use forge::{EnumValue, Field, Json, Model, Object, Scalar, Type}; use serde_json::{Map, Value, json}; @@ -20,38 +22,32 @@ use serde_json::{Map, Value, json}; pub(super) type Refs<'f> = HashSet>; /// Whether schemas describe what a client sends or what the API returns. -#[derive(Clone, Copy, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, 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> { +/// Builds the schemas of one direction. +#[derive(Clone, Copy)] +pub(super) struct Schemas { 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() - } - } +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(&mut self, t: Type<'f>, refs: &mut Refs<'f>) -> Value { + pub(super) fn ty<'f>(self, t: Type<'f>, refs: &mut Refs<'f>) -> Value { match t { Type::Scalar(scalar) => scalar_schema(scalar), Type::Enum(e) => { @@ -93,14 +89,14 @@ impl<'f> Schemas<'f> { } Type::Model(model) => { refs.insert(model); - json!({ "$ref": format!("#/$defs/{}", self.key(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(&mut self, field: Field<'f>, refs: &mut Refs<'f>) -> Value { + 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 @@ -118,36 +114,10 @@ impl<'f> Schemas<'f> { 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); + /// 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 { @@ -158,15 +128,24 @@ impl<'f> Schemas<'f> { map.insert("deprecated".to_owned(), json!(true)); } } - self.models.insert(model, (schema, refs)); + 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(&mut self, object: Object<'f>, refs: &mut Refs<'f>) -> Value { + fn object<'f>(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())) { + 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()); @@ -187,36 +166,53 @@ impl<'f> Schemas<'f> { } 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(); +/// 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); + } } - 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; + 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 out.insert(model) { + reach(model.ty(), direction, out); + } + } + } +} + +/// 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}"); } - self.used.insert(key.clone()); - self.keys.insert(model, key.clone()); - key } + key } /// Returns the JSON Schema type of `value`, a value of an enum. From 43eaedd2afa57eacba4cb07d52f0cfe641d327b6 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 30 Oct 2026 17:04:51 +0000 Subject: [PATCH 12/18] docs(mcp): list param in the runtime --- rfcs/mcp.md | 1 + 1 file changed, 1 insertion(+) diff --git a/rfcs/mcp.md b/rfcs/mcp.md index cdb4ae67a..7348c8f80 100644 --- a/rfcs/mcp.md +++ b/rfcs/mcp.md @@ -200,6 +200,7 @@ It is often not an object: a `$ref`, an `allOf`, an array or a scalar. MCP `2026 - `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. +- `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. From df2942ff1f6a7eba565ab353334dc8ee3b727d38 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Sat, 31 Oct 2026 17:50:03 +0000 Subject: [PATCH 13/18] fix(mcp): name model constants after their $defs key Two models whose names have the same words, such as the Cloudflare schemas aaa_audit-logs-response-collection and aaa_audit_logs_response_collection, both became the PascalCase constant AaaAuditLogsResponseCollection, and generation failed. A constant is now named after the model's $defs key, which is unique: `-` is written as `$$`, each `.XX` escape as `$XX`, and `$_` goes in front of a leading digit or a reserved word. Names can't collide, so the duplicate-name check goes away, and nothing a client sees changes. --- rfcs/mcp.md | 2 +- src/bin/forge/mcp.rs | 162 +++++++++++++++++++++++++++++++------------ 2 files changed, 119 insertions(+), 45 deletions(-) diff --git a/rfcs/mcp.md b/rfcs/mcp.md index 7348c8f80..0b26e421b 100644 --- a/rfcs/mcp.md +++ b/rfcs/mcp.md @@ -148,7 +148,7 @@ Schemas are JSON Schema 2020-12, built from forge's model of the spec rather tha 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 the `schemas.ts` or `outputs.ts` of the group forge places it in, and every tool that uses it imports the constant. The constant is the model's name in PascalCase, as the TypeScript SDK names it, with `Output` after it in `outputs.ts`; two models in a group with the same name are an error, as in the SDK. 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. Both depend only on the model, so a group's files never need another group's. +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 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: diff --git a/src/bin/forge/mcp.rs b/src/bin/forge/mcp.rs index d8510ce2e..43f6b3269 100644 --- a/src/bin/forge/mcp.rs +++ b/src/bin/forge/mcp.rs @@ -26,7 +26,7 @@ //! and the JSON body as `body`, as the API takes it. use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::fmt::Write as _; use forge::ts::{self, Ident, Module, PREAMBLE, p}; @@ -62,7 +62,7 @@ impl Plugin for Generator { } } - let mut files = model_files(Module::ROOT, forge.models(), forge.groups())?; + 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(), @@ -93,7 +93,7 @@ impl Plugin for Generator { Module::group(group), group.models(), std::iter::once(group).chain(group.descendants()), - )?); + )); Ok(files.into_iter()) } } @@ -541,19 +541,32 @@ impl SchemaFile { } } -/// Returns the constant with the schema of `model` in a file of `kind`: -/// the model's name in PascalCase, as the TypeScript SDK names it, with -/// `Output` after it in `outputs.ts`, 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. +/// 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 name = Ident::model(model).name; - let name = match kind { - SchemaFile::Input => name.into_owned(), - SchemaFile::Output => format!("{name}Output"), - }; let dir = model.group().map_or(Module::ROOT, Module::group); - dir.sub(kind.file()).ident(name) + 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 @@ -565,10 +578,10 @@ fn model_files<'f>( dir: Module, placed: impl Iterator>, groups: impl Iterator>, -) -> Result, Err> { +) -> Vec { let placed: HashSet> = placed.collect(); if placed.is_empty() { - return Ok(Vec::new()); + return Vec::new(); } let mut inputs = Refs::new(); let mut outputs = Refs::new(); @@ -592,37 +605,22 @@ fn model_files<'f>( continue; } models.sort_by_cached_key(|&m| schema::key(m)); - files.push(File::Ts(schema_file(dir.sub(kind.file()), &models, kind)?)); + files.push(File::Ts(schema_file(dir.sub(kind.file()), &models, kind))); } - Ok(files) + files } /// Returns the file `module` with the constant of each of `models`, which -/// are placed in its directory. Two models in a group with the same name -/// are an error, as in the TypeScript SDK, as is a model whose name has no -/// letters or digits. -fn schema_file(module: Module, models: &[Model<'_>], kind: SchemaFile) -> Result { +/// 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", }; - let mut names: HashMap> = HashMap::new(); for &model in models { - let name = model_ident(model, kind).name.into_owned(); - if model.name().is_empty() { - return Err(format!("schema {:?}: the name has no letters or digits", model.schema_name()).into()); - } - if let Some(other) = names.insert(name.clone(), model) { - return Err(format!( - "schemas {:?} and {:?} have the same name {name:?}", - other.schema_name(), - model.schema_name() - ) - .into()); - } - let ident = f.ident(name); + 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(); @@ -642,9 +640,59 @@ fn schema_file(module: Module, models: &[Model<'_>], kind: SchemaFile) -> Result } p!(f, ""); } - Ok(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")); @@ -1083,9 +1131,9 @@ mod tests { "a response without a JSON body: {tools}" ); assert!(tools.contains(r##""$ref": "#/$defs/Pet""##), "{tools}"); - assert!(tools.contains(r#""Pet": PetOutput,"#), "{tools}"); + assert!(tools.contains(r#""Pet": Pet,"#), "{tools}"); let outputs = file(&files, "pets/outputs.ts"); - assert!(outputs.contains("export const PetOutput: JsonSchema = {"), "{outputs}"); + assert!(outputs.contains("export const Pet: JsonSchema = {"), "{outputs}"); assert!( outputs.contains(r#""id""#), "responses keep read-only fields: {outputs}" @@ -1107,7 +1155,7 @@ mod tests { 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\": PetOutput,\n },\n },"), + 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()..]; @@ -1133,8 +1181,8 @@ mod tests { 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": AOutput,"#), "{output}"); - assert!(!output.contains("EOutput"), "error models are left out: {output}"); + assert!(output.contains(r#""A": A,"#), "{output}"); + assert!(!output.contains(r#""E":"#), "error models are left out: {output}"); } #[test] @@ -1226,14 +1274,40 @@ mod tests { ); 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": Shared1Output,"#), "{x}"); - assert!(x.contains(r#""Top": TopOutput,"#), "{x}"); + 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": "string"}, "logs_list": {"type": "integer"}, + "2fa": {"type": "boolean"}}}}"##, + ); + 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}"); + } + /// Every source of the runtime library is embedded. #[test] fn runtime_is_complete() { From 9ebd355b0900309f1f408ffd4ec97393334b9c1d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Sat, 31 Oct 2026 18:08:31 +0000 Subject: [PATCH 14/18] feat(mcp): stricter, smaller input schemas, and the endpoint in each description - inputSchema and each of path, query and headers set additionalProperties: false, so a client that validates arguments refuses a misspelled parameter, or one in the wrong place. - buildRequest refuses an argument the tool doesn't take and names the place a misplaced parameter goes: `per_page goes in query`. - A model that is only a scalar or an enum, or a union or intersection of them, is written inline instead of as a $ref, and descriptions are trimmed. The Cloudflare listing drops from 6.0 MB to 5.6 MB even with the additions; most of what is left is request body models. - Each description ends with `METHOD /path`, so a model can match the tool to the API's documentation. --- rfcs/mcp.md | 13 +++-- src/bin/forge/mcp.rs | 70 +++++++++++++++++++++---- src/bin/forge/mcp/lib/src/tool.ts | 25 +++++++++ src/bin/forge/mcp/lib/test/tool.test.ts | 9 ++++ src/bin/forge/mcp/schema.rs | 33 ++++++++++-- 5 files changed, 130 insertions(+), 20 deletions(-) diff --git a/rfcs/mcp.md b/rfcs/mcp.md index 0b26e421b..145a0e94e 100644 --- a/rfcs/mcp.md +++ b/rfcs/mcp.md @@ -104,7 +104,7 @@ The name comes from forge's group hierarchy and method names, as for every targe - **`headers`**: an object of the header parameters, likewise. Read-only headers are left out. - **`body`**: the JSON body, as the API takes it, with the body's description. It is required if the body is. -A place without parameters has no argument. The arguments mirror the HTTP request, so names never clash and are what the API documents: +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": "…" } } @@ -119,7 +119,7 @@ A place without parameters has no argument. The arguments mirror the HTTP reques - `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, including required parameters such as `query.page`, then: +`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, naming the place a parameter goes when it was passed in the wrong one (`per_page goes in query`), 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 `,`; @@ -148,7 +148,7 @@ Schemas are JSON Schema 2020-12, built from forge's model of the spec rather tha 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 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. +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: @@ -159,10 +159,13 @@ Arguments and responses are built separately, because a model differs between th ## Description and annotations -`title` is the method's summary. `description` is the method's description, or its summary, or `METHOD /path`, followed by: +`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. +- `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: diff --git a/src/bin/forge/mcp.rs b/src/bin/forge/mcp.rs index 43f6b3269..f8839474a 100644 --- a/src/bin/forge/mcp.rs +++ b/src/bin/forge/mcp.rs @@ -284,6 +284,7 @@ impl Params { 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()); @@ -334,9 +335,9 @@ impl<'f> Tool<'f> { { let mut schema = Schemas::INPUT.ty(content.ty(), &mut refs); if let Value::Object(map) = &mut schema - && !b.description().is_empty() + && !b.description().trim().is_empty() { - map.insert("description".to_owned(), json!(b.description())); + map.insert("description".to_owned(), json!(b.description().trim())); } properties.insert("body".to_owned(), schema); if b.is_required() { @@ -486,13 +487,13 @@ fn tool_name(m: Method<'_>) -> String { 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() + 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() { - summary.to_owned() - } else { - format!("{} {}", m.http_method().as_str(), m.path()) - }]; + paragraphs.push(summary.to_owned()); + } let stage = match m.availability() { Some(Availability::Alpha) => Some("alpha"), Some(Availability::Beta) => Some("beta"), @@ -512,6 +513,7 @@ fn description(m: Method<'_>) -> String { } paragraphs.push(text); } + paragraphs.push(endpoint); paragraphs.join("\n\n") } @@ -720,6 +722,7 @@ fn tools_file(group: Group<'_>, tools: &[Tool<'_>]) -> ts::File { 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| { @@ -1261,7 +1264,7 @@ mod tests { }, "components": {"schemas": { "Shared$1": {"type": "object", "properties": {"top": {"$ref": "#/components/schemas/Top"}}}, - "Top": {"type": "string"}}}}"##, + "Top": {"type": "object", "properties": {"n": {"type": "integer"}}}}}}"##, ); let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); assert!( @@ -1296,8 +1299,8 @@ mod tests { "y": {"$ref": "#/components/schemas/logs_list"}, "z": {"$ref": "#/components/schemas/2fa"}}}}}}, "responses": {"200": {"description": "ok"}}}}}, - "components": {"schemas": {"logs-list": {"type": "string"}, "logs_list": {"type": "integer"}, - "2fa": {"type": "boolean"}}}}"##, + "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}"); @@ -1308,6 +1311,51 @@ mod tests { 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() { diff --git a/src/bin/forge/mcp/lib/src/tool.ts b/src/bin/forge/mcp/lib/src/tool.ts index 446c9d37b..89996d05a 100644 --- a/src/bin/forge/mcp/lib/src/tool.ts +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -139,6 +139,30 @@ export function pathParam(args: Args, name: string): string { return encodeURIComponent(scalar(name, value)); } +/** + * Throws {@link InvalidArguments} if `args` has an argument `tool` doesn't + * take, naming where it goes when it is a parameter passed in the wrong place, + * such as `per_page` at the top level instead of in `query`. + */ +function checkUnknownArguments(tool: Tool, args: Args): void { + const properties = tool.inputSchema.properties ?? {}; + const unknown = Object.keys(args).filter((key) => !Object.hasOwn(properties, key)); + if (unknown.length === 0) { + return; + } + const hints = unknown.map((key) => { + const place = Object.keys(properties).find((name) => { + const params = properties[name]?.['properties']; + return typeof params === 'object' && params !== null && Object.hasOwn(params, key); + }); + return place ? `${key} goes in ${place}: { "${place}": { "${key}": … } }` : `${key} is not an argument`; + }); + const takes = Object.keys(properties); + throw new InvalidArguments( + `Unknown arguments for ${tool.name}: ${hints.join('; ')}. It takes ${takes.length > 0 ? takes.join(', ') : 'no arguments'}.`, + ); +} + /** * 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 @@ -167,6 +191,7 @@ function missingArguments(tool: Tool, args: Args): string[] { * Throws {@link InvalidArguments}. */ export function buildRequest(tool: Tool, args: Args, baseUrl: string): HttpRequest { + checkUnknownArguments(tool, args); const missing = missingArguments(tool, args); if (missing.length > 0) { throw new InvalidArguments(`Missing required arguments: ${missing.join(', ')}`); diff --git a/src/bin/forge/mcp/lib/test/tool.test.ts b/src/bin/forge/mcp/lib/test/tool.test.ts index dbabf2b6a..2aec0de5f 100644 --- a/src/bin/forge/mcp/lib/test/tool.test.ts +++ b/src/bin/forge/mcp/lib/test/tool.test.ts @@ -72,6 +72,15 @@ test('refuses missing required arguments and values that are not scalars', () => }); }); +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 place that is not an object', () => { assert.throws(() => buildRequest(list, { path: 's' }, 'https://x'), { name: 'InvalidArguments', diff --git a/src/bin/forge/mcp/schema.rs b/src/bin/forge/mcp/schema.rs index 4866566ca..b3747a84b 100644 --- a/src/bin/forge/mcp/schema.rs +++ b/src/bin/forge/mcp/schema.rs @@ -87,6 +87,7 @@ impl Schemas { 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)) }) @@ -101,8 +102,9 @@ impl Schemas { if self.direction == Direction::Input && let Value::Object(map) = &mut schema { - if !field.description().is_empty() { - map.insert("description".to_owned(), json!(field.description())); + 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)); @@ -121,8 +123,9 @@ impl Schemas { 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())); + 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)); @@ -190,6 +193,7 @@ pub(super) fn reach<'f>(t: Type<'f>, direction: Direction, out: &mut Refs<'f>) { } 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); @@ -198,6 +202,27 @@ pub(super) fn reach<'f>(t: Type<'f>, direction: Direction, out: &mut Refs<'f>) { } } +/// 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 From f3c775a8858f355f74e1754d72b16155a4f53e00 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Sat, 31 Oct 2026 18:13:41 +0000 Subject: [PATCH 15/18] feat(mcp): export checkArguments for servers that fill in arguments A server that fills in an argument from the session, such as an account, checks the caller's arguments first, so a parameter passed at the top level is reported as misplaced rather than the account as missing. --- rfcs/mcp.md | 1 + src/bin/forge/mcp/lib/src/tool.ts | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/rfcs/mcp.md b/rfcs/mcp.md index 145a0e94e..2c21c2fa2 100644 --- a/rfcs/mcp.md +++ b/rfcs/mcp.md @@ -203,6 +203,7 @@ It is often not an object: a `$ref`, an `allOf`, an array or a scalar. MCP `2026 - `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. diff --git a/src/bin/forge/mcp/lib/src/tool.ts b/src/bin/forge/mcp/lib/src/tool.ts index 89996d05a..a717475c5 100644 --- a/src/bin/forge/mcp/lib/src/tool.ts +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -163,6 +163,21 @@ function checkUnknownArguments(tool: Tool, args: Args): void { ); } +/** + * 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 @@ -191,11 +206,7 @@ function missingArguments(tool: Tool, args: Args): string[] { * Throws {@link InvalidArguments}. */ export function buildRequest(tool: Tool, args: Args, baseUrl: string): HttpRequest { - checkUnknownArguments(tool, args); - const missing = missingArguments(tool, args); - if (missing.length > 0) { - throw new InvalidArguments(`Missing required arguments: ${missing.join(', ')}`); - } + checkArguments(tool, args); const api = tool.request(args); const url = new URL(withoutTrailingSlashes(baseUrl) + api.path); for (const [name, value, explode] of api.query ?? []) { From b380789daec251e3f01168101e80c18ce1d19513 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Sat, 31 Oct 2026 18:16:07 +0000 Subject: [PATCH 16/18] feat(mcp): refuse unknown parameters inside path, query and headers checkArguments also refuses a name inside a place that the place doesn't take, such as a misspelled query.nmae, and lists what it takes, so a server needs no schema validator to stop a typo from being ignored. --- rfcs/mcp.md | 2 +- src/bin/forge/mcp/lib/src/tool.ts | 44 ++++++++++++++++--------- src/bin/forge/mcp/lib/test/tool.test.ts | 8 +++++ 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/rfcs/mcp.md b/rfcs/mcp.md index 2c21c2fa2..42b739b48 100644 --- a/rfcs/mcp.md +++ b/rfcs/mcp.md @@ -119,7 +119,7 @@ A place without parameters has no argument. `inputSchema` and each place set `ad - `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 that every argument is one the tool takes, naming the place a parameter goes when it was passed in the wrong one (`per_page goes in query`), and that the required arguments are present, including required parameters such as `query.page`, then: +`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 `,`; diff --git a/src/bin/forge/mcp/lib/src/tool.ts b/src/bin/forge/mcp/lib/src/tool.ts index a717475c5..447bb4c35 100644 --- a/src/bin/forge/mcp/lib/src/tool.ts +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -141,26 +141,38 @@ export function pathParam(args: Args, name: string): string { /** * Throws {@link InvalidArguments} if `args` has an argument `tool` doesn't - * take, naming where it goes when it is a parameter passed in the wrong place, - * such as `per_page` at the top level instead of in `query`. + * 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 unknown = Object.keys(args).filter((key) => !Object.hasOwn(properties, key)); - if (unknown.length === 0) { - return; - } - const hints = unknown.map((key) => { - const place = Object.keys(properties).find((name) => { - const params = properties[name]?.['properties']; - return typeof params === 'object' && params !== null && Object.hasOwn(params, key); + 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`; }); - return place ? `${key} goes in ${place}: { "${place}": { "${key}": … } }` : `${key} is not an argument`; - }); - const takes = Object.keys(properties); - throw new InvalidArguments( - `Unknown arguments for ${tool.name}: ${hints.join('; ')}. It takes ${takes.length > 0 ? takes.join(', ') : 'no arguments'}.`, - ); + 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); + for (const key of Object.keys(given).filter((name) => !takes.includes(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'}.`, + ); + } } /** diff --git a/src/bin/forge/mcp/lib/test/tool.test.ts b/src/bin/forge/mcp/lib/test/tool.test.ts index 2aec0de5f..f16cf94ce 100644 --- a/src/bin/forge/mcp/lib/test/tool.test.ts +++ b/src/bin/forge/mcp/lib/test/tool.test.ts @@ -81,6 +81,14 @@ test('refuses an argument the tool does not take, saying where a parameter goes' }); }); +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', From 30da0b336a519d3f11f95d40331ae40c52a650e0 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Sat, 31 Oct 2026 21:43:17 +0000 Subject: [PATCH 17/18] feat(mcp): a tool for every body, sent as fetch would A method whose body has no JSON media type got no tool, so writes such as a KV value, an R2 object or a Worker upload were missing. Now every method gets one: - body has the spec's schema for its media type; a body in several is anyOf them, and headers gains content-type, an enum of them. - The runtime picks the media type as fetch does: the content-type argument, else from the value (a string as text, an object as JSON, else multipart/form-data), and encodes it as fetch would. An object is multipart parts or form pairs, a string goes as it is. - A format: binary argument, such as a file part, is text or { content, filename?, contentType? }, as FormData.append(name, blob, filename) takes a file. Arguments are JSON, so files are text. - Header names are lower case, and buildRequest takes them in any case. A content-type argument wins over the default, so R2's declared Content-Type header is sent. Blind agents doing KV with metadata, an R2 text object, an ES module Worker upload and an Analytics Engine SQL query finished every task with no failed calls. --- rfcs/mcp.md | 21 ++-- src/bin/forge/mcp.rs | 154 ++++++++++++++++++------ src/bin/forge/mcp/lib/src/tool.ts | 140 ++++++++++++++++++++- src/bin/forge/mcp/lib/test/fixtures.ts | 6 +- src/bin/forge/mcp/lib/test/tool.test.ts | 116 +++++++++++++++++- src/bin/forge/mcp/schema.rs | 23 ++++ 6 files changed, 402 insertions(+), 58 deletions(-) diff --git a/rfcs/mcp.md b/rfcs/mcp.md index 42b739b48..18cb276ff 100644 --- a/rfcs/mcp.md +++ b/rfcs/mcp.md @@ -59,7 +59,7 @@ Each `tools.ts` exports `tools`, an array of the group's tools sorted by name, a 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' } }), + request: (args) => ({ path: `/zones/${pathParam(args, 'zone_id')}/dns_records`, body: { value: args['body'], mediaTypes: ['application/json'] } }), } ``` @@ -69,12 +69,11 @@ The runtime imports nothing at runtime, only the SDK's types, so the tools load 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 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). | +| 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. @@ -101,8 +100,8 @@ The name comes from forge's group hierarchy and method names, as for every targe - **`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, likewise. Read-only headers are left out. -- **`body`**: the JSON body, as the API takes it, with the body's description. It is required if the body 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: @@ -117,14 +116,14 @@ A place without parameters has no argument. `inputSchema` and each place set `ad - `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 its media type. +- `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 as JSON with its media type. +- 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. diff --git a/src/bin/forge/mcp.rs b/src/bin/forge/mcp.rs index f8839474a..2bde7fcc8 100644 --- a/src/bin/forge/mcp.rs +++ b/src/bin/forge/mcp.rs @@ -30,7 +30,7 @@ use std::collections::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, Name, Plugin, Type}; +use forge::{Availability, Content, Err, Field, Forge, Group, HttpMethod, Location, Method, Model, Name, Plugin, Type}; use serde_json::{Map, Value, json}; mod schema; @@ -168,13 +168,6 @@ fn check(m: Method<'_>) -> Result { if name.len() > MAX_NAME_LENGTH { return Err(format!("the tool name is over {MAX_NAME_LENGTH} characters")); } - if let Some(b) = m.body() - && b.json().is_none() - && 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(", "))); - } segments(m.path(), &path_names(m))?; Ok(name) } @@ -203,11 +196,7 @@ fn input_types(m: Method<'_>) -> Vec> { .filter(|f| !f.is_read_only()) .map(|f| f.ty()), ); - if let Some(b) = m.body() - && let Some(content) = b.json() - { - types.push(content.ty()); - } + types.extend(m.body().into_iter().flat_map(|b| b.contents()).map(|c| c.ty())); types } @@ -235,8 +224,8 @@ struct Tool<'f> { query: Vec<(String, bool)>, /// Each header parameter's wire name. headers: Vec, - /// The media type of the JSON body. - body: Option, + /// 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)>, @@ -265,9 +254,8 @@ struct Params { } impl Params { - /// Adds `field`, a parameter that is required if `required`. - fn add<'f>(&mut self, field: Field<'f>, required: bool, refs: &mut Refs<'f>) { - let key = field.wire_name().to_owned(); + /// 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); @@ -305,9 +293,9 @@ impl<'f> Tool<'f> { for field in parameters(m.request()) { match m.location(field) { - Some(Location::Path) => path_params.add(field, true, &mut refs), + Some(Location::Path) => path_params.add(field.wire_name().to_owned(), field, true, &mut refs), Some(Location::Query { exploded }) => { - query_params.add(field, field.is_required(), &mut refs); + query_params.add(field.wire_name().to_owned(), field, field.is_required(), &mut refs); query.push((field.wire_name().to_owned(), exploded)); } None => {} @@ -319,8 +307,23 @@ impl<'f> Tool<'f> { .flat_map(|h| h.fields()) .filter(|f| !f.is_read_only()) { - header_params.add(field, field.is_required(), &mut refs); - headers.push(field.wire_name().to_owned()); + // 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(); @@ -331,9 +334,18 @@ impl<'f> Tool<'f> { let mut body = None; if let Some(b) = m.body() - && let Some(content) = b.json() + && !contents.is_empty() { - let mut schema = Schemas::INPUT.ty(content.ty(), &mut refs); + 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() { @@ -343,7 +355,7 @@ impl<'f> Tool<'f> { if b.is_required() { required.push("body".to_owned()); } - body = Some(content.media_type().to_owned()); + body = Some(contents.iter().map(|c| c.media_type().to_owned()).collect()); } let path = segments(m.path(), &path_names(m))?; @@ -846,12 +858,13 @@ fn tools_file(group: Group<'_>, tools: &[Tool<'_>]) -> ts::File { }); p!(f, "],"); } - if let Some(media_type) = &t.body { + if let Some(media_types) = &t.body { + let types: Vec = media_types.iter().map(|t| string(t)).collect(); p!( f, - "body: { value: args[\"body\"], mediaType: ", - string(media_type), - " }," + "body: { value: args[\"body\"], mediaTypes: [", + types.join(", "), + "] }," ); } }); @@ -1055,6 +1068,7 @@ mod tests { "_/tool.ts", "index.ts", "pets/outputs.ts", + "pets/photos/tools.ts", "pets/schemas.ts", "pets/tools.ts" ] @@ -1087,8 +1101,8 @@ mod tests { "{tools}" ); assert!( - tools.contains(r#"["X-Trace", param(args, "headers", "X-Trace")],"#), - "{tools}" + 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()]; @@ -1096,7 +1110,10 @@ mod tests { 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": {"#), "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}" @@ -1110,7 +1127,7 @@ mod tests { let files = generate(PETS); let tools = file(&files, "pets/tools.ts"); assert!( - tools.contains(r#"body: { value: args["body"], mediaType: "application/json" },"#), + tools.contains(r#"body: { value: args["body"], mediaTypes: ["application/json"] },"#), "{tools}" ); assert!(tools.contains(r#""Pet": Pet,"#), "{tools}"); @@ -1196,12 +1213,75 @@ mod tests { !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("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!( - readme.contains("| `POST` | `/stores/{store_id}/photos` | the request body is not JSON (image/png) |"), - "{readme}" + 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}" ); } diff --git a/src/bin/forge/mcp/lib/src/tool.ts b/src/bin/forge/mcp/lib/src/tool.ts index 447bb4c35..a0f52d1f7 100644 --- a/src/bin/forge/mcp/lib/src/tool.ts +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -71,7 +71,8 @@ export interface ApiRequest { readonly path: string; readonly query?: readonly QueryParam[]; readonly headers?: readonly HeaderParam[]; - readonly body?: { readonly value: unknown; readonly mediaType: string }; + /** The body, and the media types the API takes it in. */ + readonly body?: { readonly value: unknown; readonly mediaTypes: readonly string[] }; } /** @@ -124,7 +125,13 @@ export function param(args: Args, location: ParamLocation, name: string): unknow if (typeof values !== 'object' || Array.isArray(values)) { throw new InvalidArguments(`${location} must be an object`); } - return (values as Readonly>)[name]; + 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]; } /** @@ -163,7 +170,9 @@ function checkUnknownArguments(tool: Tool, args: Args): void { continue; } const takes = placeParams(place); - for (const key of Object.keys(given).filter((name) => !takes.includes(name))) { + 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(', ')}`); } } @@ -232,8 +241,13 @@ export function buildRequest(tool: Tool, args: Args, baseUrl: string): HttpReque } let body: string | undefined; if (api.body && api.body.value !== undefined) { - headers.set('Content-Type', api.body.mediaType); - body = JSON.stringify(api.body.value); + // 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, @@ -243,6 +257,122 @@ export function buildRequest(tool: Tool, args: Args, baseUrl: string): HttpReque }; } +/** + * 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. diff --git a/src/bin/forge/mcp/lib/test/fixtures.ts b/src/bin/forge/mcp/lib/test/fixtures.ts index 62232caae..d11e80539 100644 --- a/src/bin/forge/mcp/lib/test/fixtures.ts +++ b/src/bin/forge/mcp/lib/test/fixtures.ts @@ -11,7 +11,7 @@ export const tools: readonly Tool[] = [ 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' } } }, + headers: { type: 'object', properties: { 'x-trace': { type: 'string' } } }, }, required: ['path'], }, @@ -34,7 +34,7 @@ export const tools: readonly Tool[] = [ ['tag', param(args, 'query', 'tag'), true], ['sort', param(args, 'query', 'sort'), false], ], - headers: [['X-Trace', param(args, 'headers', 'X-Trace')]], + headers: [['x-trace', param(args, 'headers', 'x-trace')]], }), }, { @@ -55,7 +55,7 @@ export const tools: readonly Tool[] = [ request: (args) => ({ path: `/stores/${pathParam(args, 'store_id')}/pets`, query: [['dry_run', param(args, 'query', 'dry_run'), true]], - body: { value: args['body'], mediaType: 'application/json' }, + body: { value: args['body'], mediaTypes: ['application/json'] }, }), }, ]; diff --git a/src/bin/forge/mcp/lib/test/tool.test.ts b/src/bin/forge/mcp/lib/test/tool.test.ts index f16cf94ce..3b8dfac4d 100644 --- a/src/bin/forge/mcp/lib/test/tool.test.ts +++ b/src/bin/forge/mcp/lib/test/tool.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { buildRequest, InvalidArguments, listTools, toolsByName } from '../src/tool.js'; +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]]; @@ -12,7 +12,7 @@ test('builds the path, query and headers', () => { { path: { store_id: 'a b/c' }, query: { tag: ['x', 'y'], sort: { by: 'name', dir: 'asc' } }, - headers: { 'X-Trace': 7 }, + headers: { 'x-trace': 7 }, }, 'https://api.example.com/v1/', ); @@ -114,3 +114,115 @@ test('lists the output schemas only when asked, once', () => { 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/schema.rs b/src/bin/forge/mcp/schema.rs index b3747a84b..1f75f3d94 100644 --- a/src/bin/forge/mcp/schema.rs +++ b/src/bin/forge/mcp/schema.rs @@ -49,6 +49,7 @@ impl Schemas { /// 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 @@ -250,6 +251,28 @@ fn kind(value: &Value) -> &'static str { } } +/// 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 { From 7a7bdeedd95b63ff4426ebe89f045fa7b55061a3 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Sun, 1 Nov 2026 09:57:44 +0000 Subject: [PATCH 18/18] style(mcp): brace a one-line if, for oxlint's curly rule --- src/bin/forge/mcp/lib/src/tool.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bin/forge/mcp/lib/src/tool.ts b/src/bin/forge/mcp/lib/src/tool.ts index a0f52d1f7..734827edc 100644 --- a/src/bin/forge/mcp/lib/src/tool.ts +++ b/src/bin/forge/mcp/lib/src/tool.ts @@ -294,7 +294,9 @@ function encodeBody(value: unknown, mediaType: string): { body: string; contentT let type: string | undefined; let content: string; if (isFile(item)) { - if (item.filename !== undefined) disposition += `; filename=${quoted(item.filename)}`; + if (item.filename !== undefined) { + disposition += `; filename=${quoted(item.filename)}`; + } type = item.contentType; content = item.content; } else if (typeof item === 'object') {