From 301f25da06b12ec720e4fcb6cde9307f2d7231eb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 02:30:34 -0700 Subject: [PATCH] FN-7025: add MCP server management settings UI Adds dashboard controls for managing MCP server configuration across global and project scopes. - Add global and project MCP settings sections with server cards, validation, inheritance status, and import/export controls. - Wire MCP settings into split-save behavior, browser-safe core exports, localization, and dashboard documentation. - Cover MCP settings interactions and save routing with dashboard tests. Files changed: .changeset/fn-7025-mcp-dashboard-ui.md | 7 + docs/dashboard-guide.md | 14 + packages/core/src/types.ts | 4 + .../app/__tests__/settings-save-split.test.ts | 25 + .../dashboard/app/components/SettingsModal.tsx | 25 + .../__tests__/SettingsModal.mcp.test.tsx | 228 +++++++++ .../app/components/settings/save-split.ts | 5 + .../settings/sections/GlobalMcpSection.tsx | 26 ++ .../settings/sections/McpServersCard.css | 177 +++++++ .../settings/sections/McpServersCard.tsx | 515 +++++++++++++++++++++ .../settings/sections/ProjectMcpSection.tsx | 27 ++ packages/i18n/locales/en/app.json | 10 +- packages/i18n/locales/es/app.json | 10 +- packages/i18n/locales/fr/app.json | 10 +- packages/i18n/locales/ko/app.json | 10 +- packages/i18n/locales/zh-CN/app.json | 10 +- packages/i18n/locales/zh-TW/app.json | 10 +- 17 files changed, 1107 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7025 Fusion-Task-Lineage: e4e59431-c819-464b-a04b-e470ac51c460 --- .changeset/fn-7025-mcp-dashboard-ui.md | 7 + docs/dashboard-guide.md | 14 + packages/core/src/types.ts | 4 + .../app/__tests__/settings-save-split.test.ts | 25 + .../app/components/SettingsModal.tsx | 25 + .../__tests__/SettingsModal.mcp.test.tsx | 228 ++++++++ .../app/components/settings/save-split.ts | 5 + .../settings/sections/GlobalMcpSection.tsx | 26 + .../settings/sections/McpServersCard.css | 177 ++++++ .../settings/sections/McpServersCard.tsx | 515 ++++++++++++++++++ .../settings/sections/ProjectMcpSection.tsx | 27 + packages/i18n/locales/en/app.json | 10 +- packages/i18n/locales/es/app.json | 10 +- packages/i18n/locales/fr/app.json | 10 +- packages/i18n/locales/ko/app.json | 10 +- packages/i18n/locales/zh-CN/app.json | 10 +- packages/i18n/locales/zh-TW/app.json | 10 +- 17 files changed, 1107 insertions(+), 6 deletions(-) create mode 100644 .changeset/fn-7025-mcp-dashboard-ui.md create mode 100644 packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx create mode 100644 packages/dashboard/app/components/settings/sections/GlobalMcpSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/McpServersCard.css create mode 100644 packages/dashboard/app/components/settings/sections/McpServersCard.tsx create mode 100644 packages/dashboard/app/components/settings/sections/ProjectMcpSection.tsx diff --git a/.changeset/fn-7025-mcp-dashboard-ui.md b/.changeset/fn-7025-mcp-dashboard-ui.md new file mode 100644 index 0000000000..40d931af88 --- /dev/null +++ b/.changeset/fn-7025-mcp-dashboard-ui.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add MCP server management UI in Settings with global/project scopes, validation, and import/export. +category: feature +dev: New SettingsModal sections global-mcp/mcp + McpServersCard; consumes @fusion/core MCP foundation and POST /api/mcp/validate; sensitive fields bind to secret references only. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 733b119828..c6bce1edf2 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1501,6 +1501,20 @@ Breakpoints: 768px (primary mobile), 1024px (tablet `min-width: 769px and max-wi Manage project and global secrets directly inside **Settings → Project → Secrets**. This section embeds the existing Secrets UI in the settings content panel so you no longer need a footer "Manage secrets" link to leave the modal. +### MCP server management in Settings + +Manage Model Context Protocol servers from the existing Settings modal; no new top-level dashboard view is introduced. + +- **Settings → Global → MCP Servers** stores global MCP defaults shared by projects. +- **Settings → Project → MCP Servers** stores project-level MCP settings. Project entries override global servers by matching `name`, and a same-named disabled project entry suppresses the inherited global server. The project list marks inherited, overridden, project-local, and disabled-global states so operators can see which scope owns the effective entry. +- Supported transports are `stdio`, `sse`, and `streamable-http`. The editor shows the transport-specific command, URL, args, env, and header fields. +- Sensitive MCP values are secret references only. Environment values, HTTP/SSE header values, and tokens must be selected from or created in Fusion secrets; plaintext values are never persisted into the settings blob. +- Each server row has a **Test** control that calls the MCP validation API and renders pending, valid, unreachable, or error status inline using the standard status-dot convention and semantic status colors. +- The import panel accepts Claude Desktop-style `{ "mcpServers": { ... } }` JSON by paste or upload. Imported plaintext sensitive values are converted into Fusion secret references before the settings draft is saved. +- The export panel produces Fusion MCP JSON for the active scope and offers copy/download actions. + +The MCP sections reuse Settings form/card primitives and include mobile layouts for `(max-width: 768px)` so validate, override, disable, import, and export controls remain usable in the Settings sheet. + ### Lazy-Loaded Heavy Views These 20 views are lazy-loaded via `React.lazy()` with ``. `prefetchLazyViews()` warms App-level chunks once on mount via `requestIdleCallback`; AppModals lazy modal imports (`SettingsModal`, `WorkflowNodeEditor`, `SetupWizardModal`) are part of the same inventory. **Do not make these eager.** The user-facing **Artifacts** section is still implemented by the `DocumentsView` component name. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7f2e84386f..caf3a17537 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -13,6 +13,10 @@ export { } from "./capacity.js"; export type { CapacityRiskSignal } from "./capacity.js"; +// FNXC:McpConfig 2026-06-26-02:10: The dashboard Vite build aliases @fusion/core to this browser-safe module, so the pure MCP config helpers are re-exported here for Settings UI import/export, validation, and project-over-global resolution without pulling Node-only stores into the client bundle. +export { exportMcpServersJson, importMcpServersJson, resolveEffectiveMcpServers } from "./mcp-config.js"; +export { validateMcpServerDefinitionDetailed, validateMcpServerDefinitionsDetailed } from "./settings-validation.js"; + /** * Valid thinking effort levels for AI agent sessions, controlling the cost/quality tradeoff of reasoning. * Includes extra-high for maximum-effort requests on reasoning-capable models. diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts index 8816f29181..6a4bfb43b7 100644 --- a/packages/dashboard/app/__tests__/settings-save-split.test.ts +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -214,6 +214,31 @@ describe("splitSettingsSave", () => { expect(projectPatch).toEqual({ maxConcurrent: 7 }); }); + it("routes shared mcpServers only to the active MCP scope", () => { + expect(isGlobalSettingsKey("mcpServers")).toBe(true); + expect(isProjectSettingsKey("mcpServers")).toBe(true); + const globalMcp = { enabled: true, servers: [{ name: "global-docs", transport: "stdio", command: "docs" }] }; + const projectMcp = { enabled: true, servers: [{ name: "project-docs", transport: "stdio", command: "docs" }] }; + + const globalResult = splitSettingsSave({ + payload: { mcpServers: globalMcp }, + initialValues: null, + initialScopedValues: { global: { mcpServers: { enabled: false, servers: [] } }, project: { mcpServers: projectMcp } } as never, + activeSection: "global-mcp", + }); + expect(globalResult.globalPatch).toEqual({ mcpServers: globalMcp }); + expect(globalResult.projectPatch).toEqual({}); + + const projectResult = splitSettingsSave({ + payload: { mcpServers: projectMcp }, + initialValues: null, + initialScopedValues: { global: { mcpServers: globalMcp }, project: { mcpServers: { enabled: false, servers: [] } } } as never, + activeSection: "mcp", + }); + expect(projectResult.globalPatch).toEqual({}); + expect(projectResult.projectPatch).toEqual({ mcpServers: projectMcp }); + }); + it("routes enabled built-in workflow ids as a changed project setting", () => { const { projectPatch } = splitSettingsSave({ payload: { enabledBuiltinWorkflowIds: ["builtin:coding"] }, diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 95c5b703e5..9a32706b97 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -17,6 +17,7 @@ import { NotificationsSection } from "./settings/sections/NotificationsSection"; import { GlobalGeneralSection } from "./settings/sections/GlobalGeneralSection"; import { ResearchGlobalSection } from "./settings/sections/ResearchGlobalSection"; import { RemoteSection } from "./settings/sections/RemoteSection"; +import { GlobalMcpSection } from "./settings/sections/GlobalMcpSection"; import { GlobalModelsSection } from "./settings/sections/GlobalModelsSection"; import { AuthenticationSection } from "./settings/sections/AuthenticationSection"; import { @@ -37,6 +38,7 @@ import { MergeSection } from "./settings/sections/MergeSection"; import { AgentPermissionsSection } from "./settings/sections/AgentPermissionsSection"; import { MemorySection } from "./settings/sections/MemorySection"; import { ResearchProjectSection } from "./settings/sections/ResearchProjectSection"; +import { ProjectMcpSection } from "./settings/sections/ProjectMcpSection"; import { BackupsSection } from "./settings/sections/BackupsSection"; import { LoadingSpinner } from "./LoadingSpinner"; import { PluginsSection } from "./settings/sections/PluginsSection"; @@ -230,6 +232,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ { id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global" }, { id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global" }, { id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global" }, + { id: "global-mcp", label: "MCP Servers", labelKey: "settings.nav.globalMcp", scope: "global" }, { id: "cli-agents", label: "CLI Agents", labelKey: "settings.nav.cliAgents", scope: "global" }, { id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global" }, { id: "remote", label: "Remote Access & Node Sync", labelKey: "settings.nav.remote", scope: "global" }, @@ -256,6 +259,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ { id: "research-project", label: "Research", labelKey: "settings.nav.researchProject", scope: "project" }, { id: "project-models", label: "Project Models", labelKey: "settings.nav.projectModels", scope: "project" }, { id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project" }, + { id: "mcp", label: "MCP Servers", labelKey: "settings.nav.mcp", scope: "project" }, { id: "prompts", label: "Prompts", labelKey: "settings.nav.prompts", scope: "project" }, { id: "plugins", label: "Plugins", labelKey: "settings.nav.plugins", scope: "project" }, ]; @@ -2740,6 +2744,27 @@ export function SettingsModal({ case "secrets": return ; + case "global-mcp": + return ( + + ); + case "mcp": + return ( + + ); case "project-models": return ( diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx new file mode 100644 index 0000000000..f6ce2b04bd --- /dev/null +++ b/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx @@ -0,0 +1,228 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, within, cleanup } from "@testing-library/react"; +import { useState, type ReactNode } from "react"; +import type { GlobalSettings, McpServerDefinition, Settings } from "@fusion/core"; +import { GlobalMcpSection } from "../settings/sections/GlobalMcpSection"; +import { ProjectMcpSection } from "../settings/sections/ProjectMcpSection"; +import { McpServersCard, type McpSettingsScope } from "../settings/sections/McpServersCard"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback: string, values?: Record) => { + if (!values) return fallback; + return Object.entries(values).reduce((text, [key, value]) => text.replace(`{{${key}}}`, String(value)), fallback); + }, + }), +})); + +const secret = { + id: "secret-token", + scope: "project" as const, + key: "TOKEN", + description: null, + accessPolicy: "prompt" as const, + envExportable: false, + envExportKey: null, + lastReadAt: null, +}; + +function mockFetch(statusByName: Record = {}) { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "/api/secrets" && (!init?.method || init.method === "GET")) { + return new Response(JSON.stringify({ secrets: [secret] }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/secrets" && init?.method === "POST") { + const body = JSON.parse(String(init.body ?? "{}")) as { key?: string; scope?: "project" | "global" }; + return new Response(JSON.stringify({ ...secret, id: `created-${body.key ?? "secret"}`, key: body.key ?? "TOKEN", scope: body.scope ?? "project" }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/mcp/validate") { + const body = JSON.parse(String(init?.body ?? "{}")) as { server?: { name?: string } }; + const name = body.server?.name ?? "default"; + const result = statusByName[name] ?? { status: "valid" as const, message: "ok" }; + return new Response(JSON.stringify(result), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response(JSON.stringify({ error: `Unhandled ${url}` }), { status: 500, headers: { "Content-Type": "application/json" } }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalSettings?: Pick | null }) { + let currentForm: Settings = options.form ?? ({} as Settings); + const addToast = vi.fn(); + function Harness() { + const [form, setFormState] = useState(currentForm); + currentForm = form; + return ( + { + setFormState((previous) => { + const resolved = typeof next === "function" ? next(previous) : next; + currentForm = resolved; + return resolved; + }); + }} + /> + ); + } + const result = render(); + return { ...result, addToast, getForm: () => currentForm }; +} + +async function addServer(name: string, transport: "stdio" | "sse" | "streamable-http") { + fireEvent.click(screen.getByRole("button", { name: /Add server/i })); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: name } }); + fireEvent.change(screen.getByLabelText("Transport"), { target: { value: transport } }); + if (transport === "stdio") { + fireEvent.change(screen.getByLabelText("Command"), { target: { value: "node" } }); + fireEvent.click(screen.getByRole("button", { name: /Add secret reference/i })); + fireEvent.change(screen.getByLabelText("Sensitive field name"), { target: { value: "API_TOKEN" } }); + fireEvent.change(screen.getByLabelText("Secret reference"), { target: { value: "project:secret-token" } }); + } else { + fireEvent.change(screen.getByLabelText("URL"), { target: { value: `https://${name}.example.test/mcp` } }); + fireEvent.click(screen.getByRole("button", { name: /Add secret reference/i })); + fireEvent.change(screen.getByLabelText("Sensitive field name"), { target: { value: "Authorization" } }); + fireEvent.change(screen.getByLabelText("Secret reference"), { target: { value: "project:secret-token" } }); + } + fireEvent.click(screen.getByRole("button", { name: /^Save$/i })); + await screen.findByTestId(`mcp-server-row-${name}`); +} + +beforeEach(() => { + mockFetch(); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("768px"), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("MCP Settings UI", () => { + it("renders global and project MCP section affordances without a new lazy view", async () => { + render(Global scope} form={{} as Settings} setForm={vi.fn()} addToast={vi.fn()} />); + expect(await screen.findByTestId("mcp-servers-card-global")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Add server/i })).toBeInTheDocument(); + cleanup(); + + render(Project scope} form={{} as Settings} setForm={vi.fn()} globalSettings={{ mcpServers: { enabled: true, servers: [] } }} addToast={vi.fn()} />); + expect(await screen.findByTestId("mcp-servers-card-project")).toBeInTheDocument(); + expect(screen.getByText("No MCP servers configured.")).toBeInTheDocument(); + }); + + it.each(["stdio", "sse", "streamable-http"] as const)("adds, edits, and removes a %s server without plaintext secrets", async (transport) => { + const { getForm } = renderCard({ scope: "project", form: {} as Settings }); + await screen.findByText("No MCP servers configured."); + + await addServer(`${transport}-server`, transport); + const added = getForm().mcpServers?.servers?.[0]; + expect(added?.transport).toBe(transport); + expect(JSON.stringify(added)).toContain("secret-token"); + expect(JSON.stringify(added)).not.toContain("super-secret"); + + fireEvent.click(screen.getByRole("button", { name: /^Edit$/i })); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: `${transport}-renamed` } }); + fireEvent.click(screen.getByRole("button", { name: /^Save$/i })); + await screen.findByTestId(`mcp-server-row-${transport}-renamed`); + + fireEvent.click(screen.getByRole("button", { name: `Remove ${transport}-renamed` })); + await waitFor(() => expect(screen.queryByTestId(`mcp-server-row-${transport}-renamed`)).not.toBeInTheDocument()); + expect(screen.queryByRole("button", { name: `Remove ${transport}-renamed` })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Add server/i })); + expect(screen.getByTestId("mcp-server-editor")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /^Cancel$/i })); + expect(screen.queryByTestId("mcp-server-editor")).not.toBeInTheDocument(); + }); + + it("marks inherited, overridden, project-local, and disabled-global states", async () => { + const globalServers: McpServerDefinition[] = [ + { name: "shared", transport: "stdio", command: "node" }, + { name: "blocked", transport: "streamable-http", url: "https://blocked.example.test/mcp" }, + ]; + const { getForm } = renderCard({ scope: "project", form: {} as Settings, globalSettings: { mcpServers: { enabled: true, servers: globalServers } } }); + + expect(await screen.findByTestId("mcp-server-row-shared")).toHaveTextContent("inherited"); + fireEvent.click(within(screen.getByTestId("mcp-server-row-shared")).getByRole("button", { name: /Override/i })); + fireEvent.change(screen.getByLabelText("Command"), { target: { value: "python" } }); + fireEvent.click(screen.getByRole("button", { name: /^Save$/i })); + await waitFor(() => expect(screen.getByTestId("mcp-server-row-shared")).toHaveTextContent("overridden")); + + fireEvent.click(within(screen.getByTestId("mcp-server-row-blocked")).getByRole("button", { name: /Disable/i })); + await waitFor(() => expect(screen.getByTestId("mcp-server-row-blocked")).toHaveTextContent("disabled global")); + expect(getForm().mcpServers?.servers?.find((server) => server.name === "blocked")?.enabled).toBe(false); + + await addServer("local-only", "stdio"); + expect(screen.getByTestId("mcp-server-row-local-only")).toHaveTextContent("project local"); + }); + + it("validates servers and renders valid, unreachable, and error status surfaces", async () => { + mockFetch({ + ok: { status: "valid", message: "probe ok" }, + slow: { status: "unreachable", message: "timed out" }, + bad: { status: "error", message: "HTTP 503" }, + }); + renderCard({ + scope: "global", + form: { mcpServers: { enabled: true, servers: [ + { name: "ok", transport: "stdio", command: "node" }, + { name: "slow", transport: "sse", url: "https://slow.example.test/sse" }, + { name: "bad", transport: "streamable-http", url: "https://bad.example.test/mcp" }, + ] } } as Settings, + }); + + for (const name of ["ok", "slow", "bad"]) { + fireEvent.click(within(await screen.findByTestId(`mcp-server-row-${name}`)).getByRole("button", { name: /Test/i })); + } + await waitFor(() => expect(screen.getByTestId("mcp-validation-ok")).toHaveTextContent("probe ok")); + expect(screen.getByTestId("mcp-validation-ok")).toHaveClass("mcp-validation-status--valid"); + expect(screen.getByTestId("mcp-validation-slow")).toHaveClass("mcp-validation-status--unreachable"); + expect(screen.getByTestId("mcp-validation-slow")).toHaveTextContent("timed out"); + expect(screen.getByTestId("mcp-validation-bad")).toHaveClass("mcp-validation-status--error"); + expect(screen.getByTestId("mcp-validation-bad")).toHaveTextContent("HTTP 503"); + }); + + it("imports Claude JSON through secret creation and exports Fusion JSON", async () => { + const { getForm } = renderCard({ scope: "project", form: {} as Settings }); + const importPayload = JSON.stringify({ + mcpServers: { + claude: { command: "node", args: ["server.js"], env: { API_TOKEN: "super-secret" } }, + }, + }); + fireEvent.change(screen.getByPlaceholderText("Paste Claude Desktop mcpServers JSON"), { target: { value: importPayload } }); + fireEvent.click(screen.getByRole("button", { name: /^Import$/i })); + + await screen.findByTestId("mcp-server-row-claude"); + const saved = JSON.stringify(getForm().mcpServers); + expect(saved).toContain("created-mcp.claude.env.API_TOKEN"); + expect(saved).not.toContain("super-secret"); + + fireEvent.click(screen.getByRole("button", { name: /Copy Fusion MCP JSON/i })); + const exported = await screen.findByLabelText("Exported MCP JSON"); + expect((exported as HTMLTextAreaElement).value).toContain("created-mcp.claude.env.API_TOKEN"); + expect((exported as HTMLTextAreaElement).value).not.toContain("super-secret"); + expect(screen.getByRole("link", { name: /Download JSON/i })).toHaveAttribute("download", "fusion-mcp-servers.json"); + + fireEvent.change(screen.getByPlaceholderText("Paste Claude Desktop mcpServers JSON"), { target: { value: importPayload } }); + fireEvent.click(screen.getByRole("button", { name: /^Import$/i })); + expect(await screen.findByText(/Duplicate MCP server name: claude/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index 94b98f3d04..36f9e44e51 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -79,6 +79,7 @@ const GLOBAL_SECTION_KEYS: Record> = { "updateCheckFrequency", "autoReloadOnVersionChange", ]), + "global-mcp": new Set(["mcpServers"]), "global-models": new Set([ "defaultProvider", "defaultModelId", @@ -214,6 +215,9 @@ export function splitSettingsSave({ if (key === "githubTrackingDefaultRepo" && activeSection !== "global-general") { continue; } + if (key === "mcpServers" && activeSection !== "global-mcp") { + continue; + } if (key === "persistAgentThinkingLog") { continue; } @@ -267,6 +271,7 @@ export function splitSettingsSave({ if (key === "githubTokenConfigured" || key === "prAuthAvailable") continue; // server-only if (key === "customProviders") continue; // persisted via dedicated routes, not save-split (see global branch above) if (key === "githubTrackingDefaultRepo" && activeSection === "global-general") continue; + if (key === "mcpServers" && activeSection === "global-mcp") continue; if (!isProjectSettingsKey(key)) continue; const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings]; diff --git a/packages/dashboard/app/components/settings/sections/GlobalMcpSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalMcpSection.tsx new file mode 100644 index 0000000000..b6a14d72dc --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/GlobalMcpSection.tsx @@ -0,0 +1,26 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import { useTranslation } from "react-i18next"; +import type { Settings } from "@fusion/core"; +import type { ToastType } from "../../../hooks/useToast"; +import { McpServersCard } from "./McpServersCard"; + +export interface GlobalMcpSectionProps { + scopeBanner: ReactNode; + form: Settings; + setForm: Dispatch>; + projectId?: string; + addToast: (message: string, type?: ToastType) => void; +} + +export function GlobalMcpSection({ scopeBanner, form, setForm, projectId, addToast }: GlobalMcpSectionProps) { + const { t } = useTranslation("app"); + return ( + <> + {scopeBanner} +

{t("settings.nav.globalMcp", "MCP Servers")}

+ + + ); +} + +export default GlobalMcpSection; diff --git a/packages/dashboard/app/components/settings/sections/McpServersCard.css b/packages/dashboard/app/components/settings/sections/McpServersCard.css new file mode 100644 index 0000000000..12c67f2b22 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/McpServersCard.css @@ -0,0 +1,177 @@ +.mcp-servers-card { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.mcp-servers-card__header, +.mcp-server-row, +.mcp-import-export, +.mcp-sensitive-row, +.mcp-inline-actions { + display: flex; + gap: var(--space-md); +} + +.mcp-servers-card__header, +.mcp-server-row { + align-items: flex-start; + justify-content: space-between; +} + +.mcp-servers-card__title, +.mcp-server-row p, +.mcp-import-export h6 { + margin: 0; +} + +.mcp-servers-card__description, +.mcp-server-row p, +.mcp-empty, +.mcp-empty-inline, +.mcp-effective-count { + color: var(--text-muted); +} + +.mcp-server-list, +.mcp-editor, +.mcp-sensitive-list, +.mcp-import-export__pane, +.mcp-server-row__main { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.mcp-server-row, +.mcp-editor, +.mcp-import-export__pane { + padding: var(--space-md); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); +} + +.mcp-server-row__titleline, +.mcp-validation-status, +.mcp-server-row__actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.mcp-state-badge, +.mcp-transport-badge { + display: inline-flex; + align-items: center; + border-radius: var(--radius-full); + padding: var(--space-2xs) var(--space-xs); + font-size: var(--font-size-xs); + line-height: var(--line-height-tight); + border: var(--border-width) solid var(--border); + color: var(--text-muted); +} + +.mcp-state-badge--disabled, +.mcp-state-badge--disabled-global { + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 12%, transparent); +} + +.mcp-state-badge--overridden, +.mcp-state-badge--project-local { + color: var(--color-info); + background: color-mix(in srgb, var(--color-info) 12%, transparent); +} + +.mcp-state-badge--inherited { + color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 12%, transparent); +} + +.mcp-validation-status { + color: var(--text-muted); +} + +.mcp-validation-status__badge { + display: inline-flex; + align-items: center; + border-radius: var(--radius-full); + padding: var(--space-2xs) var(--space-xs); + border: var(--border-width) solid var(--border); + color: inherit; +} + +.mcp-validation-status--pending { + color: var(--color-warning); +} + +.mcp-validation-status--valid { + color: var(--color-success); +} + +.mcp-validation-status--unreachable, +.mcp-validation-status--error { + color: var(--color-error); +} + +.mcp-editor-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-md); +} + +.mcp-editor-grid__wide, +.mcp-import-export__pane, +.mcp-server-row__main { + flex: 1 1 auto; + min-width: 0; +} + +.mcp-sensitive-row { + align-items: center; + flex-wrap: wrap; +} + +.mcp-sensitive-row .input, +.mcp-sensitive-row .select { + flex: 1 1 var(--settings-field-min-width, 12rem); + min-width: 0; +} + +.mcp-subheading { + font-weight: var(--font-weight-semibold); +} + +.mcp-json-textarea { + min-height: calc(var(--space-xl) * 4); + resize: vertical; + font-family: var(--font-mono); +} + +.mcp-enabled-toggle { + align-self: flex-start; +} + +@media (max-width: 768px) { + .mcp-servers-card__header, + .mcp-server-row, + .mcp-import-export, + .mcp-server-row__actions, + .mcp-sensitive-row, + .mcp-inline-actions { + flex-direction: column; + align-items: stretch; + } + + .mcp-editor-grid { + grid-template-columns: 1fr; + } + + .mcp-server-row__actions .btn, + .mcp-inline-actions .btn, + .mcp-sensitive-row .btn { + justify-content: center; + } +} diff --git a/packages/dashboard/app/components/settings/sections/McpServersCard.tsx b/packages/dashboard/app/components/settings/sections/McpServersCard.tsx new file mode 100644 index 0000000000..392d42b1e7 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/McpServersCard.tsx @@ -0,0 +1,515 @@ +import "./McpServersCard.css"; +import { Download, Pencil, Play, Plus, Trash2, Upload } from "lucide-react"; +import type { Dispatch, SetStateAction } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + exportMcpServersJson, + importMcpServersJson, + isMcpSecretRef, + resolveEffectiveMcpServers, + validateMcpServerDefinitionDetailed, + validateMcpServerDefinitionsDetailed, + type GlobalSettings, + type McpSecretRef, + type McpServerDefinition, + type McpServersSettings, + type Settings, +} from "@fusion/core"; + +export type McpSettingsScope = "global" | "project"; +type ToastKind = "info" | "success" | "error"; +type SecretScope = "project" | "global"; +type Transport = McpServerDefinition["transport"]; +type ValidationStatus = "idle" | "pending" | "valid" | "unreachable" | "error"; +type DisplayState = "configured" | "disabled" | "inherited" | "overridden" | "project-local" | "disabled-global"; + +type FormSetter = Dispatch>; + +interface SecretRecord { + id: string; + scope: SecretScope; + key: string; + description: string | null; + accessPolicy: "auto" | "prompt" | "deny"; + envExportable: boolean; + envExportKey: string | null; + lastReadAt: string | null; +} + +interface SensitiveRowDraft { + id: string; + key: string; + secretRef: string; + scope: SecretScope; + createKey: string; + createValue: string; +} + +interface EditorDraft { + originalName?: string; + name: string; + enabled: boolean; + transport: Transport; + command: string; + argsText: string; + url: string; + env: SensitiveRowDraft[]; + headers: SensitiveRowDraft[]; +} + +interface ValidateState { + status: ValidationStatus; + message?: string; +} + +export interface McpServersCardProps { + scope: McpSettingsScope; + form: Settings; + setForm: FormSetter; + globalSettings?: Pick | null; + projectId?: string; + addToast: (message: string, type?: ToastKind) => void; +} + +const EMPTY_MCP_SETTINGS: McpServersSettings = { enabled: false, servers: [] }; +let rowCounter = 0; + +function nextRowId(): string { + rowCounter += 1; + return `mcp-sensitive-${rowCounter}`; +} + +function normalizeMcpSettings(settings?: McpServersSettings): McpServersSettings { + return { + enabled: settings?.enabled === true, + servers: Array.isArray(settings?.servers) ? settings.servers : [], + }; +} + +function sensitiveRowsFromMap(values: Record | undefined): SensitiveRowDraft[] { + return Object.entries(values ?? {}).map(([key, value]) => { + const ref = isMcpSecretRef(value) ? value : { secretRef: "", scope: "project" as const }; + return { + id: nextRowId(), + key, + secretRef: ref.secretRef, + scope: ref.scope, + createKey: key, + createValue: "", + }; + }); +} + +function draftFromServer(server?: McpServerDefinition): EditorDraft { + if (!server) { + return { + name: "", + enabled: true, + transport: "stdio", + command: "", + argsText: "", + url: "", + env: [], + headers: [], + }; + } + return { + originalName: server.name, + name: server.name, + enabled: server.enabled !== false, + transport: server.transport, + command: server.transport === "stdio" ? server.command : "", + argsText: server.transport === "stdio" ? (server.args ?? []).join(" ") : "", + url: server.transport === "stdio" ? "" : server.url, + env: server.transport === "stdio" ? sensitiveRowsFromMap(server.env) : [], + headers: server.transport === "stdio" ? [] : sensitiveRowsFromMap(server.headers), + }; +} + +function sensitiveRowsToMap(rows: SensitiveRowDraft[]): Record | undefined { + const out: Record = {}; + for (const row of rows) { + const key = row.key.trim(); + if (!key || !row.secretRef.trim()) continue; + out[key] = { secretRef: row.secretRef.trim(), scope: row.scope }; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function draftToServer(draft: EditorDraft): unknown { + const base = { name: draft.name.trim(), ...(draft.enabled ? {} : { enabled: false }) }; + if (draft.transport === "stdio") { + const args = draft.argsText.split(/\s+/u).map((entry) => entry.trim()).filter(Boolean); + return { + ...base, + transport: "stdio", + command: draft.command.trim(), + ...(args.length > 0 ? { args } : {}), + ...(sensitiveRowsToMap(draft.env) ? { env: sensitiveRowsToMap(draft.env) } : {}), + }; + } + return { + ...base, + transport: draft.transport, + url: draft.url.trim(), + ...(sensitiveRowsToMap(draft.headers) ? { headers: sensitiveRowsToMap(draft.headers) } : {}), + }; +} + +function requestJson(url: string, init?: RequestInit): Promise { + return fetch(url, { + ...init, + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + }).then(async (response) => { + if (!response.ok) { + const payload = await response.json().catch(() => ({ error: "Request failed" })); + throw new Error(String(payload?.error ?? "Request failed")); + } + return response.json() as Promise; + }); +} + +function serverSummary(server: McpServerDefinition): string { + if (server.transport === "stdio") return `${server.command}${server.args?.length ? ` ${server.args.join(" ")}` : ""}`; + return server.url; +} + +function getValidateDotClass(status: ValidationStatus): string { + if (status === "valid") return "status-dot status-dot--online"; + if (status === "pending") return "status-dot status-dot--pending"; + if (status === "unreachable" || status === "error") return "status-dot status-dot--error"; + return "status-dot"; +} + +function getStateLabel(state: DisplayState): string { + if (state === "disabled-global") return "disabled global"; + if (state === "project-local") return "project local"; + return state; +} + +function getValidationLabel(status: ValidationStatus): string { + if (status === "idle") return "Not tested"; + if (status === "pending") return "Testing…"; + return status; +} + +/** + * FNXC:McpConfig 2026-06-26-01:17: + * MCP settings are edited through one card for global and project scopes. Sensitive env/header/token-like values are modeled only as Fusion secret references; this component never writes plaintext sensitive values into the settings form. + * + * FNXC:McpConfig 2026-06-26-01:17: + * Project MCP declarations override global servers by matching name and may save enabled:false tombstones to disable inherited global servers. The project card shows inherited, overridden, local, and disabled states so operators can see effective behavior before saving. + */ +export function McpServersCard({ scope, form, setForm, globalSettings, addToast }: McpServersCardProps) { + const { t } = useTranslation("app"); + const fileInputRef = useRef(null); + const settings = normalizeMcpSettings(form.mcpServers ?? EMPTY_MCP_SETTINGS); + const globalMcp = normalizeMcpSettings(globalSettings?.mcpServers); + const configuredServers = settings.servers ?? []; + const globalServers = globalMcp.servers ?? []; + const [editor, setEditor] = useState(null); + const [editorError, setEditorError] = useState(null); + const [secrets, setSecrets] = useState([]); + const [secretsError, setSecretsError] = useState(null); + const [importText, setImportText] = useState(""); + const [importError, setImportError] = useState(null); + const [exportText, setExportText] = useState(""); + const [validateStates, setValidateStates] = useState>({}); + + const reloadSecrets = useCallback(async () => { + try { + const data = await requestJson<{ secrets: SecretRecord[] }>("/api/secrets"); + setSecrets(data.secrets); + setSecretsError(null); + } catch (error) { + setSecretsError(error instanceof Error ? error.message : String(error)); + } + }, []); + + useEffect(() => { + void reloadSecrets(); + }, [reloadSecrets]); + + const effectiveServers = useMemo( + () => scope === "project" ? resolveEffectiveMcpServers({ mcpServers: globalMcp }, { mcpServers: form.mcpServers }) : configuredServers.filter((server) => server.enabled !== false), + [configuredServers, form.mcpServers, globalMcp, scope], + ); + + const globalByName = useMemo(() => new Map(globalServers.map((server) => [server.name, server])), [globalServers]); + const projectByName = useMemo(() => new Map(configuredServers.map((server) => [server.name, server])), [configuredServers]); + + const displayRows = useMemo(() => { + if (scope === "global") return configuredServers.map((server): { server: McpServerDefinition; state: DisplayState } => ({ server, state: server.enabled === false ? "disabled" : "configured" })); + const effectiveByName = new Set(effectiveServers.map((server) => server.name)); + const rows: Array<{ server: McpServerDefinition; state: DisplayState }> = []; + for (const globalServer of globalServers) { + const projectServer = projectByName.get(globalServer.name); + if (projectServer?.enabled === false) { + rows.push({ server: projectServer, state: "disabled-global" }); + } else if (projectServer) { + rows.push({ server: projectServer, state: effectiveByName.has(projectServer.name) ? "overridden" : "disabled" }); + } else { + rows.push({ server: globalServer, state: effectiveByName.has(globalServer.name) ? "inherited" : "disabled" }); + } + } + for (const server of configuredServers) { + if (!globalByName.has(server.name)) rows.push({ server, state: server.enabled === false || !effectiveByName.has(server.name) ? "disabled" : "project-local" }); + } + return rows; + }, [configuredServers, effectiveServers, globalByName, globalServers, projectByName, scope]); + + const updateMcpSettings = (next: McpServersSettings) => { + setForm((current) => ({ ...current, mcpServers: next })); + }; + + const setEnabled = (enabled: boolean) => updateMcpSettings({ ...settings, enabled }); + + const saveServer = () => { + if (!editor) return; + setEditorError(null); + const parsed = validateMcpServerDefinitionDetailed(draftToServer(editor)); + if (!parsed.value) { + setEditorError(parsed.errors.map((error) => error.message).join("; ")); + return; + } + const nextServers = configuredServers.filter((server) => server.name !== (editor.originalName ?? parsed.value!.name)); + const duplicate = nextServers.some((server) => server.name === parsed.value!.name); + if (duplicate) { + setEditorError(t("settings.mcp.duplicateName", "Duplicate MCP server name")); + return; + } + const all = [...nextServers, parsed.value]; + const allValid = validateMcpServerDefinitionsDetailed(all); + if (!allValid.value) { + setEditorError(allValid.errors.map((error) => error.message).join("; ")); + return; + } + updateMcpSettings({ ...settings, enabled: true, servers: allValid.value }); + setEditor(null); + }; + + const removeServer = (name: string) => { + updateMcpSettings({ ...settings, servers: configuredServers.filter((server) => server.name !== name) }); + setValidateStates((current) => { + const next = { ...current }; + delete next[name]; + return next; + }); + }; + + const disableInheritedServer = (name: string) => { + const inherited = globalByName.get(name); + const tombstone: McpServerDefinition = inherited?.transport === "sse" || inherited?.transport === "streamable-http" + ? { name, enabled: false, transport: inherited.transport, url: inherited.url } + : { name, enabled: false, transport: "stdio", command: inherited?.transport === "stdio" ? inherited.command : "disabled" }; + updateMcpSettings({ ...settings, enabled: true, servers: [...configuredServers.filter((server) => server.name !== name), tombstone] }); + }; + + const createSecretForRow = async (row: SensitiveRowDraft, field: "env" | "headers") => { + if (!editor) return; + const key = row.createKey.trim() || row.key.trim(); + if (!key || !row.createValue) { + setEditorError(t("settings.mcp.secretCreateRequired", "Secret key and value are required.")); + return; + } + try { + const secret = await requestJson("/api/secrets", { + method: "POST", + body: JSON.stringify({ + scope: row.scope, + key, + value: row.createValue, + description: `MCP ${editor.name || "server"} ${field} ${row.key}`, + accessPolicy: "prompt", + envExportable: false, + envExportKey: null, + }), + }); + setEditor((current) => current && { + ...current, + [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, secretRef: secret.id, scope: secret.scope, createKey: secret.key, createValue: "" } : candidate), + }); + await reloadSecrets(); + addToast(t("settings.mcp.secretCreated", "Secret created"), "success"); + } catch (error) { + setEditorError(error instanceof Error ? error.message : String(error)); + } + }; + + const validateServer = async (server: McpServerDefinition) => { + setValidateStates((current) => ({ ...current, [server.name]: { status: "pending", message: t("settings.mcp.testing", "Testing…") } })); + try { + const result = await requestJson<{ status: "valid" | "unreachable" | "error"; message?: string }>("/api/mcp/validate", { + method: "POST", + body: JSON.stringify({ server }), + }); + setValidateStates((current) => ({ ...current, [server.name]: { status: result.status, message: result.message ?? getValidationLabel(result.status) } })); + } catch (error) { + setValidateStates((current) => ({ ...current, [server.name]: { status: "error", message: error instanceof Error ? error.message : String(error) } })); + } + }; + + const importServers = async (text: string) => { + setImportError(null); + const result = importMcpServersJson(text, { scope }); + if (result.errors.length > 0) { + setImportError(result.errors.join("; ")); + return; + } + const existingNames = new Set(configuredServers.map((server) => server.name)); + const duplicate = result.definitions.find((server) => existingNames.has(server.name)); + if (duplicate) { + setImportError(t("settings.mcp.importDuplicate", "Duplicate MCP server name: {{name}}", { name: duplicate.name })); + return; + } + try { + const refBySuggestedKey = new Map(); + for (const descriptor of result.secretsToCreate) { + const secret = await requestJson("/api/secrets", { + method: "POST", + body: JSON.stringify({ + scope: descriptor.scope, + key: descriptor.suggestedKey, + value: descriptor.plaintextValue, + description: `MCP import ${descriptor.serverName} ${descriptor.field} ${descriptor.key}`, + accessPolicy: "prompt", + envExportable: false, + envExportKey: null, + }), + }); + refBySuggestedKey.set(descriptor.suggestedKey, { secretRef: secret.id, scope: secret.scope }); + } + const definitions = result.definitions.map((server) => { + if (server.transport === "stdio") { + const env = Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, isMcpSecretRef(value) && refBySuggestedKey.has(value.secretRef) ? refBySuggestedKey.get(value.secretRef)! : value])); + return { ...server, ...(Object.keys(env).length > 0 ? { env } : {}) }; + } + const headers = Object.fromEntries(Object.entries(server.headers ?? {}).map(([key, value]) => [key, isMcpSecretRef(value) && refBySuggestedKey.has(value.secretRef) ? refBySuggestedKey.get(value.secretRef)! : value])); + return { ...server, ...(Object.keys(headers).length > 0 ? { headers } : {}) }; + }); + updateMcpSettings({ ...settings, enabled: true, servers: [...configuredServers, ...definitions] }); + setImportText(""); + await reloadSecrets(); + addToast(t("settings.mcp.imported", "MCP servers imported"), "success"); + } catch (error) { + setImportError(error instanceof Error ? error.message : String(error)); + } + }; + + const exportServers = async () => { + const payload = JSON.stringify(exportMcpServersJson(configuredServers), null, 2); + setExportText(payload); + try { + await navigator.clipboard?.writeText(payload); + addToast(t("settings.mcp.exportCopied", "MCP JSON copied"), "success"); + } catch { + addToast(t("settings.mcp.exportReady", "MCP JSON ready to copy"), "info"); + } + }; + + const renderSensitiveRows = (field: "env" | "headers", rows: SensitiveRowDraft[]) => ( +
+ {/* FNXC:McpConfig 2026-06-26-01:17: This picker is the only UI seam for MCP env/header secrets. Operators may create a secret value here, but the settings draft receives only { secretRef, scope }, never the plaintext input. */} +
{field === "env" ? t("settings.mcp.env", "Environment secret refs") : t("settings.mcp.headers", "Header secret refs")}
+ {rows.length === 0 ?

{t("settings.mcp.noSensitiveRows", "No secret references configured.")}

: null} + {rows.map((row) => ( +
+ setEditor((current) => current && { ...current, [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate) })} placeholder={field === "env" ? "API_KEY" : "Authorization"} /> + + setEditor((current) => current && { ...current, [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, createKey: event.target.value } : candidate) })} placeholder={t("settings.mcp.newSecretKey", "New secret key")} /> + setEditor((current) => current && { ...current, [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, createValue: event.target.value } : candidate) })} placeholder={t("settings.mcp.createSecretPlaceholder", "Create secret value")} /> + + +
+ ))} + + {secretsError ?

{secretsError}

: null} +
+ ); + + return ( +
+
+
+
{scope === "global" ? t("settings.mcp.globalTitle", "Global MCP servers") : t("settings.mcp.projectTitle", "Project MCP servers")}
+

{scope === "global" ? t("settings.mcp.globalDescription", "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.") : t("settings.mcp.projectDescription", "Configure project-specific MCP servers, overrides, and disabled inherited servers.")}

+
+ +
+ + + + {displayRows.length === 0 ?

{t("settings.mcp.empty", "No MCP servers configured.")}

: ( +
+ {displayRows.map(({ server, state }) => { + const validation = validateStates[server.name] ?? { status: "idle" as const }; + const editable = scope === "global" || state !== "inherited"; + return ( +
+
+
+ {server.name} + {getStateLabel(state)} + {server.transport} +
+

{serverSummary(server)}

+

+
+
+ + {state === "inherited" ? : null} + {state === "inherited" ? : null} + {editable ? : null} + {editable ? : null} +
+
+ ); + })} +
+ )} + + {editor ? ( +
+
+ + + + {editor.transport === "stdio" ? <> : } +
+ {editor.transport === "stdio" ? renderSensitiveRows("env", editor.env) : renderSensitiveRows("headers", editor.headers)} + {editorError ?

{editorError}

: null} +
+
+ ) : null} + +
+
+
{t("settings.mcp.import", "Import")}
+