From e3e7c8cbbea905f3532d9127302b4a99cc46d80d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 20 Oct 2026 14:09:26 +0100 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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