1use std::path::PathBuf;
10use std::sync::Arc;
11
12use aws_lc_rs::digest::{SHA256, digest};
13use base64::Engine as _;
14use futures_util::future::BoxFuture;
15use serde_json::{Map, Value, json};
16use tokio_util::sync::CancellationToken;
17use yapi_agent::{Tool, UpdateSink};
18use yapi_types::event::ToolResult;
19use yapi_types::message::{ContentBlock, ToolDeclaration, blocks_text};
20
21use super::client::{RequestOptions, ResourceKind};
22use super::connection::Connection;
23use super::content::{block_to_llm, to_llm_content};
24use crate::tools::truncate::{format_size, truncate_middle};
25
26const MAX_TOOL_NAME_LENGTH: usize = 64;
28pub const OUTPUT_MAX_BYTES: usize = 20 * 1024;
30pub const READ_MCP_RESOURCE_TOOL: &str = "read_mcp_resource";
32pub const LIST_MCP_RESOURCES_TOOL: &str = "list_mcp_resources";
34pub const LIST_MCP_RESOURCE_TEMPLATES_TOOL: &str = "list_mcp_resource_templates";
36
37pub fn tool_name(server: &str, tool: &str, is_taken: impl Fn(&str) -> bool) -> String {
40 let name: String = format!("mcp__{server}__{tool}")
41 .chars()
42 .map(|c| {
43 if c.is_ascii_alphanumeric() || c == '_' {
44 c
45 } else {
46 '_'
47 }
48 })
49 .collect();
50 if name.len() <= MAX_TOOL_NAME_LENGTH && !is_taken(&name) {
51 return name;
52 }
53 let digest = digest(&SHA256, format!("{server}\0{tool}").as_bytes());
54 let hash = crate::time::hex(&digest.as_ref()[..4]);
55 format!(
56 "{}_{hash}",
57 &name[..name.len().min(MAX_TOOL_NAME_LENGTH - hash.len() - 1)]
58 )
59}
60
61pub fn server_tool_label(tools: &crate::extensions::Tools, name: &str) -> Option<String> {
64 tools.with(|registry| {
65 let tool = registry.get(name)?;
66 let namespace = tool.namespace.as_ref()?;
67 namespace
68 .name
69 .starts_with(&super::config::namespace(""))
70 .then(|| tool.tool.label().to_owned())
71 })
72}
73
74async fn save_temp(data: &[u8], extension: &str) -> Result<PathBuf, String> {
76 let path = std::env::temp_dir().join(format!(
77 "yapi-mcp-{}{extension}",
78 crate::time::random_hex(8)
79 ));
80 let mut options = tokio::fs::OpenOptions::new();
81 options.write(true).create_new(true);
82 #[cfg(unix)]
83 options.mode(0o600);
84 let mut file = options
85 .open(&path)
86 .await
87 .map_err(|error| error.to_string())?;
88 tokio::io::AsyncWriteExt::write_all(&mut file, data)
89 .await
90 .map_err(|error| error.to_string())?;
91 Ok(path)
92}
93
94pub async fn limit_content(content: Vec<ContentBlock>) -> (Vec<ContentBlock>, Option<PathBuf>) {
98 let combined = blocks_text(&content, "\n");
99 let truncation = truncate_middle(&combined, OUTPUT_MAX_BYTES);
100 if !truncation.truncated {
101 return (content, None);
102 }
103 let (where_, path) = match save_temp(combined.as_bytes(), ".txt").await {
104 Ok(path) => (
105 format!(
106 "[Full output: {} (read it with offset/limit)]",
107 path.display()
108 ),
109 Some(path),
110 ),
111 Err(error) => (format!("[Could not save the full output: {error}]"), None),
112 };
113 let tokens = truncation.total_bytes.div_ceil(4);
114 let mut limited = vec![ContentBlock::text(format!(
115 "Warning: truncated output (original token count: {tokens})\nTotal output lines: {}\n\n{}\n\n{where_}",
116 truncation.total_lines, truncation.content
117 ))];
118 limited.extend(
119 content
120 .into_iter()
121 .filter(|block| matches!(block, ContentBlock::Image(_))),
122 );
123 (limited, path)
124}
125
126fn extension_of(uri: &str) -> String {
128 let path = reqwest::Url::parse(uri)
129 .map(|url| url.path().to_owned())
130 .unwrap_or_else(|_| uri.to_owned());
131 let Some(dot) = path.rfind('.') else {
132 return ".bin".into();
133 };
134 let suffix = &path[dot + 1..];
135 if (1..=8).contains(&suffix.len()) && suffix.chars().all(|c| c.is_ascii_alphanumeric()) {
136 format!(".{suffix}")
137 } else {
138 ".bin".into()
139 }
140}
141
142fn is_text_mime(mime: Option<&str>) -> bool {
144 let Some(mime) = mime else {
145 return false;
146 };
147 let kind = mime
148 .split(';')
149 .next()
150 .unwrap_or_default()
151 .trim()
152 .to_ascii_lowercase();
153 kind.starts_with("text/")
154 || kind == "application/json"
155 || kind.ends_with("+json")
156 || kind.ends_with("+xml")
157}
158
159fn field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
160 value.get(key).and_then(Value::as_str)
161}
162
163async fn block_content(server: &str, block: &Value, readable_resources: bool) -> ContentBlock {
165 match field(block, "type") {
166 Some("resource_link") => {
167 let mut details = Vec::new();
168 if let Some(mime) = field(block, "mimeType").filter(|mime| !mime.is_empty()) {
169 details.push(mime.to_owned());
170 }
171 if let Some(size) = block.get("size").and_then(Value::as_f64) {
172 details.push(format_size(size as usize));
173 }
174 let details = if details.is_empty() {
175 String::new()
176 } else {
177 format!(" ({})", details.join(", "))
178 };
179 let description = field(block, "description")
180 .filter(|text| !text.is_empty())
181 .map(|text| format!(": {text}"))
182 .unwrap_or_default();
183 let read = if readable_resources {
184 format!(". Read it with {READ_MCP_RESOURCE_TOOL} (server \"{server}\")")
185 } else {
186 String::new()
187 };
188 let title = field(block, "title")
189 .or(field(block, "name"))
190 .unwrap_or_default();
191 ContentBlock::text(format!(
192 "[Resource {} \"{title}\"{details}{description}{read}]",
193 field(block, "uri").unwrap_or_default()
194 ))
195 }
196 Some("resource")
197 if block["resource"].get("blob").is_some()
198 && !field(&block["resource"], "mimeType")
199 .is_some_and(|mime| mime.starts_with("image/")) =>
200 {
201 let resource = &block["resource"];
202 let uri = field(resource, "uri").unwrap_or_default();
203 let mime = field(resource, "mimeType");
204 let data = node_base64(field(resource, "blob").unwrap_or_default());
205 if is_text_mime(mime) {
206 return ContentBlock::text(String::from_utf8_lossy(&data));
207 }
208 let kind = format!(
209 "{}, {}",
210 mime.unwrap_or("unknown type"),
211 format_size(data.len())
212 );
213 match save_temp(&data, &extension_of(uri)).await {
214 Ok(path) => ContentBlock::text(format!(
215 "[Binary resource {uri} ({kind}) saved to {}]",
216 path.display()
217 )),
218 Err(error) => ContentBlock::text(format!(
219 "[Binary resource {uri} ({kind}) could not be saved: {error}]"
220 )),
221 }
222 }
223 _ => block_to_llm(block),
224 }
225}
226
227fn node_base64(text: &str) -> Vec<u8> {
230 const LENIENT: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new(
231 &base64::alphabet::STANDARD,
232 base64::engine::GeneralPurposeConfig::new()
233 .with_decode_padding_mode(base64::engine::DecodePaddingMode::RequireNone)
234 .with_decode_allow_trailing_bits(true),
235 );
236 let mut digits: Vec<u8> = text
237 .bytes()
238 .take_while(|&byte| byte != b'=')
239 .filter_map(|byte| match byte {
240 b'-' => Some(b'+'),
241 b'_' => Some(b'/'),
242 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' => Some(byte),
243 _ => None,
244 })
245 .collect();
246 if digits.len() % 4 == 1 {
248 digits.pop();
249 }
250 LENIENT.decode(digits).unwrap_or_default()
251}
252
253pub async fn model_content(
255 server: &str,
256 blocks: &[Value],
257 readable_resources: bool,
258) -> Vec<ContentBlock> {
259 let mut content = Vec::new();
260 for block in blocks {
261 content.push(block_content(server, block, readable_resources).await);
262 }
263 content
264}
265
266async fn limit_with_details(
269 content: Vec<ContentBlock>,
270 server: &str,
271 tool: &str,
272) -> (Vec<ContentBlock>, Value) {
273 let (content, path) = limit_content(content).await;
274 let mut details = json!({"server": server, "tool": tool});
275 if let Some(path) = path {
276 details["fullOutputPath"] = json!(path.display().to_string());
277 }
278 (content, details)
279}
280
281pub async fn convert_result(
283 server: &str,
284 tool: &str,
285 result: Value,
286 readable_resources: bool,
287) -> ToolResult {
288 let blocks = result
289 .get("content")
290 .and_then(Value::as_array)
291 .cloned()
292 .unwrap_or_default();
293 let mut converted = if blocks.is_empty() {
294 to_llm_content(&result)
295 } else {
296 model_content(server, &blocks, readable_resources).await
297 };
298 let is_error = result.get("isError").and_then(Value::as_bool) == Some(true);
299 if is_error && blocks_text(&converted, "\n").is_empty() {
300 converted.push(ContentBlock::text(format!(
301 "MCP tool {server}/{tool} returned an error"
302 )));
303 }
304 let (content, details) = limit_with_details(converted, server, tool).await;
305 let mut structured = result;
306 if let Some(object) = structured.as_object_mut() {
307 object.shift_remove("_meta");
308 }
309 ToolResult {
310 content,
311 details: Some(details),
312 structured_content: Some(structured),
313 is_error: is_error.then_some(true),
314 ..ToolResult::default()
315 }
316}
317
318fn parameters(schema: &Map<String, Value>) -> Value {
321 let mut parameters = schema.clone();
322 if !parameters.contains_key("type") {
323 parameters.insert("type".into(), json!("object"));
324 }
325 if !parameters.contains_key("properties") {
326 parameters.insert("properties".into(), json!({}));
327 }
328 Value::Object(parameters)
329}
330
331pub fn annotations(tool: &super::Tool) -> Option<Value> {
334 const HINTS: [&str; 4] = [
335 "readOnlyHint",
336 "destructiveHint",
337 "idempotentHint",
338 "openWorldHint",
339 ];
340 let given = tool.annotations.as_ref()?;
341 let hints: serde_json::Map<String, Value> = HINTS
342 .iter()
343 .filter_map(|hint| {
344 Some((
345 (*hint).to_owned(),
346 given.get(*hint).filter(|value| value.is_boolean())?.clone(),
347 ))
348 })
349 .collect();
350 (!hints.is_empty()).then_some(Value::Object(hints))
351}
352
353pub struct McpTool {
355 declaration: ToolDeclaration,
356 output_schema: Value,
357 label: String,
358 server: String,
359 tool: String,
360 connection: Arc<Connection>,
361}
362
363impl McpTool {
364 pub fn new(connection: Arc<Connection>, tool: &super::Tool, name: String) -> McpTool {
366 let server = connection.name().to_owned();
367 let title = tool.title.clone().or_else(|| {
368 tool.annotations
369 .as_ref()
370 .and_then(|annotations| annotations.get("title"))
371 .and_then(Value::as_str)
372 .map(str::to_owned)
373 });
374 let description = tool
375 .description
376 .as_deref()
377 .map(str::trim)
378 .filter(|text| !text.is_empty())
379 .map(str::to_owned)
380 .or(title)
381 .unwrap_or_else(|| format!("MCP tool {} from server {server}", tool.name));
382 McpTool {
383 declaration: ToolDeclaration {
384 name,
385 description,
386 parameters: parameters(&tool.input_schema),
387 constrained_sampling: None,
388 },
389 output_schema: result_schema(tool.output_schema.as_ref()),
390 label: format!("{server}/{}", tool.name),
391 server,
392 tool: tool.name.clone(),
393 connection,
394 }
395 }
396}
397
398fn result_schema(structured: Option<&Map<String, Value>>) -> Value {
401 let mut properties = Map::new();
402 properties.insert(
403 "content".into(),
404 json!({"type": "array", "items": {"type": "object"}}),
405 );
406 if let Some(structured) = structured {
407 properties.insert(
408 "structuredContent".into(),
409 Value::Object(structured.clone()),
410 );
411 }
412 properties.insert("isError".into(), json!({"type": "boolean"}));
413 properties.insert("_meta".into(), json!({"type": "object"}));
414 json!({"type": "object", "properties": properties, "required": ["content"]})
415}
416
417impl Tool for McpTool {
418 fn declaration(&self) -> &ToolDeclaration {
419 &self.declaration
420 }
421
422 fn output_schema(&self) -> Option<&Value> {
423 Some(&self.output_schema)
424 }
425
426 fn label(&self) -> &str {
427 &self.label
428 }
429
430 fn execute(
431 &self,
432 _call_id: String,
433 args: Value,
434 cancel: CancellationToken,
435 updates: UpdateSink,
436 ) -> BoxFuture<'_, Result<ToolResult, String>> {
437 Box::pin(async move {
438 let (server, tool) = (self.server.clone(), self.tool.clone());
439 let options = RequestOptions {
440 cancel: Some(cancel),
441 timeout: Some(self.connection.timeout()),
442 on_progress: Some(Arc::new(move |progress: &Value| {
443 let total = progress
444 .get("total")
445 .filter(|total| !total.is_null())
446 .map(|total| format!("/{total}"))
447 .unwrap_or_default();
448 let message = progress
449 .get("message")
450 .and_then(Value::as_str)
451 .map(str::to_owned)
452 .unwrap_or_else(|| format!("Progress {}{total}", progress["progress"]));
453 updates(ToolResult {
454 content: vec![ContentBlock::text(message)],
455 details: Some(json!({"server": server, "tool": tool})),
456 ..ToolResult::default()
457 });
458 })),
459 };
460 let args = if args.is_null() { json!({}) } else { args };
461 let result = self
462 .connection
463 .call_tool(&self.tool, args, options)
464 .await
465 .map_err(|error| error.to_string())?;
466 Ok(convert_result(
467 &self.server,
468 &self.tool,
469 result,
470 self.connection.readable_resources(),
471 )
472 .await)
473 })
474 }
475}
476
477#[derive(Clone, Copy, Debug, PartialEq, Eq)]
479enum ResourceTool {
480 List(ResourceKind),
481 Read,
482}
483
484pub type ResourceServers = Arc<dyn Fn() -> Vec<Arc<Connection>> + Send + Sync>;
486
487pub struct McpResourceTool {
491 declaration: ToolDeclaration,
492 output_schema: Value,
493 kind: ResourceTool,
494 servers: ResourceServers,
495}
496
497pub fn is_app_resource(item: &Value) -> bool {
499 let uri = field(item, "uri")
500 .or_else(|| field(item, "uriTemplate"))
501 .unwrap_or_default();
502 let mime = field(item, "mimeType")
503 .unwrap_or_default()
504 .to_ascii_lowercase();
505 let profile = mime.split(';').skip(1).any(|parameter| {
506 parameter.split_once('=').is_some_and(|(key, value)| {
507 key.trim() == "profile" && value.trim().trim_matches('"') == "mcp-app"
508 })
509 });
510 uri.starts_with("ui://") || profile
511}
512
513fn listed(server: &str, item: &Value) -> Value {
515 let mut out = Map::new();
516 out.insert("server".into(), json!(server));
517 if let Some(object) = item.as_object() {
518 for (key, value) in object {
519 if key != "_meta" && key != "icons" {
520 out.insert(key.clone(), value.clone());
521 }
522 }
523 }
524 Value::Object(out)
525}
526
527fn string_argument(args: &Value, key: &str) -> Result<Option<String>, String> {
528 match args.get(key) {
529 None | Some(Value::Null) => Ok(None),
530 Some(Value::String(value)) => {
531 Ok(Some(value.trim().to_owned()).filter(|value| !value.is_empty()))
532 }
533 Some(_) => Err(format!("{key} must be a string")),
534 }
535}
536
537pub fn resource_tools(servers: ResourceServers) -> Vec<McpResourceTool> {
539 let string = |description: &str| json!({"type": "string", "description": description});
540 let list_parameters = json!({
541 "type": "object",
542 "properties": {
543 "server": string("MCP server name. Omit to list every server with resources."),
544 "cursor": string("Opaque cursor from a previous call with the same server; omit for the first page."),
545 },
546 "additionalProperties": false,
547 });
548 let read_parameters = json!({
549 "type": "object",
550 "properties": {
551 "server": string("MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."),
552 "uri": string("Resource URI to read. Must be one of the URIs returned by list_mcp_resources."),
553 },
554 "required": ["server", "uri"],
555 "additionalProperties": false,
556 });
557 let declare = |name: &str, description: &str, parameters: Value| ToolDeclaration {
558 name: name.into(),
559 description: description.into(),
560 parameters,
561 constrained_sampling: None,
562 };
563 let optional_string = json!({"type": "string"});
565 let listing_errors = json!({
566 "type": "array",
567 "description": "Servers that could not be listed",
568 "items": {
569 "type": "object",
570 "properties": {"server": {"type": "string"}, "error": {"type": "string"}},
571 "required": ["server", "error"],
572 },
573 });
574 let list_output = json!({
575 "type": "object",
576 "properties": {
577 "server": optional_string,
578 "resources": {
579 "type": "array",
580 "items": {
581 "type": "object",
582 "properties": {
583 "server": {"type": "string"},
584 "uri": {"type": "string"},
585 "name": {"type": "string"},
586 "title": optional_string,
587 "description": optional_string,
588 "mimeType": optional_string,
589 "size": {"type": "number"},
590 },
591 "required": ["server", "uri", "name"],
592 },
593 },
594 "nextCursor": optional_string,
595 "errors": listing_errors,
596 },
597 "required": ["resources"],
598 });
599 let list_templates_output = json!({
600 "type": "object",
601 "properties": {
602 "server": optional_string,
603 "resourceTemplates": {
604 "type": "array",
605 "items": {
606 "type": "object",
607 "properties": {
608 "server": {"type": "string"},
609 "uriTemplate": {"type": "string", "description": "RFC 6570 URI template"},
610 "name": {"type": "string"},
611 "title": optional_string,
612 "description": optional_string,
613 "mimeType": optional_string,
614 },
615 "required": ["server", "uriTemplate", "name"],
616 },
617 },
618 "nextCursor": optional_string,
619 "errors": listing_errors,
620 },
621 "required": ["resourceTemplates"],
622 });
623 let read_output = json!({
624 "type": "object",
625 "properties": {
626 "server": {"type": "string"},
627 "uri": {"type": "string"},
628 "contents": {
629 "type": "array",
630 "items": {
631 "anyOf": [
632 {
633 "type": "object",
634 "properties": {"uri": {"type": "string"}, "mimeType": optional_string, "text": {"type": "string"}},
635 "required": ["uri", "text"],
636 },
637 {
638 "type": "object",
639 "properties": {
640 "uri": {"type": "string"},
641 "mimeType": optional_string,
642 "blob": {"type": "string", "description": "base64"},
643 },
644 "required": ["uri", "blob"],
645 },
646 ],
647 },
648 },
649 },
650 "required": ["server", "uri", "contents"],
651 });
652 vec![
653 McpResourceTool {
654 declaration: declare(
655 LIST_MCP_RESOURCES_TOOL,
656 "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.",
657 list_parameters.clone(),
658 ),
659 output_schema: list_output,
660 kind: ResourceTool::List(ResourceKind::Resources),
661 servers: Arc::clone(&servers),
662 },
663 McpResourceTool {
664 declaration: declare(
665 LIST_MCP_RESOURCE_TEMPLATES_TOOL,
666 "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.",
667 list_parameters,
668 ),
669 output_schema: list_templates_output,
670 kind: ResourceTool::List(ResourceKind::Templates),
671 servers: Arc::clone(&servers),
672 },
673 McpResourceTool {
674 declaration: declare(
675 READ_MCP_RESOURCE_TOOL,
676 "Read a specific resource from an MCP server given the server name and resource URI.",
677 read_parameters,
678 ),
679 output_schema: read_output,
680 kind: ResourceTool::Read,
681 servers,
682 },
683 ]
684}
685
686impl McpResourceTool {
687 fn find(&self, name: &str) -> Result<Arc<Connection>, String> {
688 let servers = (self.servers)();
689 if let Some(server) = servers.iter().find(|server| server.name() == name) {
690 return Ok(Arc::clone(server));
691 }
692 let available: Vec<&str> = servers.iter().map(|server| server.name()).collect();
693 Err(if available.is_empty() {
694 format!("MCP server \"{name}\" has no resources")
695 } else {
696 format!(
697 "MCP server \"{name}\" has no resources. Servers with resources: {}",
698 available.join(", ")
699 )
700 })
701 }
702
703 async fn list(
704 &self,
705 kind: ResourceKind,
706 args: &Value,
707 cancel: &CancellationToken,
708 ) -> Result<Value, String> {
709 let key = kind.key();
710 let server_name = string_argument(args, "server")?;
711 let cursor = string_argument(args, "cursor")?;
712 let options = |server: &Connection| RequestOptions {
713 cancel: Some(cancel.clone()),
714 timeout: Some(server.timeout()),
715 on_progress: None,
716 };
717 if let Some(name) = server_name {
718 let server = self.find(&name)?;
719 let (items, next) = server
720 .resources_page(kind, cursor, options(&server))
721 .await
722 .map_err(|error| error.to_string())?;
723 let items: Vec<Value> = items
724 .iter()
725 .filter(|item| !is_app_resource(item))
726 .map(|item| listed(server.name(), item))
727 .collect();
728 let mut payload = json!({ "server": server.name(), key: items });
729 if let Some(next) = next {
730 payload["nextCursor"] = json!(next);
731 }
732 return Ok(payload);
733 }
734 if cursor.is_some() {
735 return Err("cursor can only be used when a server is specified".into());
736 }
737 let mut servers = (self.servers)();
738 servers.sort_by(|a, b| yapi_types::collate::locale_compare(a.name(), b.name()));
739 let mut items = Vec::new();
740 let mut errors = Vec::new();
741 for server in &servers {
742 match server.all_resources(kind, options(server)).await {
743 Ok(found) => items.extend(
744 found
745 .iter()
746 .filter(|item| !is_app_resource(item))
747 .map(|item| listed(server.name(), item)),
748 ),
749 Err(error) => {
750 errors.push(json!({"server": server.name(), "error": error.to_string()}))
751 }
752 }
753 }
754 let mut payload = json!({ key: items });
755 if !errors.is_empty() {
756 payload["errors"] = json!(errors);
757 }
758 Ok(payload)
759 }
760
761 async fn read(&self, args: &Value, cancel: &CancellationToken) -> Result<ToolResult, String> {
762 let name = string_argument(args, "server")?.ok_or("server must be provided")?;
763 let uri = string_argument(args, "uri")?.ok_or("uri must be provided")?;
764 let server = self.find(&name)?;
765 let result = server
766 .read_resource(
767 &uri,
768 RequestOptions {
769 cancel: Some(cancel.clone()),
770 timeout: Some(server.timeout()),
771 on_progress: None,
772 },
773 )
774 .await
775 .map_err(|error| error.to_string())?;
776 let contents = result["contents"].as_array().cloned().unwrap_or_default();
777 let mut blocks = Vec::new();
778 for item in &contents {
779 if contents.len() > 1 {
780 blocks.push(json!({"type": "text", "text": format!("{}:", field(item, "uri").unwrap_or_default())}));
781 }
782 blocks.push(json!({"type": "resource", "resource": item}));
783 }
784 let converted = model_content(server.name(), &blocks, false).await;
785 let converted = if converted.is_empty() {
786 vec![ContentBlock::text(format!("Resource {uri} is empty."))]
787 } else {
788 converted
789 };
790 let (content, details) =
791 limit_with_details(converted, server.name(), READ_MCP_RESOURCE_TOOL).await;
792 let contents: Vec<Value> = contents
793 .into_iter()
794 .map(|mut item| {
795 if let Some(object) = item.as_object_mut() {
796 object.shift_remove("_meta");
797 }
798 item
799 })
800 .collect();
801 Ok(ToolResult {
802 content,
803 details: Some(details),
804 structured_content: Some(
805 json!({"server": server.name(), "uri": uri, "contents": contents}),
806 ),
807 ..ToolResult::default()
808 })
809 }
810}
811
812impl Tool for McpResourceTool {
813 fn declaration(&self) -> &ToolDeclaration {
814 &self.declaration
815 }
816
817 fn output_schema(&self) -> Option<&Value> {
818 Some(&self.output_schema)
819 }
820
821 fn execute(
822 &self,
823 _call_id: String,
824 args: Value,
825 cancel: CancellationToken,
826 _updates: UpdateSink,
827 ) -> BoxFuture<'_, Result<ToolResult, String>> {
828 Box::pin(async move {
829 let kind = match self.kind {
830 ResourceTool::Read => return self.read(&args, &cancel).await,
831 ResourceTool::List(kind) => kind,
832 };
833 let payload = self.list(kind, &args, &cancel).await?;
834 let json = yapi_types::json::stringify(&payload);
835 let server = string_argument(&args, "server")?.unwrap_or_default();
836 let (content, details) = limit_with_details(
837 vec![ContentBlock::text(json)],
838 &server,
839 &self.declaration.name,
840 )
841 .await;
842 Ok(ToolResult {
843 content,
844 details: Some(details),
845 structured_content: Some(payload),
846 ..ToolResult::default()
847 })
848 })
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855
856 #[test]
857 fn names_tools_like_pi() {
858 assert_eq!(
859 tool_name("docs", "search-pages", |_| false),
860 "mcp__docs__search_pages"
861 );
862 let long = tool_name("server", &"x".repeat(80), |_| false);
863 assert_eq!(long.len(), 64);
864 assert!(long.starts_with("mcp__server__xxx"));
865 let taken = tool_name("a", "b", |name| name == "mcp__a__b");
866 assert_eq!(&taken[..10], "mcp__a__b_");
867 assert_eq!(taken.len(), 18);
868 assert_eq!(extension_of("file:///tmp/archive.tar.gz"), ".gz");
869 assert_eq!(extension_of("blob:thing"), ".bin");
870 assert!(is_app_resource(&json!({"uri": "ui://widget"})));
871 assert!(is_app_resource(
872 &json!({"uri": "a", "mimeType": "text/html;profile=mcp-app"})
873 ));
874 }
875
876 #[tokio::test]
877 async fn lenient_resource_blob() {
878 let text = |blob: &str| {
879 let block = json!({
880 "type": "resource",
881 "resource": {"uri": "file:///a.txt", "mimeType": "text/plain", "blob": blob},
882 });
883 async move { block_content("docs", &block, false).await }
884 };
885 let wrapped = "aGVsbG8g".repeat(10) + "\n" + &"aGVsbG8g".repeat(2) + "\n";
888 for (blob, expected) in [
889 ("aGVsbG8", "hello".to_owned()),
890 (&wrapped, "hello ".repeat(12)),
891 ("_-8=QQ==", "\u{fffd}\u{fffd}".to_owned()),
892 ] {
893 assert_eq!(text(blob).await, ContentBlock::text(expected), "{blob:?}");
894 }
895 }
896}