Skip to main content

skiff_cli/mcp/
mod.rs

1//! MCP client transports and tool → CLI mapping.
2//!
3//! - [`stdio`](stdio) — child-process MCP (`--mcp-stdio`)
4//! - [`transport`](transport) — HTTP with `auto` / `streamable` / `sse`
5//! - [`tools_to_commands`] — JSON Schema → [`crate::model::CommandDef`]
6
7mod common;
8mod http;
9mod sse;
10mod stdio;
11mod transport;
12
13pub use common::{call_tool_on, list_tools_on, tools_to_commands, McpClient};
14pub use http::connect_streamable;
15pub use sse::connect_sse;
16pub use stdio::{call_tool_stdio, connect_stdio, connect_stdio_with, fetch_mcp_tools_stdio};
17pub use transport::{
18    call_tool as call_tool_http, connect_http, fetch_mcp_tools as fetch_mcp_tools_http,
19    TransportMode,
20};
21
22use serde_json::Value;
23
24use crate::coerce::to_kebab;
25use crate::model::{schema_type_to_python, CommandDef, ParamDef, ParamLocation};
26
27/// Convert MCP `list_tools` entries into CLI commands.
28///
29/// Missing/`null` `inputSchema` is treated as `{}` (upstream issue #69).
30pub fn extract_mcp_commands(tools: &[Value]) -> Vec<CommandDef> {
31    let mut commands = Vec::new();
32    for tool in tools {
33        let tool_name = tool
34            .get("name")
35            .and_then(|v| v.as_str())
36            .unwrap_or("unknown");
37        let name = to_kebab(tool_name);
38        let desc = tool
39            .get("description")
40            .and_then(|v| v.as_str())
41            .unwrap_or("")
42            .to_string();
43        let schema = tool
44            .get("inputSchema")
45            .filter(|v| !v.is_null())
46            .cloned()
47            .unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}}));
48
49        let required_fields: std::collections::HashSet<String> = schema
50            .get("required")
51            .and_then(|v| v.as_array())
52            .map(|arr| {
53                arr.iter()
54                    .filter_map(|x| x.as_str().map(str::to_string))
55                    .collect()
56            })
57            .unwrap_or_default();
58
59        let mut params = Vec::new();
60        if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) {
61            for (prop_name, prop_schema) in props {
62                let (py_type, suffix) = schema_type_to_python(prop_schema);
63                let description = format!(
64                    "{}{suffix}",
65                    prop_schema
66                        .get("description")
67                        .and_then(|v| v.as_str())
68                        .unwrap_or(prop_name)
69                );
70                let choices = prop_schema
71                    .get("enum")
72                    .and_then(|v| v.as_array())
73                    .map(|arr| {
74                        arr.iter()
75                            .filter_map(|x| x.as_str().map(str::to_string))
76                            .collect()
77                    });
78                params.push(ParamDef {
79                    name: to_kebab(prop_name),
80                    original_name: prop_name.clone(),
81                    python_type: py_type,
82                    required: required_fields.contains(prop_name),
83                    description,
84                    choices,
85                    location: ParamLocation::ToolInput,
86                    schema: prop_schema.clone(),
87                });
88            }
89        }
90
91        let has_body = !params.is_empty();
92        commands.push(CommandDef {
93            name,
94            description: desc,
95            params,
96            has_body,
97            tool_name: Some(tool_name.to_string()),
98            ..Default::default()
99        });
100    }
101    commands
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use serde_json::json;
108
109    #[test]
110    fn extracts_and_handles_null_schema() {
111        let tools = vec![
112            json!({
113                "name": "list_tasks",
114                "description": "List tasks",
115                "inputSchema": {
116                    "type": "object",
117                    "properties": {
118                        "status": {"type": "string", "enum": ["TO_DO", "DONE"]}
119                    },
120                    "required": ["status"]
121                }
122            }),
123            json!({
124                "name": "noop",
125                "description": "No inputs",
126                "inputSchema": null
127            }),
128        ];
129        let cmds = extract_mcp_commands(&tools);
130        assert_eq!(cmds.len(), 2);
131        assert_eq!(cmds[0].name, "list-tasks");
132        assert_eq!(cmds[0].params.len(), 1);
133        assert!(cmds[0].params[0].required);
134        assert_eq!(cmds[1].name, "noop");
135        assert!(cmds[1].params.is_empty());
136    }
137}