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:
7
.changeset/fn-7025-mcp-dashboard-ui.md
Normal file
7
.changeset/fn-7025-mcp-dashboard-ui.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user