use super::{McpState, ToolDefinition};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::{debug, error};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct MCPCallInput {
tool_name: String,
tool_args: Value,
}
#[derive(Debug, Deserialize)]
struct MCPCallOutput {
success: bool,
result: Option<Value>,
error: Option<String>,
}
pub async fn get_tool_definitions(state: &McpState) -> Result<Vec<ToolDefinition>> {
debug!("Fetching tool definitions from WASM");
let tools: Vec<ToolDefinition> = state
.plugin
.call("handle_list_tools", &())
.await
.context("WASM handle_list_tools failed")?;
debug!(count = tools.len(), "Got tool definitions from WASM");
Ok(tools)
}
pub async fn execute_tool(state: &McpState, name: &str, args: Value) -> Result<Value> {
debug!(tool = %name, "Routing tool call through WASM");
let input = MCPCallInput {
tool_name: name.to_string(),
tool_args: args,
};
debug!(tool = %name, "Calling WASM handle_mcp_call");
let call_result = state.plugin.call("handle_mcp_call", &input).await;
match &call_result {
Ok(_) => debug!(tool = %name, "WASM call succeeded"),
Err(e) => {
error!(
tool = %name,
error = %e,
"WASM call failed before deserialization"
);
}
}
let output: MCPCallOutput = call_result.map_err(|e| {
anyhow!("WASM call failed for tool '{}': {}", name, e)
})?;
debug!(
tool = %name,
success = output.success,
has_result = output.result.is_some(),
has_error = output.error.is_some(),
"Parsed MCPCallOutput"
);
if let Some(ref err) = output.error {
error!(tool = %name, mcp_error = %err, "Tool returned error in MCPCallOutput");
}
if output.success {
output
.result
.ok_or_else(|| anyhow!("WASM returned success but no result"))
} else {
Err(anyhow!(output
.error
.unwrap_or_else(|| "Unknown WASM error".to_string())))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn mcp_call_input_serializes_camel_case() {
let input = MCPCallInput {
tool_name: "git_branch".to_string(),
tool_args: json!({"path": "/tmp"}),
};
let json = serde_json::to_string(&input).unwrap();
assert!(json.contains("toolName"), "Expected toolName in JSON");
assert!(json.contains("toolArgs"), "Expected toolArgs in JSON");
assert!(
!json.contains("tool_name"),
"Unexpected snake_case tool_name"
);
assert!(
!json.contains("tool_args"),
"Unexpected snake_case tool_args"
);
}
#[test]
fn mcp_call_input_preserves_tool_args() {
let args = json!({
"owner": "exomonad",
"repo": "exomonad",
"number": 42
});
let input = MCPCallInput {
tool_name: "github_get_issue".to_string(),
tool_args: args.clone(),
};
let serialized = serde_json::to_value(&input).unwrap();
assert_eq!(serialized["toolArgs"], args);
}
#[test]
fn mcp_call_output_deserializes_success() {
let json = r#"{"success": true, "result": {"branch": "main"}, "error": null}"#;
let output: MCPCallOutput = serde_json::from_str(json).unwrap();
assert!(output.success);
assert!(output.result.is_some());
assert_eq!(output.result.unwrap()["branch"], "main");
assert!(output.error.is_none());
}
#[test]
fn mcp_call_output_deserializes_error() {
let json = r#"{"success": false, "result": null, "error": "something failed"}"#;
let output: MCPCallOutput = serde_json::from_str(json).unwrap();
assert!(!output.success);
assert!(output.result.is_none());
assert_eq!(output.error, Some("something failed".to_string()));
}
#[test]
fn mcp_call_output_deserializes_with_complex_result() {
let json = r#"{
"success": true,
"result": {
"issues": [
{"number": 1, "title": "Bug", "state": "open"},
{"number": 2, "title": "Feature", "state": "closed"}
]
},
"error": null
}"#;
let output: MCPCallOutput = serde_json::from_str(json).unwrap();
assert!(output.success);
let result = output.result.unwrap();
let issues = result["issues"].as_array().unwrap();
assert_eq!(issues.len(), 2);
assert_eq!(issues[0]["number"], 1);
assert_eq!(issues[1]["title"], "Feature");
}
#[test]
fn mcp_call_output_requires_success_field() {
let json = r#"{"result": {"ok": true}, "error": null}"#;
let result: Result<MCPCallOutput, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn mcp_call_output_handles_missing_optional_fields() {
let json = r#"{"success": true}"#;
let result: Result<MCPCallOutput, _> = serde_json::from_str(json);
if let Ok(output) = result {
assert!(output.success);
assert!(output.result.is_none());
assert!(output.error.is_none());
} else {
let json_with_nulls = r#"{"success": true, "result": null, "error": null}"#;
let output: MCPCallOutput = serde_json::from_str(json_with_nulls).unwrap();
assert!(output.success);
}
}
#[test]
fn mcp_call_input_empty_args() {
let input = MCPCallInput {
tool_name: "list_agents".to_string(),
tool_args: json!({}),
};
let serialized = serde_json::to_value(&input).unwrap();
assert_eq!(serialized["toolName"], "list_agents");
assert_eq!(serialized["toolArgs"], json!({}));
}
#[test]
fn mcp_call_input_nested_args() {
let input = MCPCallInput {
tool_name: "spawn_agents".to_string(),
tool_args: json!({
"issues": ["1", "2", "3"],
"owner": "test",
"repo": "repo",
"worktree_dir": "/path/to/worktrees"
}),
};
let serialized = serde_json::to_value(&input).unwrap();
let args = &serialized["toolArgs"];
assert_eq!(args["issues"].as_array().unwrap().len(), 3);
assert_eq!(args["owner"], "test");
}
}