diff --git a/src/cli/init.rs b/src/cli/init.rs index 8a1a491..b2b1563 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -78,6 +78,7 @@ impl InitWizard { tools: crate::config::ToolsConfig::default(), memory_maintenance: crate::config::MemoryMaintenanceConfig::default(), mcp_servers: HashMap::new(), + mcp_tool_timeout_secs: 300, image_context: crate::config::ImageContextConfig::default(), subagents: crate::config::SubagentsConfig::default(), experts: crate::config::ExpertsConfig::default(), @@ -843,6 +844,7 @@ impl InitWizard { tools: existing.tools.clone(), memory_maintenance: existing.memory_maintenance.clone(), mcp_servers: existing.mcp_servers.clone(), + mcp_tool_timeout_secs: existing.mcp_tool_timeout_secs, image_context: existing.image_context.clone(), subagents: existing.subagents.clone(), experts: existing.experts.clone(), diff --git a/src/config/mod.rs b/src/config/mod.rs index db8b504..f7564e9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -34,6 +34,9 @@ pub struct Config { pub memory_maintenance: MemoryMaintenanceConfig, #[serde(default, rename = "mcpServers")] pub mcp_servers: HashMap, + /// MCP 工具调用超时时间(秒)。0 表示不超时。默认 300(5 分钟)。 + #[serde(default = "default_mcp_tool_timeout_secs")] + pub mcp_tool_timeout_secs: u64, #[serde(default)] pub image_context: ImageContextConfig, #[serde(default)] @@ -556,6 +559,10 @@ fn default_max_retries() -> u32 { 3 } +fn default_mcp_tool_timeout_secs() -> u64 { + 300 +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct GatewayConfig { #[serde(default = "default_gateway_host")] diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 35257fd..e3cbd6d 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -118,6 +118,7 @@ impl GatewayState { config.memory_maintenance.clone(), session_ttl_hours, mcp_config, + config.mcp_tool_timeout_secs, Some(bus.clone()), Arc::new(crate::config::ModelResolver::from_config(&config)), config.compaction.clone(), diff --git a/src/gateway/runtime.rs b/src/gateway/runtime.rs index 093100f..5165c7c 100644 --- a/src/gateway/runtime.rs +++ b/src/gateway/runtime.rs @@ -56,6 +56,7 @@ pub(crate) fn build_session_manager( maintenance_config: MemoryMaintenanceConfig, session_ttl_hours: Option, mcp_config: crate::mcp::McpConfig, + mcp_tool_timeout_secs: u64, bus: Option>, model_resolver: Arc, compaction_config: CompactionConfig, @@ -84,6 +85,7 @@ pub(crate) fn build_session_manager( maintenance_config, session_ttl_hours, mcp_config, + mcp_tool_timeout_secs, bus, model_resolver, compaction_config, @@ -106,6 +108,7 @@ pub(crate) fn build_session_manager_with_sender( maintenance_config: MemoryMaintenanceConfig, session_ttl_hours: Option, mcp_config: crate::mcp::McpConfig, + mcp_tool_timeout_secs: u64, bus: Option>, model_resolver: Arc, compaction_config: CompactionConfig, @@ -192,6 +195,7 @@ pub(crate) fn build_session_manager_with_sender( manager.clone(), server_key.clone(), tool_info, + mcp_tool_timeout_secs, ); mcp_tools_for_subagents.push(wrapper); } diff --git a/src/gateway/session.rs b/src/gateway/session.rs index 8ffebb1..ef2c0c9 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -747,6 +747,7 @@ impl SessionManager { maintenance_config, session_ttl_hours, mcp_config, + 300, None, model_resolver, crate::config::CompactionConfig::default(), diff --git a/src/mcp/client.rs b/src/mcp/client.rs index 9836e36..e800f93 100644 --- a/src/mcp/client.rs +++ b/src/mcp/client.rs @@ -788,13 +788,14 @@ impl McpInitializer { pub async fn register_tools( &mut self, registry: &mut crate::tools::ToolRegistry, + timeout_secs: u64, ) -> anyhow::Result<()> { if let Some(manager) = self.manager.clone() { // Wait for connections to complete first self.wait_for_connections().await?; tracing::info!("Registering MCP tools after connections completed"); - crate::mcp::register_mcp_tools(manager, registry).await?; + crate::mcp::register_mcp_tools(manager, registry, timeout_secs).await?; } Ok(()) } diff --git a/src/mcp/tool_adapter.rs b/src/mcp/tool_adapter.rs index 1ce1ee2..4dbf012 100644 --- a/src/mcp/tool_adapter.rs +++ b/src/mcp/tool_adapter.rs @@ -37,11 +37,18 @@ pub struct McpToolWrapper { full_name: String, /// Tool information from MCP server tool_info: Tool, + /// Tool call timeout in seconds (0 = no timeout) + timeout_secs: u64, } impl McpToolWrapper { /// Create a new tool wrapper - pub fn new(manager: Arc, server_key: String, tool_info: Tool) -> Self { + pub fn new( + manager: Arc, + server_key: String, + tool_info: Tool, + timeout_secs: u64, + ) -> Self { let tool_name = tool_info.name.clone().into_owned(); let raw_name = format!("mcp_{}_{}", server_key, tool_name); let full_name = sanitize_tool_name(&raw_name); @@ -61,6 +68,7 @@ impl McpToolWrapper { tool_name, full_name, tool_info, + timeout_secs, } } @@ -98,10 +106,33 @@ impl PicoBotTool for McpToolWrapper { "Calling MCP tool" ); - let result = self + let call = self .manager - .call_tool(&self.server_key, &self.tool_name, args) - .await?; + .call_tool(&self.server_key, &self.tool_name, args); + + let result = if self.timeout_secs > 0 { + tokio::time::timeout( + std::time::Duration::from_secs(self.timeout_secs), + call, + ) + .await + .map_err(|_| { + tracing::warn!( + server_key = %self.server_key, + tool = %self.tool_name, + timeout_secs = self.timeout_secs, + "MCP tool call timed out" + ); + anyhow::anyhow!( + "MCP tool '{}' on server '{}' timed out after {}s", + self.tool_name, + self.server_key, + self.timeout_secs + ) + })?? + } else { + call.await? + }; // Convert MCP CallToolResult to PicoBot ToolResult let output = extract_text_content(&result); @@ -147,11 +178,17 @@ fn extract_text_content(result: &rmcp::model::CallToolResult) -> String { pub async fn register_mcp_tools( manager: Arc, registry: &mut crate::tools::registry::ToolRegistry, + timeout_secs: u64, ) -> anyhow::Result<()> { let all_tools = manager.all_tools().await; for (server_key, tool_info) in all_tools { - let wrapper = McpToolWrapper::new(manager.clone(), server_key.clone(), tool_info); + let wrapper = McpToolWrapper::new( + manager.clone(), + server_key.clone(), + tool_info, + timeout_secs, + ); tracing::info!( name = %wrapper.name(), @@ -198,7 +235,7 @@ mod tests { .clone(); let tool_info = Tool::new("echo", "Echo tool", schema); - let wrapper = McpToolWrapper::new(manager, "filesystem".to_string(), tool_info); + let wrapper = McpToolWrapper::new(manager, "filesystem".to_string(), tool_info, 300); assert_eq!(wrapper.name(), "mcp_filesystem_echo"); assert_eq!(wrapper.original_name(), "echo"); assert_eq!(wrapper.server_key(), "filesystem"); @@ -216,7 +253,7 @@ mod tests { .clone(); let tool_info = Tool::new("tools.list:read", "Namespaced tool", schema); - let wrapper = McpToolWrapper::new(manager, "github.api".to_string(), tool_info); + let wrapper = McpToolWrapper::new(manager, "github.api".to_string(), tool_info, 300); // mcp_github.api_tools.list:read → mcp_github_api_tools_list_read assert_eq!(wrapper.name(), "mcp_github_api_tools_list_read"); // Original identifiers preserved for routing diff --git a/web/src/components/Settings/tabs/McpTab.tsx b/web/src/components/Settings/tabs/McpTab.tsx index 8718bf6..b6eb512 100644 --- a/web/src/components/Settings/tabs/McpTab.tsx +++ b/web/src/components/Settings/tabs/McpTab.tsx @@ -58,6 +58,18 @@ export function McpTab({ config, update, setToast }: Props) { return (
+
+ + update('mcp_tool_timeout_secs', Number(e.target.value))} + className={inputCls} + min={0} + step={30} + /> + +
{mcpStatus && mcpStatus.enabled && (
diff --git a/web/src/components/Settings/types.ts b/web/src/components/Settings/types.ts index ba24df3..3b8f7e3 100644 --- a/web/src/components/Settings/types.ts +++ b/web/src/components/Settings/types.ts @@ -257,6 +257,7 @@ export interface AppConfig { client: ClientConfig; channels: Record; mcpServers: Record; + mcp_tool_timeout_secs: number; } export type TabId =