Skip to main content

skiff_cli/mcp/
common.rs

1//! Shared helpers for MCP clients (stdio / HTTP / SSE).
2
3use rmcp::model::CallToolResult;
4use serde_json::{json, Map, Value};
5
6use crate::error::{Error, Result};
7use crate::mcp::extract_mcp_commands;
8use crate::model::CommandDef;
9use crate::spool::write_spool;
10
11pub type McpClient = rmcp::service::RunningService<rmcp::RoleClient, ()>;
12
13pub fn tools_from_rmcp(tools: impl IntoIterator<Item = rmcp::model::Tool>) -> Vec<Value> {
14    let mut out = Vec::new();
15    for t in tools {
16        let schema = serde_json::to_value(&t.input_schema)
17            .unwrap_or_else(|_| json!({"type": "object", "properties": {}}));
18        out.push(json!({
19            "name": t.name,
20            "description": t.description.clone().unwrap_or_default(),
21            "inputSchema": schema,
22        }));
23    }
24    out
25}
26
27fn spool_base64_blob(data_b64: &str, mime: &str, kind: &str) -> Result<Value> {
28    use base64::Engine;
29    // Prefer decode when possible; otherwise store the base64 text.
30    let raw = base64::engine::general_purpose::STANDARD
31        .decode(data_b64)
32        .unwrap_or_else(|_| data_b64.as_bytes().to_vec());
33    let path = write_spool(&raw, "bin")?;
34    eprintln!(
35        "skiff: spooled {kind} content ({mime}, {} bytes) to {}",
36        raw.len(),
37        path.display()
38    );
39    Ok(json!({
40        "type": kind,
41        "mimeType": mime,
42        "bytes": raw.len(),
43        "path": path.display().to_string(),
44        "spooled": true,
45        "hint": format!("file '{}'", path.display()),
46    }))
47}
48
49fn content_only_payload(result: &CallToolResult) -> Result<Value> {
50    let mut texts = Vec::new();
51    let mut extras = Vec::new();
52
53    for block in &result.content {
54        if let Some(t) = block.as_text() {
55            texts.push(t.text.clone());
56            continue;
57        }
58        if let Some(img) = block.as_image() {
59            extras.push(spool_base64_blob(&img.data, &img.mime_type, "image")?);
60            continue;
61        }
62        if let Some(audio) = block.as_audio() {
63            extras.push(spool_base64_blob(&audio.data, &audio.mime_type, "audio")?);
64            continue;
65        }
66        // resource / resource_link / unknown — stub without dumping
67        extras.push(json!({
68            "type": "other",
69            "note": "non-text content omitted; use --envelope for wire form",
70        }));
71    }
72
73    if texts.is_empty() && extras.is_empty() {
74        // Prefer structuredContent when the tool returned no content blocks.
75        if let Some(sc) = &result.structured_content {
76            return Ok(sc.clone());
77        }
78        return Ok(Value::Null);
79    }
80
81    if texts.is_empty() {
82        return Ok(if extras.len() == 1 {
83            extras.pop().unwrap()
84        } else {
85            Value::Array(extras)
86        });
87    }
88
89    let text = texts.join("\n");
90    let text_val = if let Ok(parsed) = serde_json::from_str::<Value>(&text) {
91        parsed
92    } else {
93        Value::String(text)
94    };
95
96    if extras.is_empty() {
97        return Ok(text_val);
98    }
99
100    Ok(json!({
101        "text": text_val,
102        "attachments": extras,
103    }))
104}
105
106fn tool_error_message(payload: &Value) -> String {
107    match payload {
108        Value::Null => "MCP tool returned an error".into(),
109        Value::String(s) => format!("MCP tool error: {s}"),
110        other => format!(
111            "MCP tool error: {}",
112            serde_json::to_string(other).unwrap_or_else(|_| other.to_string())
113        ),
114    }
115}
116
117/// Format a tool result for agents: content-only by default; spool non-text blobs.
118///
119/// When `is_error` is set, returns [`Error::Runtime`] so the CLI exits non-zero.
120/// Structured-only results (`structured_content` with empty `content`) are returned
121/// as that JSON value in content-only mode.
122pub fn format_tool_result(result: &CallToolResult, full_envelope: bool) -> Result<Value> {
123    let value = if full_envelope {
124        serde_json::to_value(result).map_err(|e| Error::runtime(e.to_string()))?
125    } else {
126        content_only_payload(result)?
127    };
128
129    if result.is_error == Some(true) {
130        return Err(Error::runtime(tool_error_message(&value)));
131    }
132    Ok(value)
133}
134
135pub fn tools_to_commands(tools: &[Value]) -> Vec<CommandDef> {
136    extract_mcp_commands(tools)
137}
138
139pub fn auth_headers_to_http(
140    auth_headers: &[(String, String)],
141) -> Result<std::collections::HashMap<http::HeaderName, http::HeaderValue>> {
142    let mut map = std::collections::HashMap::new();
143    for (name, value) in auth_headers {
144        let hn = http::HeaderName::from_bytes(name.as_bytes())
145            .map_err(|e| Error::usage(format!("invalid auth header name {name:?}: {e}")))?;
146        let hv = http::HeaderValue::from_str(value)
147            .map_err(|e| Error::usage(format!("invalid auth header value for {name}: {e}")))?;
148        map.insert(hn, hv);
149    }
150    Ok(map)
151}
152
153pub async fn list_tools_on(client: &McpClient) -> Result<Vec<Value>> {
154    let tools = client
155        .list_all_tools()
156        .await
157        .map_err(|e| Error::runtime(format!("list_tools failed: {e}")))?;
158    Ok(tools_from_rmcp(tools))
159}
160
161pub async fn call_tool_on(
162    client: &McpClient,
163    tool_name: &str,
164    arguments: Map<String, Value>,
165    full_envelope: bool,
166) -> Result<Value> {
167    use rmcp::model::CallToolRequestParams;
168
169    let params = CallToolRequestParams::new(tool_name.to_string()).with_arguments(arguments);
170    let result = client
171        .call_tool(params)
172        .await
173        .map_err(|e| Error::runtime(format!("call_tool failed: {e}")))?;
174    format_tool_result(&result, full_envelope)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use rmcp::model::CallToolResult;
181    use serde_json::json;
182
183    fn parse_result(v: Value) -> CallToolResult {
184        serde_json::from_value(v).expect("CallToolResult")
185    }
186
187    #[test]
188    fn structured_only_content() {
189        let r = parse_result(json!({
190            "content": [],
191            "structuredContent": {"ok": true, "n": 7}
192        }));
193        assert_eq!(
194            format_tool_result(&r, false).unwrap(),
195            json!({"ok": true, "n": 7})
196        );
197    }
198
199    #[test]
200    fn is_error_returns_runtime_err() {
201        let r = parse_result(json!({
202            "content": [{"type": "text", "text": "boom"}],
203            "isError": true
204        }));
205        let err = format_tool_result(&r, false).unwrap_err();
206        assert!(err.to_string().contains("boom"));
207    }
208
209    #[test]
210    fn text_content_preferred_over_structured() {
211        let r = parse_result(json!({
212            "content": [{"type": "text", "text": "hello"}],
213            "structuredContent": {"ignored": true}
214        }));
215        assert_eq!(format_tool_result(&r, false).unwrap(), json!("hello"));
216    }
217
218    #[test]
219    fn envelope_includes_is_error_flag_on_success_path() {
220        let r = parse_result(json!({
221            "content": [{"type": "text", "text": "ok"}],
222            "isError": false
223        }));
224        let v = format_tool_result(&r, true).unwrap();
225        assert!(v.get("content").is_some());
226    }
227}