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
This commit is contained in:
gsxdsm
2026-06-26 02:30:34 -07:00
parent afa33b76d3
commit 301f25da06
17 changed files with 1107 additions and 6 deletions

View File

@@ -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.

View File

@@ -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 `<Suspense fallback={null}>`. `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.

View File

@@ -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.

View File

@@ -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"] },

View File

@@ -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 <SecretsSection scopeBanner={renderScopeBanner()} addToast={addToast} />;
case "global-mcp":
return (
<GlobalMcpSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
projectId={projectId}
addToast={addToast}
/>
);
case "mcp":
return (
<ProjectMcpSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
globalSettings={scopedSettings?.global ?? null}
projectId={projectId}
addToast={addToast}
/>
);
case "project-models":
return (

View File

@@ -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<string, string | number>) => {
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<string, { status: "valid" | "unreachable" | "error"; message: string }> = {}) {
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<GlobalSettings, "mcpServers"> | null }) {
let currentForm: Settings = options.form ?? ({} as Settings);
const addToast = vi.fn();
function Harness() {
const [form, setFormState] = useState<Settings>(currentForm);
currentForm = form;
return (
<McpServersCard
scope={options.scope}
form={form}
globalSettings={options.globalSettings}
addToast={addToast}
setForm={(next) => {
setFormState((previous) => {
const resolved = typeof next === "function" ? next(previous) : next;
currentForm = resolved;
return resolved;
});
}}
/>
);
}
const result = render(<Harness />);
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(<GlobalMcpSection scopeBanner={<div>Global scope</div>} 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(<ProjectMcpSection scopeBanner={<div>Project scope</div>} 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();
});
});

View File

@@ -79,6 +79,7 @@ const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"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];

View File

@@ -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<SetStateAction<Settings>>;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
}
export function GlobalMcpSection({ scopeBanner, form, setForm, projectId, addToast }: GlobalMcpSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">{t("settings.nav.globalMcp", "MCP Servers")}</h4>
<McpServersCard scope="global" form={form} setForm={setForm} projectId={projectId} addToast={addToast} />
</>
);
}
export default GlobalMcpSection;

View File

@@ -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;
}
}

View File

@@ -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<SetStateAction<Settings>>;
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<GlobalSettings, "mcpServers"> | 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<string, unknown> | 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<string, McpSecretRef> | undefined {
const out: Record<string, McpSecretRef> = {};
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<T>(url: string, init?: RequestInit): Promise<T> {
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<T>;
});
}
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<HTMLInputElement | null>(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<EditorDraft | null>(null);
const [editorError, setEditorError] = useState<string | null>(null);
const [secrets, setSecrets] = useState<SecretRecord[]>([]);
const [secretsError, setSecretsError] = useState<string | null>(null);
const [importText, setImportText] = useState("");
const [importError, setImportError] = useState<string | null>(null);
const [exportText, setExportText] = useState("");
const [validateStates, setValidateStates] = useState<Record<string, ValidateState>>({});
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<SecretRecord>("/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<string, McpSecretRef>();
for (const descriptor of result.secretsToCreate) {
const secret = await requestJson<SecretRecord>("/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[]) => (
<div className="mcp-sensitive-list" data-testid={`mcp-${field}-rows`}>
{/* 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. */}
<div className="mcp-subheading">{field === "env" ? t("settings.mcp.env", "Environment secret refs") : t("settings.mcp.headers", "Header secret refs")}</div>
{rows.length === 0 ? <p className="mcp-empty-inline">{t("settings.mcp.noSensitiveRows", "No secret references configured.")}</p> : null}
{rows.map((row) => (
<div className="mcp-sensitive-row" key={row.id}>
<input className="input" aria-label={t("settings.mcp.sensitiveKey", "Sensitive field name")} value={row.key} onChange={(event) => 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"} />
<select className="select" aria-label={t("settings.mcp.secretReference", "Secret reference")} value={`${row.scope}:${row.secretRef}`} onChange={(event) => {
const [nextScope, nextRef] = event.target.value.split(":");
setEditor((current) => current && { ...current, [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, scope: nextScope as SecretScope, secretRef: nextRef } : candidate) });
}}>
<option value={`${row.scope}:`}>{t("settings.mcp.chooseSecret", "Choose a secret…")}</option>
{secrets.map((secret) => <option key={`${secret.scope}:${secret.id}`} value={`${secret.scope}:${secret.id}`}>{secret.scope}: {secret.key}</option>)}
</select>
<input className="input" aria-label={t("settings.mcp.newSecretKey", "New secret key")} value={row.createKey} onChange={(event) => 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")} />
<input className="input" type="password" aria-label={t("settings.mcp.newSecretValue", "New secret value (not stored in settings)")} value={row.createValue} onChange={(event) => 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")} />
<button type="button" className="btn btn-sm touch-target" onClick={() => void createSecretForRow(row, field)}>{t("settings.mcp.createSecret", "Create secret")}</button>
<button type="button" className="btn btn-icon touch-target" aria-label={t("settings.mcp.removeSensitive", "Remove secret reference")} onClick={() => setEditor((current) => current && { ...current, [field]: current[field].filter((candidate) => candidate.id !== row.id) })}><Trash2 aria-hidden="true" /></button>
</div>
))}
<button type="button" className="btn btn-sm touch-target" onClick={() => setEditor((current) => current && { ...current, [field]: [...current[field], { id: nextRowId(), key: "", secretRef: "", scope, createKey: "", createValue: "" }] })}><Plus aria-hidden="true" /> {t("settings.mcp.addSecretRef", "Add secret reference")}</button>
{secretsError ? <p className="form-error">{secretsError}</p> : null}
</div>
);
return (
<section className="card mcp-servers-card" data-testid={`mcp-servers-card-${scope}`}>
<div className="mcp-servers-card__header">
<div>
<h5 className="mcp-servers-card__title">{scope === "global" ? t("settings.mcp.globalTitle", "Global MCP servers") : t("settings.mcp.projectTitle", "Project MCP servers")}</h5>
<p className="mcp-servers-card__description">{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.")}</p>
</div>
<button type="button" className="btn btn-primary touch-target" onClick={() => { setEditor(draftFromServer()); setEditorError(null); }}><Plus aria-hidden="true" /> {t("settings.mcp.addServer", "Add server")}</button>
</div>
<label className="checkbox-label mcp-enabled-toggle">
<input type="checkbox" checked={settings.enabled === true} onChange={(event) => setEnabled(event.target.checked)} />
{t("settings.mcp.enabled", "Enable MCP servers for this scope")}
</label>
{displayRows.length === 0 ? <p className="mcp-empty" data-testid={`mcp-empty-${scope}`}>{t("settings.mcp.empty", "No MCP servers configured.")}</p> : (
<div className="mcp-server-list">
{displayRows.map(({ server, state }) => {
const validation = validateStates[server.name] ?? { status: "idle" as const };
const editable = scope === "global" || state !== "inherited";
return (
<article className="mcp-server-row" key={`${state}:${server.name}`} data-testid={`mcp-server-row-${server.name}`}>
<div className="mcp-server-row__main">
<div className="mcp-server-row__titleline">
<strong>{server.name}</strong>
<span className={`mcp-state-badge mcp-state-badge--${state}`} data-state={state}>{getStateLabel(state)}</span>
<span className="mcp-transport-badge">{server.transport}</span>
</div>
<p>{serverSummary(server)}</p>
<p className={`mcp-validation-status mcp-validation-status--${validation.status}`} data-testid={`mcp-validation-${server.name}`} aria-live="polite"><span className={getValidateDotClass(validation.status)} aria-hidden="true" /> <span className="mcp-validation-status__badge">{validation.status === "idle" ? t("settings.mcp.notTested", "Not tested") : getValidationLabel(validation.status)}</span>{validation.message ? <span>{validation.message}</span> : null}</p>
</div>
<div className="mcp-server-row__actions">
<button type="button" className="btn btn-sm touch-target" onClick={() => void validateServer(server)} disabled={validation.status === "pending"}><Play aria-hidden="true" /> {validation.status === "pending" ? t("settings.mcp.testing", "Testing…") : t("settings.mcp.test", "Test")}</button>
{state === "inherited" ? <button type="button" className="btn btn-sm touch-target" onClick={() => { setEditor(draftFromServer(server)); setEditorError(null); }}><Pencil aria-hidden="true" /> {t("settings.mcp.override", "Override")}</button> : null}
{state === "inherited" ? <button type="button" className="btn btn-warning btn-sm touch-target" onClick={() => disableInheritedServer(server.name)}>{t("settings.mcp.disableInherited", "Disable")}</button> : null}
{editable ? <button type="button" className="btn btn-sm touch-target" onClick={() => { setEditor(draftFromServer(server)); setEditorError(null); }}><Pencil aria-hidden="true" /> {t("actions.edit", "Edit")}</button> : null}
{editable ? <button type="button" className="btn btn-icon touch-target" aria-label={t("settings.mcp.removeServer", "Remove {{name}}", { name: server.name })} onClick={() => removeServer(server.name)}><Trash2 aria-hidden="true" /></button> : null}
</div>
</article>
);
})}
</div>
)}
{editor ? (
<div className="mcp-editor card" data-testid="mcp-server-editor">
<div className="mcp-editor-grid">
<label className="form-group"><span>{t("settings.mcp.name", "Name")}</span><input className="input" value={editor.name} onChange={(event) => setEditor({ ...editor, name: event.target.value })} /></label>
<label className="form-group"><span>{t("settings.mcp.transport", "Transport")}</span><select className="select" value={editor.transport} onChange={(event) => setEditor({ ...editor, transport: event.target.value as Transport })}><option value="stdio">stdio</option><option value="sse">SSE</option><option value="streamable-http">HTTP</option></select></label>
<label className="checkbox-label"><input type="checkbox" checked={editor.enabled} onChange={(event) => setEditor({ ...editor, enabled: event.target.checked })} /> {t("settings.mcp.serverEnabled", "Server enabled")}</label>
{editor.transport === "stdio" ? <><label className="form-group"><span>{t("settings.mcp.command", "Command")}</span><input className="input" value={editor.command} onChange={(event) => setEditor({ ...editor, command: event.target.value })} /></label><label className="form-group"><span>{t("settings.mcp.args", "Arguments")}</span><input className="input" value={editor.argsText} onChange={(event) => setEditor({ ...editor, argsText: event.target.value })} /></label></> : <label className="form-group mcp-editor-grid__wide"><span>{t("settings.mcp.url", "URL")}</span><input className="input" value={editor.url} onChange={(event) => setEditor({ ...editor, url: event.target.value })} /></label>}
</div>
{editor.transport === "stdio" ? renderSensitiveRows("env", editor.env) : renderSensitiveRows("headers", editor.headers)}
{editorError ? <p className="form-error">{editorError}</p> : null}
<div className="modal-actions"><button type="button" className="btn" onClick={() => { setEditor(null); setEditorError(null); }}>{t("actions.cancel", "Cancel")}</button><button type="button" className="btn btn-primary" onClick={saveServer}>{t("actions.save", "Save")}</button></div>
</div>
) : null}
<div className="mcp-import-export">
<div className="mcp-import-export__pane">
<h6>{t("settings.mcp.import", "Import")}</h6>
<textarea className="input mcp-json-textarea" value={importText} onChange={(event) => setImportText(event.target.value)} placeholder={t("settings.mcp.importPlaceholder", "Paste Claude Desktop mcpServers JSON")} />
<input ref={fileInputRef} className="visually-hidden" type="file" accept="application/json,.json" onChange={(event) => { const file = event.target.files?.[0]; if (!file) return; void file.text().then(setImportText); event.target.value = ""; }} />
<div className="mcp-inline-actions"><button type="button" className="btn btn-sm touch-target" onClick={() => fileInputRef.current?.click()}><Upload aria-hidden="true" /> {t("settings.mcp.uploadJson", "Upload JSON")}</button><button type="button" className="btn btn-sm touch-target" onClick={() => void importServers(importText)}>{t("settings.mcp.import", "Import")}</button></div>
{importError ? <p className="form-error">{importError}</p> : null}
</div>
<div className="mcp-import-export__pane">
<h6>{t("settings.mcp.export", "Export")}</h6>
<button type="button" className="btn btn-sm touch-target" onClick={() => void exportServers()}><Download aria-hidden="true" /> {t("settings.mcp.copyExport", "Copy Fusion MCP JSON")}</button>
{exportText ? <a className="btn btn-sm touch-target" download="fusion-mcp-servers.json" href={`data:application/json;charset=utf-8,${encodeURIComponent(exportText)}`}>{t("settings.mcp.downloadExport", "Download JSON")}</a> : null}
{exportText ? <textarea className="input mcp-json-textarea" readOnly value={exportText} aria-label={t("settings.mcp.exportedJson", "Exported MCP JSON")} /> : null}
</div>
</div>
{scope === "project" ? <p className="mcp-effective-count">{t("settings.mcp.effectiveCount", "Effective enabled servers: {{count}}", { count: effectiveServers.length })}</p> : null}
</section>
);
}
export default McpServersCard;

View File

@@ -0,0 +1,27 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import type { GlobalSettings, Settings } from "@fusion/core";
import type { ToastType } from "../../../hooks/useToast";
import { McpServersCard } from "./McpServersCard";
export interface ProjectMcpSectionProps {
scopeBanner: ReactNode;
form: Settings;
setForm: Dispatch<SetStateAction<Settings>>;
globalSettings?: Pick<GlobalSettings, "mcpServers"> | null;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
}
export function ProjectMcpSection({ scopeBanner, form, setForm, globalSettings, projectId, addToast }: ProjectMcpSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">{t("settings.nav.mcp", "MCP Servers")}</h4>
<McpServersCard scope="project" form={form} setForm={setForm} globalSettings={globalSettings} projectId={projectId} addToast={addToast} />
</>
);
}
export default ProjectMcpSection;

View File

@@ -6128,7 +6128,9 @@
"tooltip": {
"global": "Shared across all projects",
"project": "Specific to this project"
}
},
"globalMcp": "MCP Servers",
"mcp": "MCP Servers"
},
"nodeRouting": {
"blockExecution": "Block execution",
@@ -6508,6 +6510,12 @@
"fileBrowser": {
"currentDirectory": "Current directory:",
"projectRoot": "(project root)"
},
"mcp": {
"globalTitle": "Global MCP servers",
"projectTitle": "Project MCP servers",
"globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.",
"projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers."
}
},
"setup": {

View File

@@ -6118,7 +6118,9 @@
"tooltip": {
"global": "Compartido en todos los proyectos",
"project": "Específico de este proyecto"
}
},
"globalMcp": "MCP Servers",
"mcp": "MCP Servers"
},
"nodeRouting": {
"blockExecution": "",
@@ -6498,6 +6500,12 @@
"fileBrowser": {
"currentDirectory": "",
"projectRoot": ""
},
"mcp": {
"globalTitle": "Global MCP servers",
"projectTitle": "Project MCP servers",
"globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.",
"projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers."
}
},
"setup": {

View File

@@ -6118,7 +6118,9 @@
"tooltip": {
"global": "Partagé entre tous les projets",
"project": "Spécifique à ce projet"
}
},
"globalMcp": "MCP Servers",
"mcp": "MCP Servers"
},
"nodeRouting": {
"blockExecution": "",
@@ -6498,6 +6500,12 @@
"fileBrowser": {
"currentDirectory": "",
"projectRoot": ""
},
"mcp": {
"globalTitle": "Global MCP servers",
"projectTitle": "Project MCP servers",
"globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.",
"projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers."
}
},
"setup": {

View File

@@ -6118,7 +6118,9 @@
"tooltip": {
"global": "모든 프로젝트에서 공유됨",
"project": "이 프로젝트에만 적용됨"
}
},
"globalMcp": "MCP Servers",
"mcp": "MCP Servers"
},
"nodeRouting": {
"blockExecution": "",
@@ -6498,6 +6500,12 @@
"fileBrowser": {
"currentDirectory": "",
"projectRoot": ""
},
"mcp": {
"globalTitle": "Global MCP servers",
"projectTitle": "Project MCP servers",
"globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.",
"projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers."
}
},
"setup": {

View File

@@ -6118,7 +6118,9 @@
"tooltip": {
"global": "在所有项目中共享",
"project": "特定于此项目"
}
},
"globalMcp": "MCP Servers",
"mcp": "MCP Servers"
},
"nodeRouting": {
"blockExecution": "",
@@ -6498,6 +6500,12 @@
"fileBrowser": {
"currentDirectory": "",
"projectRoot": ""
},
"mcp": {
"globalTitle": "Global MCP servers",
"projectTitle": "Project MCP servers",
"globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.",
"projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers."
}
},
"setup": {

View File

@@ -6118,7 +6118,9 @@
"tooltip": {
"global": "在所有專案中共用",
"project": "特定於此專案"
}
},
"globalMcp": "MCP Servers",
"mcp": "MCP Servers"
},
"nodeRouting": {
"blockExecution": "",
@@ -6498,6 +6500,12 @@
"fileBrowser": {
"currentDirectory": "",
"projectRoot": ""
},
"mcp": {
"globalTitle": "Global MCP servers",
"projectTitle": "Project MCP servers",
"globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.",
"projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers."
}
},
"setup": {