diff --git a/src/mcp/tool_adapter.rs b/src/mcp/tool_adapter.rs index e28e587..1ce1ee2 100644 --- a/src/mcp/tool_adapter.rs +++ b/src/mcp/tool_adapter.rs @@ -8,6 +8,22 @@ use rmcp::model::Tool; use crate::mcp::client::McpClientManager; use crate::tools::traits::{Tool as PicoBotTool, ToolResult}; +/// Sanitize a tool name to comply with OpenAI's function name pattern `^[a-zA-Z0-9_-]+$`. +/// Any character outside [a-zA-Z0-9_-] (e.g. '.', ':', '/') is replaced with '_'. +/// This is applied to the LLM-facing name only; `McpToolWrapper` retains the original +/// `server_key` and `tool_name` for routing tool calls to the correct MCP server. +fn sanitize_tool_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect() +} + /// Wrapper that adapts an MCP tool to PicoBot's Tool trait #[derive(Clone)] pub struct McpToolWrapper { @@ -27,7 +43,18 @@ impl McpToolWrapper { /// Create a new tool wrapper pub fn new(manager: Arc, server_key: String, tool_info: Tool) -> Self { let tool_name = tool_info.name.clone().into_owned(); - let full_name = format!("mcp_{}_{}", server_key, tool_name); + let raw_name = format!("mcp_{}_{}", server_key, tool_name); + let full_name = sanitize_tool_name(&raw_name); + if full_name != raw_name { + tracing::warn!( + original = %raw_name, + sanitized = %full_name, + server_key = %server_key, + tool_name = %tool_name, + "MCP tool name contained characters invalid for OpenAI function name pattern \ + (^[a-zA-Z0-9_-]+$); sanitized to comply" + ); + } Self { manager, server_key, @@ -176,4 +203,45 @@ mod tests { assert_eq!(wrapper.original_name(), "echo"); assert_eq!(wrapper.server_key(), "filesystem"); } + + #[test] + fn test_mcp_tool_wrapper_name_sanitizes_invalid_chars() { + // OpenAI requires function names to match ^[a-zA-Z0-9_-]+$. + // server_key and tool_name from MCP servers may contain '.', ':', '/', etc. + let manager = Arc::new(McpClientManager::new()); + let schema: serde_json::Map = + serde_json::json!({"type": "object"}) + .as_object() + .unwrap() + .clone(); + let tool_info = Tool::new("tools.list:read", "Namespaced tool", schema); + + let wrapper = McpToolWrapper::new(manager, "github.api".to_string(), tool_info); + // 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 + assert_eq!(wrapper.original_name(), "tools.list:read"); + assert_eq!(wrapper.server_key(), "github.api"); + } + + #[test] + fn test_sanitize_tool_name_matches_openai_pattern() { + let re = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap(); + for input in [ + "mcp_filesystem_echo", + "mcp_github.api_tools.list:read", + "mcp_a/b@c d", + "mcp_中文_tool", + ] { + let sanitized = sanitize_tool_name(input); + assert!( + re.is_match(&sanitized), + "sanitized name {:?} (from {:?}) does not match OpenAI pattern", + sanitized, + input + ); + } + // Empty stays empty + assert_eq!(sanitize_tool_name(""), ""); + } } diff --git a/web/src/components/Settings/ConfigPage.tsx b/web/src/components/Settings/ConfigPage.tsx index f29a5fa..bc7d9da 100644 --- a/web/src/components/Settings/ConfigPage.tsx +++ b/web/src/components/Settings/ConfigPage.tsx @@ -2062,13 +2062,22 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage const entries = Object.entries(config.mcpServers); const statusFor = (key: string) => mcpStatus?.servers?.find((s) => s.key === key); const addMcp = () => { - const name = prompt('MCP 服务器名称:')?.trim(); - if (name && !config.mcpServers[name]) { - update('mcpServers', { - ...config.mcpServers, - [name]: { type: 'stdio', is_active: true, command: '', args: [] }, - }); + const name = prompt('MCP 服务器名称(仅字母、数字、下划线、连字符):')?.trim(); + if (!name) return; + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + setToast('名称只能包含字母、数字、下划线和连字符'); + setTimeout(() => setToast(''), 3000); + return; } + if (config.mcpServers[name]) { + setToast('该名称已存在'); + setTimeout(() => setToast(''), 3000); + return; + } + update('mcpServers', { + ...config.mcpServers, + [name]: { type: 'stdio', is_active: true, command: '', args: [] }, + }); }; const delMcp = (name: string) => { if (confirm(`删除 MCP 服务器 "${name}"?`)) { @@ -2076,6 +2085,26 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage update('mcpServers', rest); } }; + const renameMcp = (oldName: string, newName: string) => { + const trimmed = newName.trim(); + if (trimmed === oldName || !trimmed) return; + if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { + setToast('名称只能包含字母、数字、下划线和连字符'); + setTimeout(() => setToast(''), 3000); + return; + } + if (config.mcpServers[trimmed]) { + setToast('该名称已存在'); + setTimeout(() => setToast(''), 3000); + return; + } + const entries = Object.entries(config.mcpServers); + const newMap: Record = {}; + for (const [k, v] of entries) { + newMap[k === oldName ? trimmed : k] = v; + } + update('mcpServers', newMap); + }; const updMcp = (name: string, patch: Partial) => update('mcpServers', { ...config.mcpServers, @@ -2114,9 +2143,9 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage key={name} className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden" > -
- {st ? ( - st.connected ? ( + {st && ( +
+ {st.connected ? ( {st.tool_count} 工具 @@ -2131,18 +2160,14 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage 未连接 - ) - ) : null} - - {name} - - -
+ )} +
+ )} + delMcp(name)} + onRename={(n) => renameMcp(name, n)} + />