1use std::collections::BTreeMap;
25
26use super::ToolAnnotations;
27use crate::service_discovery::json_lite::{self, JsonValue};
28
29pub const MAX_REF_DEPTH: usize = 24;
31pub const MAX_SPEC_BYTES: usize = 16 * 1024 * 1024;
33pub const MAX_SCHEMA_DEPTH: usize = 64;
37pub const MAX_SCHEMA_NODES: usize = 50_000;
38const MAX_DESCRIPTION: usize = 1024;
40
41#[derive(Debug, Clone, Default)]
43pub struct ToolOptions {
44 pub prefix: Option<String>,
46 pub include: Vec<String>,
48 pub exclude: Vec<String>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Location {
55 Path,
56 Query,
57 Header,
58}
59
60#[derive(Debug, Clone)]
62pub struct OpenApiTool {
63 pub name: String,
64 pub description: String,
65 pub input_schema: String,
67 pub output_schema: Option<String>,
69 pub annotations: ToolAnnotations,
70 pub method: String,
71 pub path: String,
73 pub parameters: Vec<(String, Location)>,
74 pub has_body: bool,
76}
77
78#[derive(Debug, Clone, PartialEq)]
80pub struct ToolRequest {
81 pub method: String,
82 pub path_and_query: String,
84 pub headers: Vec<(String, String)>,
85 pub body: Option<String>,
87}
88
89pub 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
136fn 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
145fn 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
158fn 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
173fn 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 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 ¶ms {
220 let location = match text(p, "in").as_str() {
221 "path" => Location::Path,
222 "query" => Location::Query,
223 "header" => Location::Header,
224 _ => continue, };
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
309fn 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
321fn scalar(value: &JsonValue) -> String {
323 match value {
324 JsonValue::String(s) => s.clone(),
325 other => json_text(other),
326 }
327}
328
329impl OpenApiTool {
330 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 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
398fn 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 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;