From 690165984905608f40a096a624ac3d7471b94958 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Tue, 4 Aug 2026 11:17:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20=E9=99=90=E5=88=B6=20server=20?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E5=AD=97=E7=AC=A6=E5=B9=B6=E5=85=9C=E5=BA=95?= =?UTF-8?q?=20tool=5Fname=20=E6=B8=85=E6=B4=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI 要求 function name 匹配 ^[a-zA-Z0-9_-]+$,否则整个请求 400。MCP 工具名由 mcp_{server_key}_{tool_name} 拼成,两个输入源:server_key 用户可控(前端 addMcp/renameMcp 正则校验 + toast),tool_name 由 MCP server 上报(后端 sanitize_tool_name 替换非法字符为 _)。 前端:MCP 卡片头部改用 MapEntryHeader 支持点击重命名,状态指示灯移到上方独立行。顺手修了 MapEntryHeader 进入编辑时 val 未同步当前 name 的 bug。 后端:保留 server_key 和 tool_name 原值用于路由,仅清洗 LLM 可见的 full_name,并在发生清洗时打 warn 日志便于定位。 --- src/mcp/tool_adapter.rs | 70 +++++++++++++++++++++- web/src/components/Settings/ConfigPage.tsx | 67 ++++++++++++++------- web/src/components/Settings/ui.tsx | 7 ++- 3 files changed, 121 insertions(+), 23 deletions(-) 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)} + />