Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 49 additions & 28 deletions src-tauri/src/commands/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2030,56 +2030,56 @@ fn remove_cline_server(id: &str) -> Result<bool, AppCommandError> {
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<String, (Value, BTreeSet<McpAppType>)>,
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<Vec<LocalMcpServer>, AppCommandError> {
let mut merged: BTreeMap<String, (Value, BTreeSet<McpAppType>)> = 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
Expand Down Expand Up @@ -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<String, (Value, BTreeSet<McpAppType>)> = 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::Value>(toml_src).expect("parse test toml")
}
Expand Down
96 changes: 96 additions & 0 deletions src/components/settings/mcp-settings.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<McpSettings />)

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(<McpSettings />)

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"
)
})
})
117 changes: 66 additions & 51 deletions src/components/settings/mcp-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,14 @@ function normalizeApps(apps: McpAppType[]): McpAppType[] {
return [...new Set(apps)]
}

function hasVisibleServerId(server: Pick<LocalMcpServer, "id">): 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<McpAppType, boolean> {
const appSet = new Set(apps)
return {
Expand Down Expand Up @@ -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
}, [])
Expand All @@ -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)
Expand Down Expand Up @@ -1019,11 +1028,11 @@ export function McpSettings() {
</Dialog>

<div className="h-full min-h-0 grid grid-cols-1 gap-4 p-3 md:p-4 lg:grid-cols-[360px_1fr]">
<section className="min-h-0 rounded-xl border bg-card p-3">
<section className="min-h-0 overflow-hidden rounded-xl border bg-card p-3">
<Tabs
value={leftTab}
onValueChange={(value) => setLeftTab(value as LeftTab)}
className="h-full"
className="h-full min-h-0"
>
<TabsList className="w-full">
<TabsTrigger value="local" className="flex-1">
Expand Down Expand Up @@ -1052,56 +1061,62 @@ export function McpSettings() {
</div>
) : null}

<div className="flex-1 min-h-0 overflow-auto space-y-1">
<div
data-testid="mcp-local-list-scroll"
className="min-h-0 flex-1 overflow-auto"
>
{filteredLocalServers.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
{t("local.empty")}
</div>
) : (
filteredLocalServers.map((server) => {
const active =
selection?.kind === "local" && selection.id === server.id
const spec = isObject(server.spec) ? server.spec : {}
return (
<ContextMenu key={server.id}>
<ContextMenuTrigger asChild>
<button
className={cn(
"w-full rounded-md border p-2 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "hover:bg-muted/60"
)}
onClick={() => {
setSelection({ kind: "local", id: server.id })
}}
>
<div className="text-sm font-medium break-all">
{server.id}
</div>
<div className="text-xs text-muted-foreground line-clamp-2 break-all">
{specSummary(spec, mcpT)}
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
variant="destructive"
onClick={() => {
uninstallServer(server.id).catch((err) => {
console.error(
"[Settings] uninstall MCP failed:",
err
)
})
}}
>
{t("actions.uninstall")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})
<div className="flex flex-col gap-1">
{filteredLocalServers.map((server) => {
const active =
selection?.kind === "local" &&
selection.id === server.id
const spec = isObject(server.spec) ? server.spec : {}
return (
<ContextMenu key={server.id}>
<ContextMenuTrigger asChild>
<button
className={cn(
"w-full rounded-md border p-2 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "hover:bg-muted/60"
)}
onClick={() => {
setSelection({ kind: "local", id: server.id })
}}
>
<div className="text-sm font-medium break-all">
{server.id}
</div>
<div className="text-xs text-muted-foreground line-clamp-2 break-all">
{specSummary(spec, mcpT)}
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
variant="destructive"
onClick={() => {
uninstallServer(server.id).catch((err) => {
console.error(
"[Settings] uninstall MCP failed:",
err
)
})
}}
>
{t("actions.uninstall")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
)}
</div>

Expand Down