Skip to main content

rws_server/mcp/
openapi_tools.rs

1//! MCP tools from an OpenAPI 3.x document (18.19.0, #66): each operation
2//! becomes a tool whose input schema holds its path, query and header
3//! parameters and its JSON body, and [`OpenApiTool::request`] turns a
4//! call's arguments back into the HTTP request.
5//!
6//! ```rust
7//! # extern crate rws_server as rust_web_server;
8//! use rust_web_server::mcp::openapi_tools::{tools, ToolOptions};
9//!
10//! let spec = r#"{"openapi":"3.1.0","info":{"title":"Billing","version":"1"},
11//!   "paths":{"/invoices/{id}":{"get":{"operationId":"getInvoice","summary":"Get an invoice",
12//!     "parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}]}}}}"#;
13//! let found = tools(spec, &ToolOptions::default()).unwrap();
14//! assert_eq!("getInvoice", found[0].name);
15//! let call = found[0].request(r#"{"id": 42}"#).unwrap();
16//! assert_eq!(("GET", "/invoices/42"), (call.method.as_str(), call.path_and_query.as_str()));
17//! ```
18//!
19//! Local `$ref`s (`#/components/...`) are inlined, a cycle or more than
20//! [`MAX_REF_DEPTH`] levels becomes `{}`; remote refs are refused. Only
21//! `GET` operations become tools unless [`ToolOptions::include`] names
22//! others: writes are an explicit choice.
23
24use std::collections::BTreeMap;
25
26use super::ToolAnnotations;
27use crate::service_discovery::json_lite::{self, JsonValue};
28
29/// `$ref`s nested deeper than this become `{}`.
30pub const MAX_REF_DEPTH: usize = 24;
31/// Documents larger than this are refused.
32pub const MAX_SPEC_BYTES: usize = 16 * 1024 * 1024;
33/// Inlined schemas nest at most this deep and hold at most this many
34/// values; past either the rest becomes `{}` (a document whose refs fan out
35/// would otherwise grow exponentially).
36pub const MAX_SCHEMA_DEPTH: usize = 64;
37pub const MAX_SCHEMA_NODES: usize = 50_000;
38/// Descriptions are cut to this many bytes.
39const MAX_DESCRIPTION: usize = 1024;
40
41/// Which operations become tools, and how they're named.
42#[derive(Debug, Clone, Default)]
43pub struct ToolOptions {
44    /// Tool names get `<prefix>__` (the gateway's naming).
45    pub prefix: Option<String>,
46    /// operationIds (or generated names) to expose; empty: every `GET`.
47    pub include: Vec<String>,
48    /// operationIds never to expose.
49    pub exclude: Vec<String>,
50}
51
52/// Where a parameter goes.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Location {
55    Path,
56    Query,
57    Header,
58}
59
60/// One operation as an MCP tool.
61#[derive(Debug, Clone)]
62pub struct OpenApiTool {
63    pub name: String,
64    pub description: String,
65    /// The tool's `inputSchema` (JSON text).
66    pub input_schema: String,
67    /// The 2xx JSON response's schema when it is an object (`outputSchema`).
68    pub output_schema: Option<String>,
69    pub annotations: ToolAnnotations,
70    pub method: String,
71    /// `/invoices/{id}`.
72    pub path: String,
73    pub parameters: Vec<(String, Location)>,
74    /// The operation takes a JSON body (the `body` argument).
75    pub has_body: bool,
76}
77
78/// The HTTP request for one call.
79#[derive(Debug, Clone, PartialEq)]
80pub struct ToolRequest {
81    pub method: String,
82    /// Path with parameters filled and percent-encoded, plus the query.
83    pub path_and_query: String,
84    pub headers: Vec<(String, String)>,
85    /// JSON text.
86    pub body: Option<String>,
87}
88
89/// Every operation of `spec` (OpenAPI 3.0 or 3.1, JSON) as a tool.
90pub fn tools(spec: &str, options: &ToolOptions) -> Result<Vec<OpenApiTool>, String> {
91    if spec.len() > MAX_SPEC_BYTES {
92        return Err(format!("the OpenAPI document is over {} MB", MAX_SPEC_BYTES >> 20));
93    }
94    let doc = json_lite::parse(spec).map_err(|e| format!("the OpenAPI document isn't JSON: {e}"))?;
95    match doc.get("openapi").and_then(JsonValue::as_str) {
96        Some(v) if v.starts_with("3.") => {}
97        Some(v) => return Err(format!("OpenAPI {v} isn't supported (3.0 and 3.1 are)")),
98        None if doc.get("swagger").is_some() => return Err("Swagger 2.0 isn't supported; convert it to OpenAPI 3".into()),
99        None => return Err("not an OpenAPI document (no \"openapi\" field)".into()),
100    }
101    let Some(JsonValue::Object(paths)) = doc.get("paths") else { return Ok(Vec::new()) };
102    let mut out: Vec<OpenApiTool> = Vec::new();
103    let mut taken: BTreeMap<String, usize> = BTreeMap::new();
104    for (path, item) in paths {
105        let item = resolve(&doc, item);
106        let shared = item.get("parameters").and_then(JsonValue::as_array).unwrap_or(&[]).to_vec();
107        for method in ["get", "put", "post", "delete", "patch", "head", "options"] {
108            let Some(op) = item.get(method) else { continue };
109            let op = resolve(&doc, op);
110            let generated = sanitize(&format!("{method}_{}", path.trim_matches('/')));
111            let id = op.get("operationId").and_then(JsonValue::as_str).map(sanitize).filter(|s| !s.is_empty()).unwrap_or(generated);
112            let listed = |list: &[String]| list.iter().any(|x| x == &id);
113            if listed(&options.exclude) || (if options.include.is_empty() { method != "get" } else { !listed(&options.include) }) {
114                continue;
115            }
116            let mut tool = tool(&doc, path, method, &op, &shared, &id);
117            let mut name = match &options.prefix {
118                Some(prefix) => format!("{prefix}__{}", tool.name),
119                None => tool.name.clone(),
120            };
121            name.truncate(64);
122            let n = taken.entry(name.clone()).or_insert(0);
123            *n += 1;
124            if *n > 1 {
125                let suffix = format!("_{n}");
126                name.truncate(64 - suffix.len());
127                name.push_str(&suffix);
128            }
129            tool.name = name;
130            out.push(tool);
131        }
132    }
133    Ok(out)
134}
135
136/// MCP tool names: `[A-Za-z0-9_-]`, at most 64.
137fn sanitize(s: &str) -> String {
138    let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect();
139    while out.contains("__") {
140        out = out.replace("__", "_");
141    }
142    out.trim_matches('_').chars().take(64).collect()
143}
144
145/// Follow `$ref`s (local only) until a value without one.
146fn resolve(doc: &JsonValue, value: &JsonValue) -> JsonValue {
147    let mut current = value;
148    for _ in 0..MAX_REF_DEPTH {
149        let Some(reference) = current.get("$ref").and_then(JsonValue::as_str) else { return current.clone() };
150        match pointer(doc, reference) {
151            Some(target) => current = target,
152            None => return JsonValue::Object(Vec::new()),
153        }
154    }
155    JsonValue::Object(Vec::new())
156}
157
158/// A local JSON pointer (`#/components/schemas/Invoice`).
159fn pointer<'a>(doc: &'a JsonValue, reference: &str) -> Option<&'a JsonValue> {
160    let path = reference.strip_prefix("#/")?;
161    let mut cur = doc;
162    for token in path.split('/') {
163        let token = token.replace("~1", "/").replace("~0", "~");
164        cur = match cur {
165            JsonValue::Object(_) => cur.get(&token)?,
166            JsonValue::Array(items) => items.get(token.parse::<usize>().ok()?)?,
167            _ => return None,
168        };
169    }
170    Some(cur)
171}
172
173/// A schema with every local `$ref` inlined (cycles, depth, size → `{}`).
174fn inline(doc: &JsonValue, value: &JsonValue) -> JsonValue {
175    let mut budget = MAX_SCHEMA_NODES;
176    inline_within(doc, value, &mut Vec::new(), 0, &mut budget)
177}
178
179fn inline_within(doc: &JsonValue, value: &JsonValue, stack: &mut Vec<String>, depth: usize, budget: &mut usize) -> JsonValue {
180    if stack.len() >= MAX_REF_DEPTH || depth >= MAX_SCHEMA_DEPTH || *budget == 0 {
181        return JsonValue::Object(Vec::new());
182    }
183    *budget -= 1;
184    match value {
185        JsonValue::Object(pairs) => {
186            if let Some(reference) = value.get("$ref").and_then(JsonValue::as_str) {
187                if stack.iter().any(|r| r == reference) {
188                    return JsonValue::Object(Vec::new());
189                }
190                let Some(target) = pointer(doc, reference) else { return JsonValue::Object(Vec::new()) };
191                stack.push(reference.to_string());
192                let out = inline_within(doc, target, stack, depth + 1, budget);
193                stack.pop();
194                return out;
195            }
196            JsonValue::Object(pairs.iter().map(|(k, v)| (k.clone(), inline_within(doc, v, stack, depth + 1, budget))).collect())
197        }
198        JsonValue::Array(items) => JsonValue::Array(items.iter().map(|v| inline_within(doc, v, stack, depth + 1, budget)).collect()),
199        other => other.clone(),
200    }
201}
202
203fn text(value: &JsonValue, key: &str) -> String {
204    value.get(key).and_then(JsonValue::as_str).unwrap_or("").to_string()
205}
206
207fn tool(doc: &JsonValue, path: &str, method: &str, op: &JsonValue, shared: &[JsonValue], id: &str) -> OpenApiTool {
208    // Operation parameters override path-level ones with the same name+in.
209    let mut params: Vec<JsonValue> = Vec::new();
210    for raw in shared.iter().chain(op.get("parameters").and_then(JsonValue::as_array).unwrap_or(&[]).iter()) {
211        let p = resolve(doc, raw);
212        let key = (text(&p, "name"), text(&p, "in"));
213        params.retain(|q| (text(q, "name"), text(q, "in")) != key);
214        params.push(p);
215    }
216    let mut properties: Vec<(String, JsonValue)> = Vec::new();
217    let mut required: Vec<JsonValue> = Vec::new();
218    let mut parameters = Vec::new();
219    for p in &params {
220        let location = match text(p, "in").as_str() {
221            "path" => Location::Path,
222            "query" => Location::Query,
223            "header" => Location::Header,
224            _ => continue, // cookies: not sent
225        };
226        let name = text(p, "name");
227        if name.is_empty() {
228            continue;
229        }
230        let mut schema = match p.get("schema") {
231            Some(s) => inline(doc, s),
232            None => JsonValue::Object(vec![("type".into(), JsonValue::String("string".into()))]),
233        };
234        let description = text(p, "description");
235        if let (JsonValue::Object(fields), false) = (&mut schema, description.is_empty()) {
236            if !fields.iter().any(|(k, _)| k == "description") {
237                fields.push(("description".into(), JsonValue::String(description)));
238            }
239        }
240        if location == Location::Path || p.get("required") == Some(&JsonValue::Bool(true)) {
241            required.push(JsonValue::String(name.clone()));
242        }
243        properties.push((name.clone(), schema));
244        parameters.push((name, location));
245    }
246    let body = op.get("requestBody").map(|b| resolve(doc, b));
247    let body_schema = body
248        .as_ref()
249        .and_then(|b| b.get("content"))
250        .and_then(|c| match c {
251            JsonValue::Object(types) => types.iter().find(|(t, _)| t.starts_with("application/json") || t.ends_with("+json")).map(|(_, v)| v.clone()),
252            _ => None,
253        })
254        .and_then(|media| media.get("schema").map(|s| inline(doc, s)));
255    let has_body = body_schema.is_some();
256    if let Some(schema) = body_schema {
257        if body.as_ref().and_then(|b| b.get("required")) == Some(&JsonValue::Bool(true)) {
258            required.push(JsonValue::String("body".into()));
259        }
260        properties.push(("body".into(), schema));
261    }
262    let mut input = vec![("type".to_string(), JsonValue::String("object".into())), ("properties".to_string(), JsonValue::Object(properties))];
263    if !required.is_empty() {
264        input.push(("required".to_string(), JsonValue::Array(required)));
265    }
266    let output_schema = op
267        .get("responses")
268        .and_then(|r| match r {
269            JsonValue::Object(codes) => codes.iter().find(|(c, _)| c.starts_with('2')).map(|(_, v)| resolve(doc, v)),
270            _ => None,
271        })
272        .and_then(|resp| resp.get("content").and_then(|c| c.get("application/json")).and_then(|m| m.get("schema")).map(|s| inline(doc, s)))
273        .filter(|s| s.get("type").and_then(JsonValue::as_str) == Some("object"))
274        .map(|s| json_text(&s));
275    let mut description = [text(op, "summary"), text(op, "description")].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join("\n\n");
276    if description.is_empty() {
277        description = format!("{} {path}", method.to_ascii_uppercase());
278    }
279    if description.len() > MAX_DESCRIPTION {
280        let mut cut = MAX_DESCRIPTION;
281        while !description.is_char_boundary(cut) {
282            cut -= 1;
283        }
284        description.truncate(cut);
285    }
286    let annotations = ToolAnnotations {
287        read_only_hint: Some(matches!(method, "get" | "head" | "options")),
288        destructive_hint: (method == "delete").then_some(true),
289        idempotent_hint: matches!(method, "get" | "head" | "options" | "put" | "delete").then_some(true),
290        open_world_hint: Some(true),
291    };
292    OpenApiTool {
293        name: id.to_string(),
294        description,
295        input_schema: json_text(&JsonValue::Object(input)),
296        output_schema,
297        annotations,
298        method: method.to_ascii_uppercase(),
299        path: path.to_string(),
300        parameters,
301        has_body,
302    }
303}
304
305fn json_text(value: &JsonValue) -> String {
306    json_lite::to_text(value)
307}
308
309/// Percent-encode everything but unreserved characters.
310fn encode(s: &str) -> String {
311    let mut out = String::with_capacity(s.len());
312    for b in s.bytes() {
313        match b {
314            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char),
315            _ => out.push_str(&format!("%{b:02X}")),
316        }
317    }
318    out
319}
320
321/// A scalar argument as text; anything else as its JSON.
322fn scalar(value: &JsonValue) -> String {
323    match value {
324        JsonValue::String(s) => s.clone(),
325        other => json_text(other),
326    }
327}
328
329impl OpenApiTool {
330    /// The request for a call with `arguments` (a JSON object).
331    pub fn request(&self, arguments: &str) -> Result<ToolRequest, String> {
332        let args = if arguments.trim().is_empty() { JsonValue::Object(Vec::new()) } else { json_lite::parse(arguments).map_err(|e| format!("arguments aren't JSON: {e}"))? };
333        if !matches!(args, JsonValue::Object(_)) {
334            return Err("arguments must be an object".into());
335        }
336        let mut path = self.path.clone();
337        let mut query: Vec<String> = Vec::new();
338        let mut headers = Vec::new();
339        for (name, location) in &self.parameters {
340            let Some(value) = args.get(name).filter(|v| !matches!(v, JsonValue::Null)) else {
341                if *location == Location::Path {
342                    return Err(format!("missing path parameter {name}"));
343                }
344                continue;
345            };
346            match location {
347                Location::Path => path = path.replace(&format!("{{{name}}}"), &encode(&scalar(value))),
348                Location::Query => match value {
349                    JsonValue::Array(items) => query.extend(items.iter().map(|v| format!("{}={}", encode(name), encode(&scalar(v))))),
350                    other => query.push(format!("{}={}", encode(name), encode(&scalar(other)))),
351                },
352                Location::Header => {
353                    let text = scalar(value);
354                    if text.bytes().any(|b| b < 0x20 || b == 0x7f) {
355                        return Err(format!("header {name} can't hold control characters"));
356                    }
357                    headers.push((name.clone(), text));
358                }
359            }
360        }
361        let body = match (self.has_body, args.get("body")) {
362            (true, Some(v)) => Some(json_text(v)),
363            _ => None,
364        };
365        let path_and_query = if query.is_empty() { path } else { format!("{path}?{}", query.join("&")) };
366        Ok(ToolRequest { method: self.method.clone(), path_and_query, headers, body })
367    }
368}
369
370impl super::McpServer {
371    /// Make `app`'s operations, as `spec` (its OpenAPI document) describes
372    /// them, tools of this server, run in-process: a call builds the
373    /// request and hands it to `app` — no HTTP round trip, the app's own
374    /// middleware applying as to any request. With [`AppWithState`] the
375    /// document comes from [`AppWithState::openapi_spec`].
376    ///
377    /// [`AppWithState`]: crate::state::AppWithState
378    /// [`AppWithState::openapi_spec`]: crate::state::AppWithState::openapi_spec
379    pub fn openapi_tools<A>(mut self, app: A, spec: &str, options: &ToolOptions) -> Result<Self, String>
380    where
381        A: crate::application::Application + Send + Sync + 'static,
382    {
383        let app = std::sync::Arc::new(app);
384        for tool in tools(spec, options)? {
385            let (name, description, input) = (tool.name.clone(), tool.description.clone(), tool.input_schema.clone());
386            let (output, annotations) = (tool.output_schema.clone(), tool.annotations);
387            let (app, tool) = (std::sync::Arc::clone(&app), std::sync::Arc::new(tool));
388            self = self.tool_with_context(&name, &description, &input, move |ctx, args| call_in_process(app.as_ref(), &tool, args, &ctx));
389            if let Some(schema) = output {
390                self = self.tool_output_schema(&name, &schema);
391            }
392            self = self.tool_annotations(&name, ToolAnnotations { open_world_hint: Some(false), ..annotations });
393        }
394        Ok(self)
395    }
396}
397
398/// One call, handed to `app` as a request.
399fn call_in_process(app: &dyn crate::application::Application, tool: &OpenApiTool, arguments: &str, ctx: &super::McpContext) -> Result<super::McpContent, String> {
400    use crate::header::Header;
401    let call = tool.request(arguments)?;
402    let mut headers = vec![Header::new("Host", "localhost".to_string()), Header::new("Accept", "application/json".to_string())];
403    // The MCP caller's identity, for the app's own checks.
404    if let Some(sub) = ctx.auth_claims.as_deref().and_then(|c| json_lite::parse(c).ok()).and_then(|c| c.get("sub").and_then(JsonValue::as_str).map(str::to_string)) {
405        headers.push(Header::new("X-Rws-Mcp-Caller", sub));
406    }
407    headers.extend(call.headers.iter().filter(|(n, _)| !n.eq_ignore_ascii_case("x-rws-mcp-caller")).map(|(n, v)| Header::new(n.clone(), v.clone())));
408    let body = call.body.unwrap_or_default().into_bytes();
409    if !body.is_empty() {
410        headers.push(Header::new("Content-Type", "application/json".to_string()));
411        headers.push(Header::new("Content-Length", body.len().to_string()));
412    }
413    let request = crate::request::Request { method: call.method.clone(), request_uri: call.path_and_query.clone(), http_version: "HTTP/1.1".to_string(), headers, body: body.into() };
414    let connection = crate::server::ConnectionInfo::default();
415    let response = app.execute(&request, &connection)?;
416    let content_type = response.content_range_list.first().map(|c| c.content_type.clone()).unwrap_or_default();
417    let text = response.content_range_list.iter().map(|c| String::from_utf8_lossy(&c.body).into_owned()).collect::<String>();
418    if !(200..300).contains(&response.status_code) {
419        return Err(format!("HTTP {}: {text}", response.status_code));
420    }
421    Ok(match (content_type.contains("json"), tool.output_schema.is_some()) {
422        (true, true) if text.trim_start().starts_with('{') => super::McpContent::structured(text),
423        (true, _) => super::McpContent::json(text),
424        _ => super::McpContent::text(text),
425    })
426}
427
428#[cfg(test)]
429mod tests;