Skip to main content

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