From 9daa6c0d702851176bdfee8fa7dc590c70d0eeb0 Mon Sep 17 00:00:00 2001 From: kogekiplay Date: Fri, 12 Jun 2026 19:34:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20MCP=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E7=A9=BA=E7=99=BD=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/mcp.rs | 77 +++++++----- src/components/settings/mcp-settings.test.tsx | 96 ++++++++++++++ src/components/settings/mcp-settings.tsx | 117 ++++++++++-------- 3 files changed, 211 insertions(+), 79 deletions(-) create mode 100644 src/components/settings/mcp-settings.test.tsx diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 8d873542..e89ad76f 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -2030,56 +2030,56 @@ fn remove_cline_server(id: &str) -> Result { Ok(removed) } +fn is_visible_mcp_server_id(id: &str) -> bool { + id.chars().any(|ch| { + !ch.is_whitespace() && !matches!(ch, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}') + }) +} + +fn merge_local_server( + merged: &mut BTreeMap)>, + app: McpAppType, + id: String, + spec: Value, +) { + if !is_visible_mcp_server_id(&id) { + eprintln!("[MCP] skip local MCP entry with blank server id for {app:?}"); + return; + } + + let entry = merged.entry(id).or_insert_with(|| (spec, BTreeSet::new())); + entry.1.insert(app); +} + fn scan_local_servers() -> Result, AppCommandError> { let mut merged: BTreeMap)> = BTreeMap::new(); for (id, spec) in read_claude_servers()? { - let entry = merged - .entry(id) - .or_insert_with(|| (spec.clone(), BTreeSet::new())); - entry.1.insert(McpAppType::ClaudeCode); + merge_local_server(&mut merged, McpAppType::ClaudeCode, id, spec); } for (id, spec) in read_codex_servers()? { - let entry = merged - .entry(id) - .or_insert_with(|| (spec.clone(), BTreeSet::new())); - entry.1.insert(McpAppType::Codex); + merge_local_server(&mut merged, McpAppType::Codex, id, spec); } for (id, spec) in read_opencode_servers()? { - let entry = merged - .entry(id) - .or_insert_with(|| (spec.clone(), BTreeSet::new())); - entry.1.insert(McpAppType::OpenCode); + merge_local_server(&mut merged, McpAppType::OpenCode, id, spec); } for (id, spec) in read_gemini_servers()? { - let entry = merged - .entry(id) - .or_insert_with(|| (spec.clone(), BTreeSet::new())); - entry.1.insert(McpAppType::Gemini); + merge_local_server(&mut merged, McpAppType::Gemini, id, spec); } for (id, spec) in read_openclaw_servers()? { - let entry = merged - .entry(id) - .or_insert_with(|| (spec.clone(), BTreeSet::new())); - entry.1.insert(McpAppType::OpenClaw); + merge_local_server(&mut merged, McpAppType::OpenClaw, id, spec); } for (id, spec) in read_cline_servers()? { - let entry = merged - .entry(id) - .or_insert_with(|| (spec.clone(), BTreeSet::new())); - entry.1.insert(McpAppType::Cline); + merge_local_server(&mut merged, McpAppType::Cline, id, spec); } for (id, spec) in read_hermes_servers()? { - let entry = merged - .entry(id) - .or_insert_with(|| (spec.clone(), BTreeSet::new())); - entry.1.insert(McpAppType::Hermes); + merge_local_server(&mut merged, McpAppType::Hermes, id, spec); } Ok(merged @@ -4164,6 +4164,27 @@ mod tests { assert!(normalize_mcp_type("ws").is_none()); } + #[test] + fn local_mcp_merge_skips_blank_server_ids() { + let mut merged: BTreeMap)> = BTreeMap::new(); + + merge_local_server( + &mut merged, + McpAppType::Codex, + " \t\u{200B}\u{FEFF}".to_string(), + json!({"type": "stdio", "command": "npx"}), + ); + merge_local_server( + &mut merged, + McpAppType::Codex, + "playwright".to_string(), + json!({"type": "stdio", "command": "npx"}), + ); + + assert_eq!(merged.len(), 1); + assert!(merged.contains_key("playwright")); + } + fn codex_entry(toml_src: &str) -> toml::Value { toml::from_str::(toml_src).expect("parse test toml") } diff --git a/src/components/settings/mcp-settings.test.tsx b/src/components/settings/mcp-settings.test.tsx new file mode 100644 index 00000000..eeab2952 --- /dev/null +++ b/src/components/settings/mcp-settings.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { McpSettings } from "./mcp-settings" + +const t = (key: string) => key + +const apiMocks = vi.hoisted(() => ({ + mcpGetMarketplaceServerDetail: vi.fn(), + mcpInstallFromMarketplace: vi.fn(), + mcpListMarketplaces: vi.fn(), + mcpRemoveServer: vi.fn(), + mcpScanLocal: vi.fn(), + mcpSearchMarketplace: vi.fn(), + mcpUpsertLocalServer: vi.fn(), +})) + +vi.mock("next-intl", () => ({ + useTranslations: () => t, +})) + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})) + +vi.mock("@/lib/api", () => apiMocks) + +describe("McpSettings", () => { + beforeEach(() => { + vi.clearAllMocks() + apiMocks.mcpListMarketplaces.mockResolvedValue([]) + apiMocks.mcpSearchMarketplace.mockResolvedValue([]) + }) + + test("omits local MCP entries with blank ids", async () => { + apiMocks.mcpScanLocal.mockResolvedValue([ + { + id: "visible-server", + spec: { type: "stdio", command: "npx", args: ["visible"] }, + apps: ["codex"], + }, + { + id: " ", + spec: { type: "stdio", command: "npx", args: ["invisible"] }, + apps: ["codex"], + }, + ]) + + render() + + expect(await screen.findByText("npx visible")).toBeInTheDocument() + expect(screen.queryByText("npx invisible")).not.toBeInTheDocument() + }) + + test("keeps local MCP rows inside an isolated scroll region", async () => { + apiMocks.mcpScanLocal.mockResolvedValue([ + { + id: "open-design", + spec: { + type: "stdio", + command: "/Users/kogeki/.nvm/versions/node/v24.16.0/bin/node", + args: [ + "/Users/kogeki/Documents/Codex/2026-06-08/open-design/bin/od.mjs", + "mcp", + ], + }, + apps: ["codex"], + }, + { + id: "playwright", + spec: { + type: "stdio", + command: "npx", + args: ["-y", "@playwright/mcp@latest"], + }, + apps: ["codex"], + }, + ]) + + render() + + expect( + await screen.findByText("npx -y @playwright/mcp@latest") + ).toBeInTheDocument() + const scrollRegion = screen.getByTestId("mcp-local-list-scroll") + expect(scrollRegion).toHaveClass("min-h-0", "flex-1", "overflow-auto") + expect(scrollRegion).not.toHaveClass("space-y-1") + expect(scrollRegion.firstElementChild).toHaveClass( + "flex", + "flex-col", + "gap-1" + ) + }) +}) diff --git a/src/components/settings/mcp-settings.tsx b/src/components/settings/mcp-settings.tsx index 94e0c9a4..94cb5575 100644 --- a/src/components/settings/mcp-settings.tsx +++ b/src/components/settings/mcp-settings.tsx @@ -242,6 +242,14 @@ function normalizeApps(apps: McpAppType[]): McpAppType[] { return [...new Set(apps)] } +function hasVisibleServerId(server: Pick): boolean { + return server.id.replace(/[\s\u200B-\u200D\uFEFF]/g, "").length > 0 +} + +function visibleLocalServers(servers: LocalMcpServer[]): LocalMcpServer[] { + return servers.filter(hasVisibleServerId) +} + function appsToDraft(apps: McpAppType[]): Record { const appSet = new Set(apps) return { @@ -400,7 +408,7 @@ export function McpSettings() { }, [installedServers, localFilter, mcpT]) const refreshLocalServers = useCallback(async () => { - const servers = await mcpScanLocal() + const servers = visibleLocalServers(await mcpScanLocal()) setInstalledServers(servers) return servers }, []) @@ -414,14 +422,15 @@ export function McpSettings() { mcpScanLocal(), mcpListMarketplaces(), ]) - setInstalledServers(servers) + const nextServers = visibleLocalServers(servers) + setInstalledServers(nextServers) setProviders(marketProviders) setSelectedProvider( (current) => current || marketProviders[0]?.id || "official_registry" ) - if (servers[0]) { - setSelection({ kind: "local", id: servers[0].id }) + if (nextServers[0]) { + setSelection({ kind: "local", id: nextServers[0].id }) } } catch (err) { const message = toLocalizedErrorMessage(err, mcpT) @@ -1019,11 +1028,11 @@ export function McpSettings() {
-
+
setLeftTab(value as LeftTab)} - className="h-full" + className="h-full min-h-0" > @@ -1052,56 +1061,62 @@ export function McpSettings() {
) : null} -
+
{filteredLocalServers.length === 0 ? (
{t("local.empty")}
) : ( - filteredLocalServers.map((server) => { - const active = - selection?.kind === "local" && selection.id === server.id - const spec = isObject(server.spec) ? server.spec : {} - return ( - - - - - - { - uninstallServer(server.id).catch((err) => { - console.error( - "[Settings] uninstall MCP failed:", - err - ) - }) - }} - > - {t("actions.uninstall")} - - - - ) - }) +
+ {filteredLocalServers.map((server) => { + const active = + selection?.kind === "local" && + selection.id === server.id + const spec = isObject(server.spec) ? server.spec : {} + return ( + + + + + + { + uninstallServer(server.id).catch((err) => { + console.error( + "[Settings] uninstall MCP failed:", + err + ) + }) + }} + > + {t("actions.uninstall")} + + + + ) + })} +
)}