diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 6781dd8ea..23a36f4e9 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 Rust, 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. + Mcp, /// A plugin compiled to a WebAssembly component, at the path. #[cfg(feature = "wasm")] Wasm(PathBuf), @@ -45,8 +50,11 @@ impl Target { if s == "ts" { return Ok(Target::Ts); } + if s == "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 +83,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..6e2700eb0 --- /dev/null +++ b/src/bin/forge/mcp.rs @@ -0,0 +1,614 @@ +//! The MCP server generator. +//! +//! It generates a Rust crate for an MCP server that exposes each method as +//! a tool, and calls the API over HTTP when a client calls one. The crate +//! is the runtime in `mcp/runtime`, which is the same for every document, +//! and a `tools.rs` that lists the tools of the document. +//! +//! The server speaks MCP over standard input and output, through +//! [rmcp](https://crates.io/crates/rmcp), the official Rust SDK for MCP. + +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::fmt::Write; + +use forge::{Err, Field, Forge, HttpMethod, Location, Method, Model, Plugin, Type}; +use serde_json::Map; + +mod schema; + +use schema::Schemas; + +/// Generates an MCP server. +pub struct Generator; + +impl Plugin for Generator { + type File = File; + + /// Returns every file of the crate. The tools of every group are in one + /// file, so this generates nothing per group. + fn generate(&self, forge: &Forge) -> Result, Err> { + Ok([ + File::new("Cargo.toml", CARGO_TOML.to_owned()), + File::new("src/main.rs", format!("{PREAMBLE}{RUNTIME}")), + File::new("src/tools.rs", tools(forge)?), + ] + .into_iter()) + } +} + +/// A file of the crate. +pub struct File { + path: &'static str, + content: String, +} + +impl File { + fn new(path: &'static str, content: String) -> File { + File { path, content } + } +} + +impl forge::File for File { + fn path(&self) -> &str { + self.path + } + + fn content(&self) -> Result, Err> { + Ok(Cow::Borrowed(self.content.as_bytes())) + } +} + +/// The comment at the top of every generated Rust file. +const PREAMBLE: &str = "// Code generated by forge. DO NOT EDIT.\n\n"; + +/// The source of the server, which reads the tools from `tools.rs`. +const RUNTIME: &str = include_str!("mcp/runtime/main.rs"); + +/// The manifest of the crate. +const CARGO_TOML: &str = r#"# Code generated by forge. DO NOT EDIT. + +[package] +name = "mcp-server" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +publish = false + +[dependencies] +reqwest = "0.13.5" +rmcp = { version = "3.5.1", default-features = false, features = ["server", "transport-io"] } +serde_json = "1.0.154" +tokio = { version = "1.53.3", features = ["macros", "rt-multi-thread"] } + +# The crate is a workspace of its own, so that it builds wherever it is +# generated, including inside another workspace. +[workspace] +"#; + +/// Returns the source of `tools.rs`: the base URL, the instructions, and a +/// tool for each method that is not [skipped]. +fn tools(forge: &Forge) -> Result { + let mut out = String::from(PREAMBLE); + writeln!(out, "//! The tools of the server, one for each method of the API.")?; + writeln!(out)?; + writeln!(out, "use crate::{{Body, Location, Param, Tool}};")?; + writeln!(out)?; + writeln!(out, "/// The base URL of the API, unless `API_BASE_URL` overrides it.")?; + writeln!(out, "pub const BASE_URL: &str = {:?};", forge.base_url)?; + writeln!(out)?; + writeln!(out, "/// What the server tells a client about the API.")?; + writeln!(out, "pub const INSTRUCTIONS: &str = {:?};", forge.description.trim())?; + writeln!(out)?; + writeln!(out, "/// Every tool, sorted by group and then by name.")?; + writeln!(out, "pub const TOOLS: &[Tool] = &[")?; + let mut names: HashMap = HashMap::new(); + for group in forge.groups() { + for m in group.methods() { + if skipped(m) { + continue; + } + let name = tool_name(m); + let path = method_path(m); + if let Some(other) = names.insert(name.clone(), path.clone()) { + return Err(format!("{other} and {path} are both the tool {name}").into()); + } + tool(&mut out, m, &name)?; + } + } + writeln!(out, "];")?; + Ok(out) +} + +/// Prints the tool of `m`, named `name`. +fn tool(out: &mut String, m: Method<'_>, name: &str) -> Result<(), Err> { + let request = Request::new(m); + let behavior = Behavior::new(m); + writeln!(out, " Tool {{")?; + writeln!(out, " name: {name:?},")?; + writeln!(out, " title: {:?},", m.summary().trim())?; + writeln!(out, " description: {:?},", description(m))?; + writeln!(out, " method: {:?},", m.http_method().as_str())?; + writeln!(out, " path: {:?},", m.path())?; + if request.params.is_empty() { + writeln!(out, " params: &[],")?; + } else { + writeln!(out, " params: &[")?; + for (field, location) in &request.params { + let location = match location { + Location::Path => "Location::Path".to_owned(), + Location::Query { exploded } => format!("Location::Query {{ exploded: {exploded} }}"), + }; + writeln!( + out, + " Param {{ name: {:?}, location: {location} }},", + field.wire_name() + )?; + } + writeln!(out, " ],")?; + } + let body = match request.body { + None => "Body::None", + Some(BodyKind::Flat(_)) => "Body::Flat", + Some(BodyKind::Field { .. }) => "Body::Field", + }; + writeln!(out, " body: {body},")?; + writeln!(out, " input_schema: {},", raw_string(&request.schema()))?; + writeln!(out, " read_only: {},", behavior.read_only)?; + writeln!(out, " destructive: {},", behavior.destructive)?; + writeln!(out, " idempotent: {},", behavior.idempotent)?; + writeln!(out, " }},")?; + Ok(()) +} + +/// Returns the name of the tool of `m`: the names of its groups and its own, +/// in snake_case, separated by underscores, 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 names of the groups of `m` and its own, separated by dots, +/// for errors. +fn method_path(m: Method<'_>) -> String { + let mut words: Vec = m.group().path().map(|name| name.camel()).collect(); + words.push(m.name().camel()); + words.join(".") +} + +/// Returns the description of the tool of `m`: its summary and description, +/// then whether it is deprecated, and the sentence to confirm it with. +fn description(m: Method<'_>) -> String { + let mut paragraphs: Vec<&str> = Vec::new(); + let summary = m.summary().trim(); + let description = m.description().trim(); + if !summary.is_empty() { + paragraphs.push(summary); + } + if !description.is_empty() && description != summary { + paragraphs.push(description); + } + if m.is_deprecated() { + paragraphs.push("Deprecated."); + } + if let Some(confirmation) = m.confirmation().map(str::trim) + && !confirmation.is_empty() + { + paragraphs.push(confirmation); + } + paragraphs.join("\n\n") +} + +/// Reports whether `m` has no tool: it is hidden, its request body has +/// content and none of it is JSON, a success response has content and none +/// of it is JSON or text, it needs a header, which the server does not send +/// yet, or two of its arguments have the same name. +fn skipped(m: Method<'_>) -> bool { + if m.is_hidden() { + return true; + } + if let Some(body) = m.body() + && body.json().is_none() + && body.contents().len() > 0 + { + return true; + } + let readable = |media_type: &str| media_type.starts_with("text/"); + if m.responses() + .any(|r| r.contents().len() > 0 && !r.contents().any(|c| c.is_json() || readable(c.media_type()))) + { + return true; + } + if m.headers() + .is_some_and(|headers| headers.fields().any(Field::is_required)) + { + return true; + } + let request = Request::new(m); + let mut names = HashSet::new(); + let body_name = match request.body { + Some(BodyKind::Field { .. }) => Some("body"), + _ => None, + }; + !request + .params + .iter() + .map(|(field, _)| field.wire_name()) + .chain(body_name) + .all(|name| names.insert(name)) +} + +/// The arguments of a tool: the path and query parameters of its method, +/// each named after its wire name, and its JSON body. +struct Request<'f> { + /// The parameters, path parameters first. + params: Vec<(Field<'f>, Location)>, + body: Option>, +} + +/// Where the body of a method is in the arguments of its tool. +enum BodyKind<'f> { + /// The body's fields are arguments, next to the parameters. + Flat(Type<'f>), + /// The body is the argument `body`. + Field { + ty: Type<'f>, + required: bool, + description: &'f str, + }, +} + +impl<'f> Request<'f> { + /// Returns the arguments of the tool of `m`. The body is flat when + /// [`Method::request`] puts its fields next to the parameters. + fn new(m: Method<'f>) -> Request<'f> { + let request = m.request(); + let object = match request { + Some(Type::Object(object)) => Some(object), + Some(Type::Intersection(intersection)) => match intersection.members().next() { + Some(Type::Object(object)) => Some(object), + _ => None, + }, + _ => None, + }; + let params: Vec<(Field<'f>, Location)> = object + .into_iter() + .flat_map(|object| object.fields()) + .filter_map(|field| Some((field, m.location(field)?))) + .collect(); + let body = m.body().and_then(|body| { + let ty = body.json()?.ty(); + let flat = if params.is_empty() { + object_like(ty, &mut HashSet::new()) + } else { + matches!(request, Some(Type::Intersection(_))) + }; + Some(if flat { + BodyKind::Flat(ty) + } else { + BodyKind::Field { + ty, + required: body.is_required(), + description: body.description(), + } + }) + }); + Request { params, body } + } + + /// Returns the JSON Schema of the arguments. + fn schema(&self) -> String { + let mut schemas = Schemas::default(); + let mut properties = Map::new(); + let mut required = Vec::new(); + for (field, _) in &self.params { + properties.insert(field.wire_name().to_owned(), schemas.field(*field)); + if field.is_required() { + required.push(field.wire_name().to_owned()); + } + } + let mut all_of = Vec::new(); + match self.body { + None => {} + Some(BodyKind::Flat(ty)) => all_of.push(schemas.ty(ty)), + Some(BodyKind::Field { + ty, + required: body_required, + description, + }) => { + let mut schema = schemas.ty(ty); + if let serde_json::Value::Object(map) = &mut schema + && !description.is_empty() + { + map.insert("description".to_owned(), description.into()); + } + properties.insert("body".to_owned(), schema); + if body_required { + required.push("body".to_owned()); + } + } + } + schemas.object(properties, required, all_of).to_string() + } +} + +/// Reports whether `t` is an object with fields, or a model, union or +/// intersection of them, whose fields can sit next to the parameters. +fn object_like<'f>(t: Type<'f>, seen: &mut HashSet>) -> bool { + match t { + Type::Object(object) => object.fields().len() > 0, + Type::Model(m) => seen.insert(m) && object_like(m.ty(), seen), + Type::Union(union) => union.members().all(|m| object_like(m, seen)), + Type::Intersection(intersection) => intersection.members().all(|m| object_like(m, seen)), + _ => false, + } +} + +/// What calling a method does, as the hints of its tool say. +struct Behavior { + read_only: bool, + destructive: bool, + idempotent: bool, +} + +impl Behavior { + /// Returns what calling `m` does, from its HTTP method. A method that + /// needs confirmation is destructive. + fn new(m: Method<'_>) -> Behavior { + let confirm = m.confirmation().is_some(); + match m.http_method() { + HttpMethod::Get | HttpMethod::Head | HttpMethod::Options | HttpMethod::Query if !confirm => Behavior { + read_only: true, + destructive: false, + idempotent: true, + }, + HttpMethod::Get | HttpMethod::Head | HttpMethod::Options | HttpMethod::Query => Behavior { + read_only: false, + destructive: true, + idempotent: true, + }, + HttpMethod::Put | HttpMethod::Delete => Behavior { + read_only: false, + destructive: true, + idempotent: true, + }, + HttpMethod::Post | HttpMethod::Patch | HttpMethod::Trace | HttpMethod::Connect | HttpMethod::Other(_) => { + Behavior { + read_only: false, + destructive: confirm, + idempotent: false, + } + } + } + } +} + +/// Returns `s` as a Rust raw string literal, with as many `#`s as it needs. +fn raw_string(s: &str) -> String { + let mut hashes = 1; + while s.contains(&format!("\"{}", "#".repeat(hashes))) { + hashes += 1; + } + let hashes = "#".repeat(hashes); + format!("r{hashes}\"{s}\"{hashes}") +} + +#[cfg(test)] +mod tests { + use forge::{Forge, Plugin}; + + use super::{Generator, raw_string}; + + /// Returns the files that the generator makes from `spec`, by path. + fn generate(spec: &str) -> Vec<(String, String)> { + let forge: Forge = serde_json::from_str(spec).unwrap(); + Generator + .generate(&forge) + .unwrap() + .map(|f| (f.path.to_owned(), f.content)) + .collect() + } + + /// Returns `tools.rs`, generated from `spec`. + fn tools(spec: &str) -> String { + generate(spec) + .into_iter() + .find(|(path, _)| path == "src/tools.rs") + .unwrap() + .1 + } + + /// Returns the input schema of the only tool in `tools`. + fn input_schema(tools: &str) -> serde_json::Value { + let line = tools + .lines() + .find_map(|line| line.trim().strip_prefix("input_schema: r")) + .unwrap(); + let hashes = line.len() - line.trim_start_matches('#').len(); + let literal = line.strip_suffix(',').unwrap(); + serde_json::from_str(&literal[hashes + 1..literal.len() - hashes - 1]).unwrap() + } + + #[test] + fn files() { + let paths: Vec = + generate(r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "paths": {}}"#) + .into_iter() + .map(|(path, _)| path) + .collect(); + assert_eq!(paths, ["Cargo.toml", "src/main.rs", "src/tools.rs"]); + } + + #[test] + fn document() { + let tools = tools( + r#"{"openapi": "3.1.0", "info": {"title": "Example", "version": "1"}, + "servers": [{"url": "https://api.example.com/v1"}], "paths": {}}"#, + ); + assert!( + tools.contains(r#"pub const BASE_URL: &str = "https://api.example.com/v1";"#), + "{tools}" + ); + assert!( + tools.contains(r#"pub const INSTRUCTIONS: &str = "Example";"#), + "{tools}" + ); + assert!(tools.contains("pub const TOOLS: &[Tool] = &[\n];"), "{tools}"); + } + + #[test] + fn get_with_parameters() { + let tools = tools( + r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/accounts/{account_id}/members": {"get": { + "x-fern-sdk-group-name": "accounts.members", "x-fern-sdk-method-name": "list", + "summary": "List members", + "parameters": [ + {"name": "account_id", "in": "path", "required": true, "schema": {"type": "string"}, + "description": "The account."}, + {"name": "page", "in": "query", "schema": {"type": "integer", "default": 1}}, + {"name": "status", "in": "query", "explode": false, + "schema": {"type": "array", "items": {"enum": ["active", "pending"]}}} + ], + "responses": {"200": {"description": "ok"}}}}}}"#, + ); + assert!(tools.contains(r#"name: "accounts_members_list","#), "{tools}"); + assert!(tools.contains(r#"title: "List members","#), "{tools}"); + assert!(tools.contains(r#"method: "GET","#), "{tools}"); + assert!(tools.contains(r#"path: "/accounts/{account_id}/members","#), "{tools}"); + assert!( + tools.contains(r#"Param { name: "account_id", location: Location::Path },"#), + "{tools}" + ); + assert!( + tools.contains(r#"Param { name: "status", location: Location::Query { exploded: false } },"#), + "{tools}" + ); + assert!(tools.contains("body: Body::None,"), "{tools}"); + assert!(tools.contains("read_only: true,"), "{tools}"); + assert!(tools.contains("destructive: false,"), "{tools}"); + assert_eq!( + input_schema(&tools), + serde_json::json!({ + "type": "object", + "properties": { + "account_id": {"type": "string", "description": "The account."}, + "page": {"type": "integer", "default": 1}, + "status": {"type": "array", "items": {"enum": ["active", "pending"]}} + }, + "required": ["account_id"] + }) + ); + } + + #[test] + fn flat_body_with_model() { + let tools = tools( + r##"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/zones/{zone_id}/records": {"post": { + "x-fern-sdk-group-name": "dns.records", "x-fern-sdk-method-name": "create", + "parameters": [{"name": "zone_id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"required": true, "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/Record"}}}}, + "responses": {"200": {"description": "ok"}}}}}, + "components": {"schemas": {"Record": {"type": "object", "required": ["name"], + "properties": {"id": {"type": "string", "readOnly": true}, "name": {"type": "string"}, + "children": {"type": "array", "items": {"$ref": "#/components/schemas/Record"}}}}}}}"##, + ); + assert!(tools.contains("body: Body::Flat,"), "{tools}"); + assert!(tools.contains("read_only: false,"), "{tools}"); + assert!(tools.contains("idempotent: false,"), "{tools}"); + assert_eq!( + input_schema(&tools), + serde_json::json!({ + "type": "object", + "properties": {"zone_id": {"type": "string"}}, + "required": ["zone_id"], + "allOf": [{"$ref": "#/$defs/Record"}], + "$defs": {"Record": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "children": {"type": "array", "items": {"$ref": "#/$defs/Record"}} + }, + "required": ["name"] + }} + }) + ); + } + + #[test] + fn array_body_is_a_field() { + let tools = tools( + r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/keys": {"delete": { + "x-fern-sdk-group-name": "kv", "x-fern-sdk-method-name": "bulk_delete", + "x-forge-require-confirmation": "This operation deletes the keys.", + "requestBody": {"required": true, "description": "The keys.", "content": {"application/json": { + "schema": {"type": "array", "items": {"type": "string"}}}}}, + "responses": {"200": {"description": "ok"}}}}}}"#, + ); + assert!(tools.contains(r#"name: "kv_bulk_delete","#), "{tools}"); + assert!(tools.contains("body: Body::Field,"), "{tools}"); + assert!(tools.contains("destructive: true,"), "{tools}"); + assert!(tools.contains("This operation deletes the keys."), "{tools}"); + assert_eq!( + input_schema(&tools), + serde_json::json!({ + "type": "object", + "properties": {"body": {"type": "array", "items": {"type": "string"}, "description": "The keys."}}, + "required": ["body"] + }) + ); + } + + #[test] + fn skips() { + let tools = tools( + r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": { + "/hidden": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "hidden", + "x-forge-hidden": true, "responses": {"200": {"description": "ok"}}}}, + "/upload": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "upload", + "requestBody": {"content": {"application/octet-stream": {"schema": {"type": "string"}}}}, + "responses": {"200": {"description": "ok"}}}}, + "/download": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "download", + "responses": {"200": {"description": "ok", + "content": {"application/pdf": {"schema": {"type": "string"}}}}}}}, + "/header": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "header", + "parameters": [{"name": "X-Key", "in": "header", "required": true, "schema": {"type": "string"}}], + "responses": {"200": {"description": "ok"}}}}, + "/text": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "text", + "responses": {"200": {"description": "ok", + "content": {"text/csv": {"schema": {"type": "string"}}}}}}} + }}"#, + ); + for skipped in ["a_hidden", "a_upload", "a_download", "a_header"] { + assert!(!tools.contains(&format!("\"{skipped}\"")), "{skipped}: {tools}"); + } + assert!(tools.contains(r#"name: "a_text","#), "{tools}"); + } + + #[test] + fn duplicate_names() { + let forge: Forge = serde_json::from_str( + 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"}}}} + }}"#, + ) + .unwrap(); + let err = Generator.generate(&forge).err().unwrap().to_string(); + assert!(err.contains("are both the tool a_b_c"), "{err}"); + } + + #[test] + fn raw_strings() { + assert_eq!(raw_string("{}"), "r#\"{}\"#"); + assert_eq!(raw_string("\"#"), "r##\"\"#\"##"); + } +} diff --git a/src/bin/forge/mcp/runtime/main.rs b/src/bin/forge/mcp/runtime/main.rs new file mode 100644 index 000000000..79c30dc9e --- /dev/null +++ b/src/bin/forge/mcp/runtime/main.rs @@ -0,0 +1,265 @@ +//! An MCP server that exposes the operations of an API as tools, and calls +//! the API over HTTP when a client calls a tool. +//! +//! The server speaks MCP over standard input and output. It reads the base +//! URL of the API from `API_BASE_URL`, which defaults to the first server of +//! the `OpenAPI` document, and sends `API_TOKEN`, when it is set, as a bearer +//! token. + +use std::borrow::Cow; +use std::fmt::Write; +use std::sync::Arc; + +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation, JsonObject, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerConfig, ToolAnnotations, +}; +use rmcp::service::RequestContext; +use rmcp::{ErrorData, RoleServer, ServerHandler, ServiceExt}; +use serde_json::Value; + +mod tools; + +/// An operation of the API, exposed as a tool. +struct Tool { + /// The name of the tool, unique within the server. + name: &'static str, + /// A short, human-readable title, or an empty string. + title: &'static str, + /// What the tool does, or an empty string. + description: &'static str, + /// The HTTP method the operation is sent with, such as `GET`. + method: &'static str, + /// The path template of the operation, such as `/accounts/{account_id}`. + path: &'static str, + /// The parameters of the operation, which are sent in the path or query. + params: &'static [Param], + /// Where the request body comes from. + body: Body, + /// The JSON Schema of the arguments of the tool. + input_schema: &'static str, + /// Whether the operation changes nothing. + read_only: bool, + /// Whether the operation can change or delete something irreversibly. + destructive: bool, + /// Whether calling the operation twice has the effect of calling it once. + idempotent: bool, +} + +/// A path or query parameter of an operation. +struct Param { + /// The name of the argument, and of the parameter on the wire. + name: &'static str, + location: Location, +} + +/// Where a parameter is sent. +enum Location { + /// In the path, in place of `{name}`. + Path, + /// In the query. An exploded array is sent as one pair per element; any + /// other array is sent as one pair, its elements separated by commas. + Query { exploded: bool }, +} + +/// Where the JSON request body of an operation comes from. +enum Body { + /// The operation has no body. + None, + /// The body is the arguments, without the parameters. + Flat, + /// The body is the argument `body`. + Field, +} + +/// Serves the tools of the API. +#[derive(Clone)] +struct Server { + http: reqwest::Client, + base_url: Arc, + token: Option>, +} + +impl Server { + /// Returns the server, configured from the environment. + fn from_env() -> Server { + let base_url = std::env::var("API_BASE_URL").unwrap_or_else(|_| tools::BASE_URL.to_owned()); + let token = std::env::var("API_TOKEN").ok().filter(|token| !token.is_empty()); + Server { + http: reqwest::Client::new(), + base_url: base_url.trim_end_matches('/').into(), + token: token.map(Into::into), + } + } + + /// Calls the operation of `tool` with `args`, and returns the response + /// body as the result. A response with an error status is a result with + /// an error, so that the client sees it. + async fn call(&self, tool: &Tool, args: JsonObject) -> CallToolResult { + let request = match self.request(tool, args) { + Ok(request) => request, + Err(err) => return CallToolResult::error(vec![ContentBlock::text(err)]), + }; + let response = match request.send().await { + Ok(response) => response, + Err(err) => return CallToolResult::error(vec![ContentBlock::text(format!("request failed: {err}"))]), + }; + let status = response.status(); + let text = match response.text().await { + Ok(text) => text, + Err(err) => { + return CallToolResult::error(vec![ContentBlock::text(format!("reading the response failed: {err}"))]); + } + }; + if status.is_success() { + CallToolResult::success(vec![ContentBlock::text(text)]) + } else { + CallToolResult::error(vec![ContentBlock::text(format!("{status}\n\n{text}"))]) + } + } + + /// Returns the HTTP request that calls the operation of `tool` with + /// `args`. + fn request(&self, tool: &Tool, mut args: JsonObject) -> Result { + let mut path = tool.path.to_owned(); + let mut query = Vec::new(); + for param in tool.params { + let Some(value) = args.remove(param.name) else { + if matches!(param.location, Location::Path) { + return Err(format!("missing argument `{}`", param.name)); + } + continue; + }; + match param.location { + Location::Path => { + let value = scalar(&value).ok_or_else(|| format!("`{}` must be a string or number", param.name))?; + path = path.replace(&format!("{{{}}}", param.name), &encode(&value)); + } + Location::Query { exploded } => match value { + Value::Null => {} + Value::Array(values) if exploded => { + query.extend(values.iter().filter_map(scalar).map(|v| (param.name, v))); + } + Value::Array(values) => { + let values: Vec = values.iter().filter_map(scalar).collect(); + query.push((param.name, values.join(","))); + } + value => { + let value = scalar(&value).ok_or_else(|| format!("`{}` must not be an object", param.name))?; + query.push((param.name, value)); + } + }, + } + } + + let method = reqwest::Method::from_bytes(tool.method.as_bytes()).map_err(|err| err.to_string())?; + let mut url = reqwest::Url::parse(&format!("{}{path}", self.base_url)).map_err(|err| err.to_string())?; + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query); + } + let mut request = self.http.request(method, url); + if let Some(token) = &self.token { + request = request.bearer_auth(token); + } + let body = match tool.body { + Body::None => None, + Body::Flat => Some(Value::Object(args)), + Body::Field => args.remove("body"), + }; + if let Some(body) = body { + request = request + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body.to_string()); + } + Ok(request) + } +} + +/// Returns `value` as it is written in a path or query, or `None` if it is +/// an array or object. +fn scalar(value: &Value) -> Option { + match value { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + Value::Null | Value::Array(_) | Value::Object(_) => None, + } +} + +/// Returns `s` percent-encoded as a path segment. +fn encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for byte in s.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + out.push(char::from(byte)); + } else { + let _ = write!(out, "%{byte:02X}"); + } + } + out +} + +/// Returns the MCP definition of `tool`. +fn definition(tool: &Tool) -> Result { + let schema: JsonObject = serde_json::from_str(tool.input_schema)?; + let description = (!tool.description.is_empty()).then_some(Cow::Borrowed(tool.description)); + let mut annotations = ToolAnnotations::new() + .read_only(tool.read_only) + .destructive(tool.destructive) + .idempotent(tool.idempotent) + .open_world(true); + if !tool.title.is_empty() { + annotations.title = Some(tool.title.to_owned()); + } + let mut definition = rmcp::model::Tool::new_with_raw(tool.name, description, schema).with_annotations(annotations); + if !tool.title.is_empty() { + definition = definition.with_title(tool.title); + } + Ok(definition) +} + +impl ServerHandler for Server { + fn get_info(&self) -> ServerConfig { + let mut config = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))); + if !tools::INSTRUCTIONS.is_empty() { + config = config.with_instructions(tools::INSTRUCTIONS); + } + config + } + + fn list_tools( + &self, + _: Option, + _: RequestContext, + ) -> impl Future> { + let tools = tools::TOOLS + .iter() + .map(definition) + .collect::, _>>() + .map(ListToolsResult::with_all_items) + .map_err(|err| ErrorData::internal_error(err.to_string(), None)); + std::future::ready(tools) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _: RequestContext, + ) -> Result { + let Some(tool) = tools::TOOLS.iter().find(|tool| tool.name == request.name) else { + return Err(ErrorData::invalid_params( + format!("unknown tool `{}`", request.name), + None, + )); + }; + Ok(self.call(tool, request.arguments.unwrap_or_default()).await.into()) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let server = Server::from_env().serve(rmcp::transport::stdio()).await?; + server.waiting().await?; + Ok(()) +} diff --git a/src/bin/forge/mcp/schema.rs b/src/bin/forge/mcp/schema.rs new file mode 100644 index 000000000..dac209e27 --- /dev/null +++ b/src/bin/forge/mcp/schema.rs @@ -0,0 +1,209 @@ +//! JSON Schemas of the arguments of tools. +//! +//! A tool's input schema is an object with a property for each argument. +//! Models are in `$defs` and referenced with `$ref`, so recursive models +//! terminate. Read-only fields are left out, since the schema only +//! describes what a client sends. + +use std::collections::BTreeMap; + +use forge::{EnumValue, Field, Json, Model, Object, Scalar, Type}; +use serde_json::{Map, Value, json}; + +/// Builds the schemas of the arguments of one tool, collecting the models +/// they use. +#[derive(Default)] +pub(super) struct Schemas<'f> { + /// The models referenced so far, by schema name, with their schemas. + /// A model is in the map before its schema is built, so a model that + /// refers to itself is built once. + defs: BTreeMap<&'f str, Option>, +} + +impl<'f> Schemas<'f> { + /// Returns the schema of an object with `properties`, which must be + /// present when listed in `required`, and that is also each schema in + /// `all_of`. The models every schema so far uses are in its `$defs`. + pub(super) fn object(self, properties: Map, required: Vec, all_of: Vec) -> Value { + let mut schema = Map::new(); + schema.insert("type".to_owned(), json!("object")); + schema.insert("properties".to_owned(), Value::Object(properties)); + if !required.is_empty() { + schema.insert("required".to_owned(), json!(required)); + } + if !all_of.is_empty() { + schema.insert("allOf".to_owned(), Value::Array(all_of)); + } + if !self.defs.is_empty() { + let defs = self + .defs + .into_iter() + .map(|(name, schema)| (name.to_owned(), schema.unwrap_or_else(|| json!({})))) + .collect(); + schema.insert("$defs".to_owned(), Value::Object(defs)); + } + Value::Object(schema) + } + + /// Returns the schema of a property for `field`: the schema of its + /// type, with its description and default. + pub(super) fn field(&mut self, field: Field<'f>) -> Value { + let mut schema = self.ty(field.ty()); + 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 schema of `t`. + pub(super) fn ty(&mut self, t: Type<'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(); + 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())); + if let Some(max) = array.max_items() { + schema.insert("maxItems".to_owned(), json!(max)); + } + Value::Object(schema) + } + Type::Object(object) => self.object_schema(object), + Type::Union(union) => { + let members: Vec = union.members().map(|m| self.ty(m)).collect(); + json!({ "anyOf": members }) + } + Type::Intersection(intersection) => { + let members: Vec = intersection.members().map(|m| self.ty(m)).collect(); + json!({ "allOf": members }) + } + Type::Model(model) => self.model(model), + } + } + + /// Returns a reference to the schema of `model`, building it the first + /// time. + fn model(&mut self, model: Model<'f>) -> Value { + let name = model.schema_name(); + if !self.defs.contains_key(name) { + self.defs.insert(name, None); + let mut schema = self.ty(model.ty()); + if let Value::Object(map) = &mut schema + && !model.description().is_empty() + && !map.contains_key("description") + { + map.insert("description".to_owned(), json!(model.description())); + } + self.defs.insert(name, Some(schema)); + } + json!({ "$ref": format!("#/$defs/{}", pointer(name)) }) + } + + /// Returns the schema of `object`. It allows keys other than its + /// fields, since a flat body's schema is one member of an `allOf` with + /// the parameters, which the body's object would otherwise reject. + fn object_schema(&mut self, object: Object<'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)); + 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() { + if let Some(extra) = object.extra() + && !matches!(extra, Type::Scalar(Scalar::Unknown)) + { + schema.insert("additionalProperties".to_owned(), self.ty(extra)); + } + } else { + schema.insert("properties".to_owned(), Value::Object(properties)); + } + if !required.is_empty() { + schema.insert("required".to_owned(), json!(required)); + } + Value::Object(schema) + } +} + +/// 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 | Scalar::Binary => json!({ "type": "string" }), + 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" }), + } +} + +/// Returns `json` as a JSON value. +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, written without a fraction when it is an +/// integer, as `1` rather than `1.0`. +fn number(n: f64) -> Value { + if n.fract() == 0.0 && n.abs() < 2f64.powi(53) { + return json!(n as i64); + } + serde_json::Number::from_f64(n).map_or(Value::Null, Value::Number) +} + +/// Returns `name` escaped as a token of a JSON Pointer. +fn pointer(name: &str) -> String { + name.replace('~', "~0").replace('/', "~1") +} + +#[cfg(test)] +mod tests { + use super::{number, pointer}; + use serde_json::json; + + #[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)); + } + + #[test] + fn pointers() { + assert_eq!(pointer("Tree/$defs/Node"), "Tree~1$defs~1Node"); + assert_eq!(pointer("a~b"), "a~0b"); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 6b85c5fc4..b7d988bb0 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -79,3 +79,36 @@ fn ts() { ); std::fs::remove_dir_all(&out).unwrap(); } + +#[test] +fn mcp() { + let out = std::env::temp_dir().join(format!("forge-cli-mcp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + let result = forge(&["-", "--target", "mcp", "--out", out.to_str().unwrap()], SPEC); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + for name in ["Cargo.toml", "src/main.rs", "src/tools.rs"] { + assert!(out.join(name).is_file(), "{name} is missing"); + } + let tools = std::fs::read_to_string(out.join("src/tools.rs")).unwrap(); + assert!(tools.contains(r#"name: "a_get","#), "{tools}"); + std::fs::remove_dir_all(&out).unwrap(); +} + +/// The MCP server builds. It downloads the server's dependencies, so it only +/// runs when asked for, with `cargo test --test cli -- --ignored`. +#[test] +#[ignore = "downloads the dependencies of the generated crate"] +fn mcp_builds() { + let out = std::env::temp_dir().join(format!("forge-cli-mcp-build-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + let result = forge(&["-", "--target", "mcp", "--out", out.to_str().unwrap()], SPEC); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + let build = Command::new(env!("CARGO")) + .args(["build", "--quiet"]) + .current_dir(&out) + .env_remove("RUSTFLAGS") + .output() + .unwrap(); + assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr)); + std::fs::remove_dir_all(&out).unwrap(); +}