1use 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 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 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 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
117pub 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}