From 32d189c2d988fb683fa314d526ce2aac213c8ba7 Mon Sep 17 00:00:00 2001 From: YaoShenyaoge Date: Sat, 19 Sep 2026 13:26:08 +0800 Subject: [PATCH] feat(mcp): add MCP server settings with project-trust gating Pi has no built-in MCP support: the runtime comes from an extension that reads mcp.json. This adds a settings section that edits the same two files the CLI and that extension read, so a single runtime serves the terminal, Pi Web, and any other host instead of a second, app-local implementation. - lib/mcp-config-store.ts: read/write global and project mcp.json with atomic writes, unknown keys preserved, and per-entry validation - app/api/mcp/route.ts: GET/POST with the standard request guards; project-scope writes require project trust, mirroring the plugin route - components/McpConfig.tsx: settings section listing configured servers plus the live mcp_* tools the session reports - ADR 0004 records why the surface is config-only and why project servers are gated: a project mcp.json spawns a local process, which the extension-level project-trust gate does not cover Also exports PROJECT_SECTIONS from lib/settings-navigation so the settings panel no longer keeps its own copy of the project-section list. --- AGENTS.md | 1 + app/api/mcp/route.test.mjs | 238 ++++++++++ app/api/mcp/route.ts | 112 +++++ components/McpConfig.tsx | 526 +++++++++++++++++++++ components/SettingsPanel.tsx | 7 +- docs/adr/0004-mcp-server-config-surface.md | 62 +++ lib/api-types.ts | 10 + lib/i18n/messages/en.ts | 26 + lib/i18n/messages/zh-CN.ts | 26 + lib/i18n/messages/zh-TW.ts | 26 + lib/mcp-config-store.test.mjs | 190 ++++++++ lib/mcp-config-store.ts | 237 ++++++++++ lib/settings-navigation.ts | 4 +- 13 files changed, 1463 insertions(+), 2 deletions(-) create mode 100644 app/api/mcp/route.test.mjs create mode 100644 app/api/mcp/route.ts create mode 100644 components/McpConfig.tsx create mode 100644 docs/adr/0004-mcp-server-config-surface.md create mode 100644 lib/mcp-config-store.test.mjs create mode 100644 lib/mcp-config-store.ts diff --git a/AGENTS.md b/AGENTS.md index f120bab40e..2baec1302d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,7 @@ app/api/ models-config/discover/route.ts POST fetch a configured provider's upstream model list models-config/test/route.ts POST test a configured model/provider plugins/route.ts GET/POST package plugin management + mcp/route.ts GET/POST MCP server config (~/.pi/agent/mcp.json + /.pi/mcp.json) skills/route.ts GET/PATCH loaded skills and disable-model-invocation skills/install/route.ts POST install skills through npx skills add skills/search/route.ts GET/POST skills.sh search diff --git a/app/api/mcp/route.test.mjs b/app/api/mcp/route.test.mjs new file mode 100644 index 0000000000..28621fca1b --- /dev/null +++ b/app/api/mcp/route.test.mjs @@ -0,0 +1,238 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { after } from "node:test"; +import { createJiti } from "jiti"; + +const root = await mkdtemp(join(tmpdir(), "pi-web-mcp-route-")); +const agentDir = join(root, "agent"); +const trustedCwd = join(root, "trusted-project"); +// A project with `.pi/extensions` requires trust, so it stays untrusted until a +// decision is recorded — exactly the case the route must refuse to edit. +const untrustedCwd = join(root, "untrusted-project"); + +const previousAgentDir = process.env.PI_CODING_AGENT_DIR; +process.env.PI_CODING_AGENT_DIR = agentDir; + +await mkdir(agentDir, { recursive: true }); +await mkdir(trustedCwd, { recursive: true }); +await mkdir(join(untrustedCwd, ".pi", "extensions"), { recursive: true }); +await writeFile(join(untrustedCwd, ".pi", "extensions", "keep.ts"), "export default () => {};\n"); + +const jiti = createJiti(import.meta.url, { alias: { "@": process.cwd() } }); +const { allowFileRoot } = await jiti.import("../../../lib/file-access.ts"); +const { GET, POST } = await jiti.import("./route.ts"); + +allowFileRoot(trustedCwd); +allowFileRoot(untrustedCwd); + +const globalPath = join(agentDir, "mcp.json"); + +after(async () => { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + await rm(root, { recursive: true, force: true }); +}); + +// Node's Request does not derive the Host header from the URL, so routes that +// run isApiRequestAllowed need it set explicitly. +function getRequest(cwd) { + return new Request(`http://localhost/api/mcp?cwd=${encodeURIComponent(cwd)}`, { + headers: { host: "localhost" }, + }); +} + +function postRequest(body, headers = {}) { + return new Request("http://localhost/api/mcp", { + method: "POST", + headers: { host: "localhost", "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +test("GET requires a cwd", async () => { + const response = await GET(new Request("http://localhost/api/mcp")); + assert.equal(response.status, 400); + assert.equal((await response.json()).error, "cwd required"); +}); + +test("GET rejects a cwd outside the allowed roots", async () => { + const response = await GET(getRequest(join(root, "elsewhere"))); + assert.equal(response.status, 403); +}); + +test("GET lists configured servers", async () => { + await writeFile(globalPath, JSON.stringify({ + mcpServers: { filesystem: { command: "npx", args: ["-y", "fs"] } }, + })); + + const response = await GET(getRequest(trustedCwd)); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.deepEqual(body.servers.map((server) => [server.name, server.scope]), [["filesystem", "global"]]); + assert.deepEqual(body.diagnostics, []); + assert.equal(body.projectResourcesLoaded, true); +}); + +test("GET reports an untrusted project", async () => { + const response = await GET(getRequest(untrustedCwd)); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.projectResourcesLoaded, false); +}); + +test("GET surfaces config diagnostics", async () => { + await writeFile(globalPath, "{ broken"); + const response = await GET(getRequest(trustedCwd)); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.diagnostics.length, 1); + assert.equal(body.diagnostics[0].path, globalPath); + await rm(globalPath, { force: true }); +}); + +test("POST rejects a non-JSON content type", async () => { + const response = await POST(new Request("http://localhost/api/mcp", { + method: "POST", + headers: { host: "localhost", "Content-Type": "text/plain" }, + body: "{}", + })); + assert.equal(response.status, 415); +}); + +test("POST rejects a cross-site request", async () => { + const response = await POST(postRequest( + { action: "save", cwd: trustedCwd, name: "x", config: { command: "x" } }, + { "sec-fetch-site": "cross-site" }, + )); + assert.equal(response.status, 403); +}); + +test("POST requires an action", async () => { + const response = await POST(postRequest({ cwd: trustedCwd })); + assert.equal(response.status, 400); + assert.equal((await response.json()).error, "action required"); +}); + +test("POST rejects an unknown action", async () => { + const response = await POST(postRequest({ action: "explode", cwd: trustedCwd, name: "x" })); + assert.equal(response.status, 400); +}); + +test("POST rejects an invalid server name", async () => { + const response = await POST(postRequest({ + action: "save", + cwd: trustedCwd, + name: "has space", + config: { command: "x" }, + })); + assert.equal(response.status, 400); + assert.match((await response.json()).error, /letters/); +}); + +test("POST rejects a config with no transport", async () => { + const response = await POST(postRequest({ + action: "save", + cwd: trustedCwd, + name: "nothing", + config: {}, + })); + assert.equal(response.status, 400); +}); + +test("POST saves a server and returns the refreshed snapshot", async () => { + await rm(globalPath, { force: true }); + const response = await POST(postRequest({ + action: "save", + cwd: trustedCwd, + scope: "global", + name: "filesystem", + config: { command: "npx", args: ["-y", "fs"] }, + })); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.deepEqual(body.servers.map((server) => server.name), ["filesystem"]); + + const written = JSON.parse(await readFile(globalPath, "utf8")); + assert.deepEqual(written.mcpServers.filesystem, { command: "npx", args: ["-y", "fs"] }); +}); + +test("POST saves into the project file", async () => { + const response = await POST(postRequest({ + action: "save", + cwd: trustedCwd, + scope: "project", + name: "repo", + config: { command: "node", args: ["server.mjs"] }, + })); + + assert.equal(response.status, 200); + const written = JSON.parse(await readFile(join(trustedCwd, ".pi", "mcp.json"), "utf8")); + assert.deepEqual(written.mcpServers.repo, { command: "node", args: ["server.mjs"] }); + await rm(join(trustedCwd, ".pi"), { recursive: true, force: true }); +}); + +test("POST refuses to edit project servers while the project is untrusted", async () => { + const response = await POST(postRequest({ + action: "save", + cwd: untrustedCwd, + scope: "project", + name: "evil", + config: { command: "node", args: ["payload.mjs"] }, + })); + + assert.equal(response.status, 403); + assert.match((await response.json()).error, /trusted/); + // Nothing may have been written for that project. + const snapshot = await GET(getRequest(untrustedCwd)); + const body = await snapshot.json(); + assert.deepEqual(body.servers.filter((server) => server.scope === "project"), []); + await assert.rejects(readFile(join(untrustedCwd, ".pi", "mcp.json"), "utf8")); +}); + +test("POST still allows a global save for an untrusted project", async () => { + await rm(globalPath, { force: true }); + const response = await POST(postRequest({ + action: "save", + cwd: untrustedCwd, + scope: "global", + name: "shared", + config: { command: "npx" }, + })); + + assert.equal(response.status, 200); + const written = JSON.parse(await readFile(globalPath, "utf8")); + assert.deepEqual(Object.keys(written.mcpServers), ["shared"]); +}); + +test("POST reports a missing server on remove", async () => { + const response = await POST(postRequest({ + action: "remove", + cwd: trustedCwd, + scope: "global", + name: "absent", + })); + assert.equal(response.status, 404); +}); + +test("POST removes a server", async () => { + await writeFile(globalPath, JSON.stringify({ + mcpServers: { a: { command: "a" }, b: { command: "b" } }, + })); + + const response = await POST(postRequest({ + action: "remove", + cwd: trustedCwd, + scope: "global", + name: "a", + })); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.deepEqual(body.servers.map((server) => server.name), ["b"]); +}); diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts new file mode 100644 index 0000000000..6c46214174 --- /dev/null +++ b/app/api/mcp/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from "next/server"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; +import type { McpServersResponse } from "@/lib/api-types"; +import { + type McpConfigScope, + readMcpServers, + removeMcpServer, + saveMcpServer, + validateServerName, +} from "@/lib/mcp-config-store"; +import { getProjectTrustStatus } from "@/lib/project-trust"; +import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security"; + +export const dynamic = "force-dynamic"; + +type McpAction = "save" | "remove"; + +function readScope(scope: unknown): McpConfigScope { + return scope === "project" ? "project" : "global"; +} + +function snapshot(cwd: string, agentDir: string): McpServersResponse { + const { servers, diagnostics } = readMcpServers(cwd, agentDir); + return { + servers, + diagnostics, + projectResourcesLoaded: getProjectTrustStatus(cwd, agentDir).trusted, + }; +} + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const cwd = searchParams.get("cwd"); + if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 }); + + try { + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + return NextResponse.json(snapshot(cwd, getAgentDir())); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } +} + +// POST /api/mcp body: { action, cwd, scope?, name, config? } +export async function POST(req: Request) { + if (!isApiRequestAllowed(req)) { + return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); + } + if (!hasJsonContentType(req)) { + return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 }); + } + + try { + const body = await req.json() as { + action?: McpAction; + cwd?: string; + scope?: string; + name?: string; + config?: unknown; + }; + if (!body.cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 }); + if (!body.action) return NextResponse.json({ error: "action required" }, { status: 400 }); + + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(body.cwd, allowedRoots)) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + const agentDir = getAgentDir(); + const scope = readScope(body.scope); + // A project config can spawn an arbitrary command, so it is gated exactly + // like project extensions: no edits while the project is untrusted. + if (scope === "project" && !getProjectTrustStatus(body.cwd, agentDir).trusted) { + return NextResponse.json( + { error: "Project resources must be trusted before editing project MCP servers" }, + { status: 403 }, + ); + } + + const nameError = validateServerName(body.name); + if (nameError) return NextResponse.json({ error: nameError }, { status: 400 }); + const name = (body.name as string).trim(); + + if (body.action === "save") { + const configError = saveMcpServer(scope, body.cwd, name, body.config); + if (configError) return NextResponse.json({ error: configError }, { status: 400 }); + } else if (body.action === "remove") { + if (!removeMcpServer(scope, body.cwd, name)) { + return NextResponse.json( + { error: `server "${name}" not found in the ${scope} config` }, + { status: 404 }, + ); + } + } else { + return NextResponse.json({ error: `Unsupported action: ${String(body.action)}` }, { status: 400 }); + } + + return NextResponse.json(snapshot(body.cwd, agentDir)); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } +} diff --git a/components/McpConfig.tsx b/components/McpConfig.tsx new file mode 100644 index 0000000000..a4c5ed3e72 --- /dev/null +++ b/components/McpConfig.tsx @@ -0,0 +1,526 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import { sendAgentCommand } from "@/lib/agent-client"; +import type { McpConfigScope, McpServerConfig, McpServersResponse, McpServerView } from "@/lib/api-types"; +import { getLastSettingsSelection, setLastSettingsSelection } from "@/lib/settings-navigation"; +import type { ToolEntry } from "@/lib/tool-presets"; +import { + ConfigButton, + ConfigDetail, + ConfigDetailActions, + ConfigDetailHeader, + ConfigDetailHeaderInfo, + ConfigDetailStack, + ConfigEmptyState, + ConfigField, + ConfigFooter, + ConfigListAction, + ConfigPanelShell, + ConfigSidebar, + ConfigSidebarGroupLabel, + ConfigSidebarItem, + ConfigSidebarList, + ConfigSidebarText, + ConfigSplitView, + ConfigStatusDot, +} from "./SettingsUi"; + +type Transport = "stdio" | "http"; +type EditorMode = "view" | "create"; + +interface ServerDraft { + name: string; + scope: McpConfigScope; + transport: Transport; + command: string; + /** One argument per line, so paths containing spaces stay unambiguous. */ + args: string; + cwd: string; + /** One `KEY=value` per line. */ + env: string; + url: string; +} + +const inputStyle: CSSProperties = { + width: "100%", + minWidth: 0, + height: 34, + padding: "0 9px", + border: "1px solid var(--border)", + borderRadius: 5, + background: "var(--bg)", + color: "var(--text)", + fontSize: 12, + outline: "none", +}; + +const textareaStyle: CSSProperties = { + ...inputStyle, + height: 72, + padding: "7px 9px", + fontFamily: "var(--font-mono)", + resize: "vertical", +}; + +function shortenPath(path: string): string { + return path.replace(/^\/(?:Users|home)\/[^/]+/, "~"); +} + +function serverKey(server: Pick): string { + return `${server.scope}:${server.name}`; +} + +function joinLines(values: readonly string[] | undefined): string { + return (values ?? []).join("\n"); +} + +function joinEnv(env: Record | undefined): string { + return Object.entries(env ?? {}).map(([key, value]) => `${key}=${value}`).join("\n"); +} + +function draftFromServer(server: McpServerView): ServerDraft { + const config = server.config; + return { + name: server.name, + scope: server.scope, + transport: config.url !== undefined ? "http" : "stdio", + command: config.command ?? "", + args: joinLines(config.args), + cwd: config.cwd ?? "", + env: joinEnv(config.env), + url: config.url ?? "", + }; +} + +function parseArgs(text: string): string[] { + return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0); +} + +function parseEnv(text: string): { env: Record; error?: string } { + const env: Record = {}; + for (const rawLine of text.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + const separator = line.indexOf("="); + if (separator <= 0) return { env, error: `invalid env line: ${line}` }; + env[line.slice(0, separator).trim()] = line.slice(separator + 1); + } + return { env }; +} + +function draftToConfig(draft: ServerDraft): { config?: McpServerConfig; error?: string } { + if (draft.transport === "http") { + if (!draft.url.trim()) return { error: "url" }; + return { config: { url: draft.url.trim() } }; + } + if (!draft.command.trim()) return { error: "command" }; + const { env, error } = parseEnv(draft.env); + if (error) return { error }; + const args = parseArgs(draft.args); + const config: McpServerConfig = { command: draft.command.trim() }; + if (args.length > 0) config.args = args; + if (draft.cwd.trim()) config.cwd = draft.cwd.trim(); + if (Object.keys(env).length > 0) config.env = env; + return { config }; +} + +function emptyDraft(scope: McpConfigScope, existing: readonly McpServerView[]): ServerDraft { + const taken = new Set(existing.map((server) => server.name)); + let name = "new-server"; + let suffix = 2; + while (taken.has(name)) name = `new-server-${suffix++}`; + return { + name, + scope, + transport: "stdio", + command: "", + args: "", + cwd: "", + env: "", + url: "", + }; +} + +export function McpConfig({ + cwd, + sessionId = null, + onClose, + onReloaded, + embedded = false, +}: { + cwd: string; + sessionId?: string | null; + onClose: () => void; + onReloaded?: () => void; + embedded?: boolean; +}) { + const { t } = useI18n(); + const [servers, setServers] = useState([]); + const [diagnostics, setDiagnostics] = useState([]); + const [projectResourcesLoaded, setProjectResourcesLoaded] = useState(true); + const [liveTools, setLiveTools] = useState([]); + const [selectedKey, setSelectedKey] = useState(() => getLastSettingsSelection("mcp", cwd)); + const [draft, setDraft] = useState(null); + const [mode, setMode] = useState("view"); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); + + const selected = useMemo( + () => servers.find((server) => serverKey(server) === selectedKey) ?? null, + [servers, selectedKey], + ); + + const refreshLiveTools = useCallback(async () => { + if (!sessionId) { + setLiveTools([]); + return; + } + try { + const tools = await sendAgentCommand(sessionId, { type: "get_tools" }); + setLiveTools(tools.filter((tool) => tool.name.startsWith("mcp_")).map((tool) => tool.name)); + } catch { + // The session may not be running; live tools are advisory only. + setLiveTools([]); + } + }, [sessionId]); + + const load = useCallback(async (preferredKey?: string | null) => { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/mcp?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store" }); + const data = await response.json() as Partial & { error?: string }; + if (!response.ok || data.error) throw new Error(data.error ?? `HTTP ${response.status}`); + const next = data.servers ?? []; + setServers(next); + setDiagnostics(data.diagnostics ?? []); + setProjectResourcesLoaded(data.projectResourcesLoaded !== false); + + const remembered = preferredKey ?? getLastSettingsSelection("mcp", cwd); + const chosen = next.find((server) => serverKey(server) === remembered) + ?? next.find((server) => server.scope === "project") + ?? next.find((server) => server.scope === "global") + ?? null; + setSelectedKey(chosen ? serverKey(chosen) : null); + setDraft(chosen ? draftFromServer(chosen) : null); + setMode("view"); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoading(false); + } + await refreshLiveTools(); + }, [cwd, refreshLiveTools]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (selectedKey) setLastSettingsSelection("mcp", selectedKey, cwd); + }, [cwd, selectedKey]); + + const selectServer = (server: McpServerView) => { + setSelectedKey(serverKey(server)); + setDraft(draftFromServer(server)); + setMode("view"); + setError(null); + setStatus(null); + }; + + const beginCreate = (scope: McpConfigScope) => { + setSelectedKey(null); + setDraft(emptyDraft(scope, servers)); + setMode("create"); + setError(null); + setStatus(null); + }; + + const applyResponse = (data: McpServersResponse, preferredKey: string | null) => { + setServers(data.servers); + setDiagnostics(data.diagnostics); + setProjectResourcesLoaded(data.projectResourcesLoaded); + const chosen = preferredKey + ? data.servers.find((server) => serverKey(server) === preferredKey) ?? null + : null; + setSelectedKey(chosen ? serverKey(chosen) : null); + setDraft(chosen ? draftFromServer(chosen) : null); + setMode("view"); + }; + + const save = async () => { + if (!draft) return; + const built = draftToConfig(draft); + if (!built.config) { + setError(built.error === "command" + ? t("mcp.errorCommandRequired") + : built.error === "url" + ? t("mcp.errorUrlRequired") + : t("mcp.errorEnvFormat", { line: built.error ?? "" })); + return; + } + + setBusy(true); + setError(null); + setStatus(null); + try { + const response = await fetch("/api/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "save", + cwd, + scope: draft.scope, + name: draft.name.trim(), + config: built.config, + }), + }); + const data = await response.json() as Partial & { error?: string }; + if (!response.ok || data.error) throw new Error(data.error ?? `HTTP ${response.status}`); + applyResponse(data as McpServersResponse, `${draft.scope}:${draft.name.trim()}`); + setStatus(t("mcp.saved")); + await reloadSession(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + if (!selected) return; + setBusy(true); + setError(null); + setStatus(null); + try { + const response = await fetch("/api/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "remove", cwd, scope: selected.scope, name: selected.name }), + }); + const data = await response.json() as Partial & { error?: string }; + if (!response.ok || data.error) throw new Error(data.error ?? `HTTP ${response.status}`); + applyResponse(data as McpServersResponse, null); + setStatus(t("mcp.removed")); + await reloadSession(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + /** + * Apply the new config to the running session. + * + * `reload` re-emits session_shutdown/session_start, which is what makes the + * MCP extension re-read mcp.json and reconnect its servers. + */ + const reloadSession = async () => { + if (!sessionId) return; + try { + await sendAgentCommand(sessionId, { type: "reload" }); + await refreshLiveTools(); + onReloaded?.(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + }; + + const globalServers = servers.filter((server) => server.scope === "global"); + const projectServers = servers.filter((server) => server.scope === "project"); + const projectEditable = projectResourcesLoaded; + + const renderGroup = (label: string, scope: McpConfigScope, group: McpServerView[], addable: boolean) => ( + <> + {label} + {group.map((server) => ( + selectServer(server)} + > + tool.startsWith(`mcp_${server.name}`))} /> + {server.name} + + {server.config.url !== undefined ? t("mcp.transportHttp") : t("mcp.transportStdio")} + + + ))} + {addable && ( + beginCreate(scope)}>{t("mcp.addServer")} + )} + + ); + + return ( + + {!projectResourcesLoaded && ( +
+ {t("trust.mcpNotLoaded")} +
+ )} + + + + + {loading ? ( +
{t("i18n.loading")}
+ ) : servers.length === 0 && liveTools.length === 0 && mode !== "create" ? ( +
{t("mcp.empty")}
+ ) : ( + <> + {renderGroup(t("mcp.scopeGlobal"), "global", globalServers, true)} + {renderGroup(t("mcp.scopeProject"), "project", projectServers, projectEditable)} + {liveTools.length > 0 && ( + <> + {t("mcp.liveTools")} + {liveTools.map((tool) => ( + + + {tool} + + ))} + + )} + + )} +
+
+ + + {!draft ? ( + + {servers.length === 0 ? t("mcp.emptyHint") : t("mcp.selectHint")} + + ) : ( + + + +
+ {mode === "create" ? t("mcp.addServer") : draft.name} +
+
+ {draft.scope === "project" ? t("mcp.scopeProject") : t("mcp.scopeGlobal")} + {selected ? ` · ${shortenPath(selected.path)}` : ""} +
+
+ + void save()}> + {busy ? t("i18n.saving") : t("i18n.save")} + + {mode === "view" && selected && ( + void remove()}> + {t("i18n.remove")} + + )} + +
+ + + setDraft({ ...draft, name: event.target.value })} + /> + + + + + + + {draft.transport === "stdio" ? ( + <> + + setDraft({ ...draft, command: event.target.value })} + /> + + +