use async_trait::async_trait;
use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata, ToolStatus};
use crate::mcp::{McpServerManager, manager_ref};
use super::super::ctx::ExecContext;
use super::ToolExecutor;
pub struct McpToolProxy;
#[async_trait]
impl ToolExecutor for McpToolProxy {
fn name(&self) -> &'static str {
"mcp_proxy"
}
fn is_internal(&self) -> bool {
true
}
fn schema(&self) -> ToolDefinition {
ToolDefinition {
name: "mcp_proxy".to_string(),
description: "Internal dispatch target for mcp__* tool calls.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"server_name": { "type": "string" },
"tool_name": { "type": "string" },
"arguments": { "type": "object" }
},
"required": ["server_name", "tool_name"]
}),
}
}
async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
let Some(server_name) = args.get("server_name").and_then(|v| v.as_str()) else {
return ToolOutcome::error("mcp_proxy requires 'server_name'", 0.0);
};
let Some(tool_name) = args.get("tool_name").and_then(|v| v.as_str()) else {
return ToolOutcome::error("mcp_proxy requires 'tool_name'", 0.0);
};
let tool_args = args
.get("arguments")
.cloned()
.unwrap_or(serde_json::json!({}));
if let Some(blocked) = super::policy_gate::gate_external(
&ctx,
"mcp_proxy",
crate::runtime::ToolCategory::Mcp,
format!("mcp {}__{}", server_name, tool_name),
&args,
)
.await
{
return blocked;
}
if !manager_ref::is_ready() {
let _ = tokio::time::timeout(
std::time::Duration::from_secs(10),
manager_ref::wait_ready(),
)
.await;
}
let Some(manager) = manager_ref::get() else {
return ToolOutcome::error("MCP servers not initialized", 0.0);
};
let start = std::time::Instant::now();
let call = manager.call_tool(server_name, tool_name, &tool_args);
tokio::select! {
biased;
_ = ctx.token.cancelled() => ToolOutcome::cancelled(),
result = call => match result {
Ok(tool_result) => outcome_from_mcp(
&tool_result,
server_name,
tool_name,
start.elapsed().as_secs_f64(),
),
Err(e) => ToolOutcome::error(
format!("mcp_proxy({}:{}): {}", server_name, tool_name, e),
start.elapsed().as_secs_f64(),
)
.with_metadata(mcp_metadata(server_name, tool_name)),
},
}
}
}
fn mcp_metadata(server_name: &str, tool_name: &str) -> ToolRunMetadata {
ToolRunMetadata {
detail: ToolMetadata::Mcp {
server: server_name.to_string(),
tool: tool_name.to_string(),
},
..ToolRunMetadata::default()
}
}
fn outcome_from_mcp(
tool_result: &crate::mcp::McpToolResult,
server_name: &str,
tool_name: &str,
duration_secs: f64,
) -> ToolOutcome {
let (text, images) = McpServerManager::format_tool_result(tool_result);
let verb = if tool_result.is_error {
"failed"
} else {
"completed"
};
let mut outcome = ToolOutcome::success(
text,
format!("{}:{} {}", server_name, tool_name, verb),
duration_secs,
)
.with_metadata(mcp_metadata(server_name, tool_name));
if let Some(images) = images {
outcome = outcome.with_images(images);
}
if tool_result.is_error {
outcome = outcome.with_status(ToolStatus::Error);
}
outcome
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{ToolCallId, TurnId};
use crate::providers::ctx::test_exec_context;
use std::path::PathBuf;
#[tokio::test]
async fn missing_server_name_errors() {
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
let outcome = McpToolProxy
.execute(serde_json::json!({"tool_name": "x"}), ctx)
.await;
assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
}
#[tokio::test]
async fn missing_tool_name_errors() {
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
let outcome = McpToolProxy
.execute(serde_json::json!({"server_name": "x"}), ctx)
.await;
assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
}
#[tokio::test]
async fn uninitialized_manager_errors_cleanly() {
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
let outcome = McpToolProxy
.execute(
serde_json::json!({"server_name": "s", "tool_name": "t"}),
ctx,
)
.await;
assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
}
#[test]
fn outcome_from_mcp_is_error_maps_to_error_status_preserving_content() {
use crate::mcp::{ContentBlock, McpToolResult};
let result = McpToolResult {
content: vec![ContentBlock::Text("boom: rate limited".into())],
is_error: true,
};
let outcome = outcome_from_mcp(&result, "slack", "send", 0.5);
assert_eq!(outcome.status, ToolStatus::Error);
assert_eq!(outcome.output(), "boom: rate limited");
assert_eq!(outcome.error_message(), Some("boom: rate limited"));
assert_eq!(outcome.summary, "slack:send failed");
}
#[test]
fn outcome_from_mcp_success_maps_to_success_status() {
use crate::mcp::{ContentBlock, McpToolResult};
let result = McpToolResult {
content: vec![ContentBlock::Text("ok".into())],
is_error: false,
};
let outcome = outcome_from_mcp(&result, "slack", "send", 0.5);
assert_eq!(outcome.status, ToolStatus::Success);
assert_eq!(outcome.output(), "ok");
assert_eq!(outcome.error_message(), None);
assert_eq!(outcome.summary, "slack:send completed");
}
}