Skip to main content

yapi_core/mcp/
content.rs

1//! Tool result content for a model. Port of `toLlmContent` in
2//! `protocol/content.ts` of pi-mcp `v1.0.0`.
3
4use serde_json::Value;
5use yapi_types::message::{ContentBlock, ImageContent};
6
7/// An image block.
8fn image(data: &str, mime_type: &str) -> ContentBlock {
9    ContentBlock::Image(ImageContent {
10        data: data.to_owned(),
11        mime_type: mime_type.to_owned(),
12    })
13}
14
15fn field<'a>(value: &'a Value, key: &str) -> &'a str {
16    value.get(key).and_then(Value::as_str).unwrap_or_default()
17}
18
19/// One MCP content block as text or an image: embedded text resources become
20/// text, image resources images, and audio, links and other binary resources a
21/// short placeholder.
22pub fn block_to_llm(block: &Value) -> ContentBlock {
23    match field(block, "type") {
24        "text" => ContentBlock::text(field(block, "text")),
25        "image" => image(field(block, "data"), field(block, "mimeType")),
26        "audio" => ContentBlock::text(format!("[audio {} omitted]", field(block, "mimeType"))),
27        "resource_link" => {
28            ContentBlock::text(format!("{}: {}", field(block, "name"), field(block, "uri")))
29        }
30        "resource" => {
31            let resource = &block["resource"];
32            if let Some(content) = resource.get("text").and_then(Value::as_str) {
33                return ContentBlock::text(content);
34            }
35            let mime_type = resource.get("mimeType").and_then(Value::as_str);
36            match mime_type {
37                Some(mime) if mime.starts_with("image/") => image(field(resource, "blob"), mime),
38                _ => ContentBlock::text(format!(
39                    "[binary resource {} ({}) omitted]",
40                    field(resource, "uri"),
41                    mime_type.unwrap_or("unknown type")
42                )),
43            }
44        }
45        other => ContentBlock::text(format!("[unsupported MCP content {other}]")),
46    }
47}
48
49/// A tool result's content blocks. Without blocks, its `structuredContent` as
50/// JSON, since servers should but do not always mirror it as text.
51pub fn to_llm_content(result: &Value) -> Vec<ContentBlock> {
52    let mut content: Vec<ContentBlock> = result
53        .get("content")
54        .and_then(Value::as_array)
55        .map(|blocks| blocks.iter().map(block_to_llm).collect())
56        .unwrap_or_default();
57    if content.is_empty()
58        && let Some(structured) = result.get("structuredContent")
59        && let Ok(json) = yapi_types::json::to_string_pretty(structured, "  ")
60    {
61        content.push(ContentBlock::text(json));
62    }
63    content
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use serde_json::json;
70
71    #[test]
72    fn converts_like_pi() {
73        let result = json!({"content": [
74            {"type": "text", "text": "a"},
75            {"type": "audio", "data": "x", "mimeType": "audio/wav"},
76            {"type": "resource_link", "uri": "u://x", "name": "x"},
77            {"type": "resource", "resource": {"uri": "u://b", "blob": "AA==", "mimeType": "application/zip"}},
78            {"type": "resource", "resource": {"uri": "u://p", "blob": "AA==", "mimeType": "image/png"}},
79            {"type": "widget"},
80        ]});
81        assert_eq!(
82            to_llm_content(&result),
83            vec![
84                ContentBlock::text("a"),
85                ContentBlock::text("[audio audio/wav omitted]"),
86                ContentBlock::text("x: u://x"),
87                ContentBlock::text("[binary resource u://b (application/zip) omitted]"),
88                image("AA==", "image/png"),
89                ContentBlock::text("[unsupported MCP content widget]"),
90            ]
91        );
92        assert_eq!(
93            to_llm_content(&json!({"content": [], "structuredContent": {"sum": 3}})),
94            vec![ContentBlock::text("{\n  \"sum\": 3\n}")]
95        );
96    }
97}