Skip to main content

yapi_core/mcp/
tools.rs

1//! MCP tools as session tools, and the three resource tools. Port of
2//! `extensions/mcp/tools.ts` and `resources.ts` in pi `v1.0.0`.
3//!
4//! Model-facing text over 20 KB keeps its start and end, with the full text
5//! saved to a temp file the model can read. Binary resources other than
6//! images are saved to temp files too. Results keep the server's whole
7//! `CallToolResult`, without `_meta`, as structured content.
8
9use 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
26/// Provider tool names hold at most 64 characters of `[A-Za-z0-9_-]`.
27const MAX_TOOL_NAME_LENGTH: usize = 64;
28/// Model-facing text beyond this is cut in the middle.
29pub const OUTPUT_MAX_BYTES: usize = 20 * 1024;
30/// Reads the resources resource links name.
31pub const READ_MCP_RESOURCE_TOOL: &str = "read_mcp_resource";
32/// Lists resources.
33pub const LIST_MCP_RESOURCES_TOOL: &str = "list_mcp_resources";
34/// Lists resource templates.
35pub const LIST_MCP_RESOURCE_TEMPLATES_TOOL: &str = "list_mcp_resource_templates";
36
37/// `mcp__<server>__<tool>` with everything but `[A-Za-z0-9_]` as `_`. A
38/// name too long, or taken by another tool, is cut and given a hash suffix.
39pub 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
61/// The `server/tool` label of the session tool `name` when an MCP server
62/// provides it, which pi's MCP renderers show in place of the name.
63pub 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
74/// Saves `data` to a temp file only the user can read, and returns its path.
75async 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
94/// Keeps model-facing text within [`OUTPUT_MAX_BYTES`]: longer text becomes
95/// one block in Codex's format with the full text's path, before any
96/// images.
97pub 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
126/// The extension a saved binary resource gets: its URI's, else `.bin`.
127fn 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
142/// Blobs of these types are shown as text.
143fn 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
163/// One block of `server`'s result for the model.
164async 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
227/// Node's `Buffer.from(text, "base64")`: characters outside the base64 and
228/// base64url alphabets are skipped and decoding stops at the first `=`.
229fn 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    // A lone final digit holds too few bits for a byte.
247    if digits.len() % 4 == 1 {
248        digits.pop();
249    }
250    LENIENT.decode(digits).unwrap_or_default()
251}
252
253/// `server`'s content blocks for the model, before the output limit.
254pub 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
266/// [`limit_content`] with the details pi records: the server, the tool and,
267/// when the text was cut, the file with the full output.
268async 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
281/// pi's `convertMcpResult`: `isError` results become error results.
282pub 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
318/// Tool input schemas must be objects with `properties`, which some servers
319/// omit.
320fn 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
331/// pi's `toToolAnnotations`: the boolean hints of `tool`'s annotations, or
332/// `None` when it has none.
333pub 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
353/// An MCP server's tool.
354pub 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    /// The tool `tool` of `connection`'s server, named `name`.
365    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
398/// pi's `createMcpResultSchema`: the `CallToolResult` scripts receive, with
399/// the tool's own output schema as `structuredContent`.
400fn 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/// Which resource tool.
478#[derive(Clone, Copy, Debug, PartialEq, Eq)]
479enum ResourceTool {
480    List(ResourceKind),
481    Read,
482}
483
484/// The servers resource tools reach, at call time.
485pub type ResourceServers = Arc<dyn Fn() -> Vec<Arc<Connection>> + Send + Sync>;
486
487/// One of `list_mcp_resources`, `list_mcp_resource_templates` and
488/// `read_mcp_resource`, which take a `server` argument and reach every
489/// connected server with resources.
490pub struct McpResourceTool {
491    declaration: ToolDeclaration,
492    output_schema: Value,
493    kind: ResourceTool,
494    servers: ResourceServers,
495}
496
497/// MCP App user interfaces, which only hosts that render them can use.
498pub 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
513/// A listed item without `_meta` and icons, tagged with its server.
514fn 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
537/// The three resource tools over `servers`.
538pub 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    // pi's output schemas, which codemode describes scripts' results by.
564    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        // Unpadded, wrapped as Python's `base64.encodebytes` writes it, and
886        // base64url, as Node's `Buffer.from` reads them.
887        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}