1use std::collections::BTreeMap;
24
25use super::ToolAnnotations;
26use crate::service_discovery::json_lite::{self, JsonValue};
27
28pub const MAX_REF_DEPTH: usize = 24;
30pub const MAX_SPEC_BYTES: usize = 16 * 1024 * 1024;
32pub const MAX_SCHEMA_DEPTH: usize = 64;
36pub const MAX_SCHEMA_NODES: usize = 50_000;
37const MAX_DESCRIPTION: usize = 1024;
39
40#[derive(Debug, Clone, Default)]
42pub struct ToolOptions {
43 pub prefix: Option<String>,
45 pub include: Vec<String>,
47 pub exclude: Vec<String>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Location {
54 Path,
55 Query,
56 Header,
57}
58
59#[derive(Debug, Clone)]
61pub struct OpenApiTool {
62 pub name: String,
63 pub description: String,
64 pub input_schema: String,
66 pub output_schema: Option<String>,
68 pub annotations: ToolAnnotations,
69 pub method: String,
70 pub path: String,
72 pub parameters: Vec<(String, Location)>,
73 pub has_body: bool,
75}
76
77#[derive(Debug, Clone, PartialEq)]
79pub struct ToolRequest {
80 pub method: String,
81 pub path_and_query: String,
83 pub headers: Vec<(String, String)>,
84 pub body: Option<String>,
86}
87
88pub 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
135fn 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
144fn 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
157fn 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
172fn 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 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 ¶ms {
219 let location = match text(p, "in").as_str() {
220 "path" => Location::Path,
221 "query" => Location::Query,
222 "header" => Location::Header,
223 _ => continue, };
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
308fn 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
320fn scalar(value: &JsonValue) -> String {
322 match value {
323 JsonValue::String(s) => s.clone(),
324 other => json_text(other),
325 }
326}
327
328impl OpenApiTool {
329 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 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
397fn 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 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;