use super::{ToolError, ToolProvider};
use crate::ast::{self, Span};
use crate::interpreter::Value;
use crate::tools::mcp_client::McpClient;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
#[derive(Debug, Clone, serde::Deserialize)]
pub struct McpServerConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct McpConfig {
#[serde(rename = "mcpServers")]
pub mcp_servers: HashMap<String, McpServerConfig>,
}
impl McpConfig {
pub fn from_file(path: &str) -> Result<Self, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read MCP config {path}: {e}"))?;
serde_json::from_str(&content)
.map_err(|e| format!("failed to parse MCP config {path}: {e}"))
}
}
#[derive(Debug, Clone)]
pub struct McpToolDef {
#[allow(dead_code)] pub server_name: String,
pub tool_name: String,
pub description: String,
pub param_names: Vec<String>,
pub ilo_params: Vec<ast::Param>,
}
pub fn json_schema_to_ilo_type(schema: &serde_json::Value) -> ast::Type {
match schema.get("type").and_then(|t| t.as_str()) {
Some("string") => ast::Type::Text,
Some("number") | Some("integer") => ast::Type::Number,
Some("boolean") => ast::Type::Bool,
_ => ast::Type::Text, }
}
fn parse_tool_def(server_name: &str, tool_json: &serde_json::Value) -> Option<McpToolDef> {
let tool_name = tool_json.get("name")?.as_str()?.to_string();
let description = tool_json
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("")
.to_string();
let input_schema = tool_json
.get("inputSchema")
.unwrap_or(&serde_json::Value::Null);
let properties = input_schema.get("properties").and_then(|p| p.as_object());
let required: Vec<String> = input_schema
.get("required")
.and_then(|r| r.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let mut param_names: Vec<String> = required;
if let Some(props) = properties {
for key in props.keys() {
if !param_names.contains(key) {
param_names.push(key.clone());
}
}
}
let ilo_params: Vec<ast::Param> = param_names
.iter()
.map(|name| {
let schema = properties
.and_then(|p| p.get(name))
.unwrap_or(&serde_json::Value::Null);
ast::Param {
name: name.clone(),
ty: json_schema_to_ilo_type(schema),
}
})
.collect();
Some(McpToolDef {
server_name: server_name.to_string(),
tool_name,
description,
param_names,
ilo_params,
})
}
pub struct McpProvider {
clients: Vec<(McpClient, Vec<McpToolDef>)>,
tool_index: HashMap<String, (usize, Vec<String>)>,
}
impl McpProvider {
pub async fn connect(config: &McpConfig) -> Result<Self, String> {
let mut clients: Vec<(McpClient, Vec<McpToolDef>)> =
Vec::with_capacity(config.mcp_servers.len());
let mut tool_index: HashMap<String, (usize, Vec<String>)> = HashMap::new();
for (server_name, server_cfg) in &config.mcp_servers {
let client = McpClient::connect(&server_cfg.command, &server_cfg.args, &server_cfg.env)
.await
.map_err(|e| format!("MCP server '{server_name}': {e}"))?;
let raw_tools = client
.list_tools()
.await
.map_err(|e| format!("MCP server '{server_name}' list_tools: {e}"))?;
let tools: Vec<McpToolDef> = raw_tools
.iter()
.filter_map(|t| parse_tool_def(server_name, t))
.collect();
let client_idx = clients.len();
for tool in &tools {
tool_index.insert(
tool.tool_name.clone(),
(client_idx, tool.param_names.clone()),
);
}
clients.push((client, tools));
}
Ok(McpProvider {
clients,
tool_index,
})
}
pub fn tool_decls(&self) -> Vec<ast::Decl> {
let return_type = ast::Type::Result(Box::new(ast::Type::Text), Box::new(ast::Type::Text));
self.clients
.iter()
.flat_map(|(_, tools)| tools.iter())
.map(|tool| ast::Decl::Tool {
name: tool.tool_name.clone(),
description: tool.description.clone(),
params: tool.ilo_params.clone(),
return_type: return_type.clone(),
timeout: None,
retry: None,
span: Span::UNKNOWN,
})
.collect()
}
}
impl ToolProvider for McpProvider {
fn call(
&self,
name: &str,
args: Vec<Value>,
) -> Pin<Box<dyn Future<Output = Result<Value, ToolError>> + Send + '_>> {
let name = name.to_string();
Box::pin(async move {
let (client_idx, param_names) = self
.tool_index
.get(&name)
.ok_or_else(|| ToolError::NotConfigured(name.clone()))?;
let (client, _) = &self.clients[*client_idx];
let mut arguments = serde_json::Map::new();
for (param_name, arg) in param_names.iter().zip(args.iter()) {
let json_val = arg
.to_json()
.map_err(|e| ToolError::Json(name.clone(), e.to_string()))?;
arguments.insert(param_name.clone(), json_val);
}
let result = client
.call_tool(&name, serde_json::Value::Object(arguments))
.await
.map_err(|e| ToolError::Http(name.clone(), e))?;
if result
.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let err_text = extract_text_content(&result);
return Ok(Value::Err(Box::new(Value::Text(err_text))));
}
let text = extract_text_content(&result);
Ok(Value::Ok(Box::new(Value::Text(text))))
})
}
}
fn extract_text_content(result: &serde_json::Value) -> String {
if let Some(content) = result.get("content").and_then(|c| c.as_array()) {
let texts: Vec<&str> = content
.iter()
.filter_map(|item| {
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
item.get("text").and_then(|t| t.as_str())
} else {
None
}
})
.collect();
if !texts.is_empty() {
return texts.join("\n");
}
}
result.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_mapping_string() {
let schema = serde_json::json!({ "type": "string" });
assert_eq!(json_schema_to_ilo_type(&schema), ast::Type::Text);
}
#[test]
fn schema_mapping_number() {
let schema = serde_json::json!({ "type": "number" });
assert_eq!(json_schema_to_ilo_type(&schema), ast::Type::Number);
}
#[test]
fn schema_mapping_integer() {
let schema = serde_json::json!({ "type": "integer" });
assert_eq!(json_schema_to_ilo_type(&schema), ast::Type::Number);
}
#[test]
fn schema_mapping_boolean() {
let schema = serde_json::json!({ "type": "boolean" });
assert_eq!(json_schema_to_ilo_type(&schema), ast::Type::Bool);
}
#[test]
fn schema_mapping_array_falls_back_to_text() {
let schema = serde_json::json!({ "type": "array" });
assert_eq!(json_schema_to_ilo_type(&schema), ast::Type::Text);
}
#[test]
fn schema_mapping_object_falls_back_to_text() {
let schema = serde_json::json!({ "type": "object" });
assert_eq!(json_schema_to_ilo_type(&schema), ast::Type::Text);
}
#[test]
fn schema_mapping_missing_type_falls_back_to_text() {
let schema = serde_json::json!({});
assert_eq!(json_schema_to_ilo_type(&schema), ast::Type::Text);
}
#[test]
fn parse_tool_def_basic() {
let tool_json = serde_json::json!({
"name": "read_file",
"description": "Read a file",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"]
}
});
let def = parse_tool_def("filesystem", &tool_json).unwrap();
assert_eq!(def.tool_name, "read_file");
assert_eq!(def.server_name, "filesystem");
assert_eq!(def.param_names, vec!["path"]);
assert_eq!(def.ilo_params.len(), 1);
assert_eq!(def.ilo_params[0].name, "path");
assert_eq!(def.ilo_params[0].ty, ast::Type::Text);
}
#[test]
fn parse_tool_def_required_ordering() {
let tool_json = serde_json::json!({
"name": "search",
"description": "Search files",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer" },
"path": { "type": "string" }
},
"required": ["query", "path"]
}
});
let def = parse_tool_def("search_server", &tool_json).unwrap();
assert_eq!(def.param_names[0], "query");
assert_eq!(def.param_names[1], "path");
assert!(def.param_names.contains(&"limit".to_string()));
}
#[test]
fn tool_decls_produce_correct_ast() {
let tool_json = serde_json::json!({
"name": "write_file",
"description": "Write to a file",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["path", "content"]
}
});
let def = parse_tool_def("fs", &tool_json).unwrap();
assert_eq!(def.param_names, vec!["path", "content"]);
assert_eq!(def.ilo_params[0].ty, ast::Type::Text);
assert_eq!(def.ilo_params[1].ty, ast::Type::Text);
}
}