FN-7064: discover MCP servers in settings

Add read-only MCP discovery with settings UI controls that keep discovered secrets bound to Fusion secret references.

- Add core MCP discovery source resolution and parsing for supported global and project tool configs.
- Expose discovered MCP server candidates through the dashboard API and engine discovery service.
- Surface discovered servers in global and project MCP settings with explicit add flows and configured-state handling.
- Require discovered sensitive env/header/token descriptors to stay locked until bound or created as Fusion secret references.
- Document discovery behavior and add a published package changeset.

Files changed:
 .changeset/fn-7064-mcp-auto-discovery.md           |   7 ++
 docs/dashboard-guide.md                            |   3 +-
 docs/mcp.md                                        |  53 ++++++++-
 docs/settings-reference.md                         |   4 +-
 packages/core/src/__tests__/mcp-discovery.test.ts  |  74 ++++++++++++
 packages/core/src/index.ts                         |   7 ++
 packages/core/src/mcp-discovery.ts                 | 125 ++++++++++++++++++++
 .../__tests__/SettingsModal.mcp.test.tsx           |  76 +++++++++++-
 .../settings/sections/McpServersCard.css           |  34 +++++-
 .../settings/sections/McpServersCard.tsx           | 130 ++++++++++++++++++++-
 .../src/__tests__/mcp-discovered-route.test.ts     | 109 +++++++++++++++++
 packages/dashboard/src/routes.ts                   |  46 ++++++++
 .../src/__tests__/mcp-discovery-service.test.ts    |  60 ++++++++++
 packages/engine/src/index.ts                       |   1 +
 packages/engine/src/mcp-discovery-service.ts       |  65 +++++++++++
 15 files changed, 782 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-7064
Fusion-Task-Lineage: ef5dc53f-f8d1-4cc8-bdaf-e98d63ee8406
This commit is contained in:
gsxdsm
2026-06-26 11:42:24 -07:00
parent bf51f1b740
commit f685518a93
15 changed files with 782 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Auto-discover MCP servers from Claude/Cursor/Windsurf/VS Code and opt-in to enable them in Settings.
category: feature
dev: New @fusion/core mcp-discovery source resolution + parser, @fusion/engine discoverMcpServers fs reader, GET /api/mcp/discovered route, and a discovered region in McpServersCard. Read-only/opt-in; discovered secrets become Fusion secret references, never plaintext.

View File

@@ -1519,10 +1519,11 @@ Manage Model Context Protocol servers from the existing Settings modal; no new t
- Supported transports are `stdio`, `sse`, and `streamable-http`. The editor shows the transport-specific command, URL, args, env, and header fields. - 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. - 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. - 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 **Discovered on this machine** region scans known Claude Desktop, Claude Code, Cursor, Windsurf, and VS Code MCP config paths for the selected scope. Candidates are read-only and inert until the operator clicks **Add**; sensitive discovered values open the secret-reference editor instead of persisting plaintext.
- 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 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 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. The MCP sections reuse Settings form/card primitives and include mobile layouts for `(max-width: 768px)` so validate, discovery, override, disable, import, and export controls remain usable in the Settings sheet.
### Lazy-Loaded Heavy Views ### Lazy-Loaded Heavy Views

View File

@@ -94,9 +94,56 @@ Note: `fn mcp validate` currently validates stored definitions and reports wheth
5. Save the server. Expected outcome: the row appears with its transport, state badge, and validation status of **Not tested**. 5. Save the server. Expected outcome: the row appears with its transport, state badge, and validation status of **Not tested**.
6. In project settings, review inherited rows from global settings. Use **Override** to replace an inherited server or **Disable** to add a same-named project disabled entry. Expected outcome: state badges identify inherited, overridden, project-local, and disabled-global behavior before you save. 6. In project settings, review inherited rows from global settings. Use **Override** to replace an inherited server or **Disable** to add a same-named project disabled entry. Expected outcome: state badges identify inherited, overridden, project-local, and disabled-global behavior before you save.
7. Click **Test** on a server row. Expected outcome: the row shows **Testing…** while pending, then `valid`, `unreachable`, or `error` with the returned message. 7. Click **Test** on a server row. Expected outcome: the row shows **Testing…** while pending, then `valid`, `unreachable`, or `error` with the returned message.
8. Use the **Import** pane to paste JSON or choose **Upload JSON**. Expected outcome: Claude Desktop-style servers are added to the draft, duplicate names are rejected, and plaintext env/header values are converted into newly created Fusion secrets plus secret references. 8. Review **Discovered on this machine**. Expected outcome: Fusion shows read-only MCP servers found in supported third-party config files for this scope, with source labels and a **Configured** badge for same-named servers already present in the current settings draft.
9. Use **Copy Fusion MCP JSON** and then **Download JSON** when needed. Expected outcome: the export contains Fusion MCP JSON with secret references, and no plaintext secret values. 9. Click **Add** for a discovered server you trust. Expected outcome: servers without sensitive fields are copied into the current scope and enable that scope; servers with discovered env/header/token material open the editor so you bind existing Fusion secrets or create new Fusion secrets before saving.
10. Save the Settings modal. Expected outcome: the selected global or project `mcpServers` settings are persisted and used by subsequent MCP-capable AI sessions. 10. Use the **Import** pane to paste JSON or choose **Upload JSON**. Expected outcome: Claude Desktop-style servers are added to the draft, duplicate names are rejected, and plaintext env/header values are converted into newly created Fusion secrets plus secret references.
11. Use **Copy Fusion MCP JSON** and then **Download JSON** when needed. Expected outcome: the export contains Fusion MCP JSON with secret references, and no plaintext secret values.
12. Save the Settings modal. Expected outcome: the selected global or project `mcpServers` settings are persisted and used by subsequent MCP-capable AI sessions.
## Auto-discovering MCP servers
Fusion can scan known on-host MCP configuration files and show inert candidates in **Settings → Global → MCP Servers** and **Settings → Project → MCP Servers**. Discovery is read-only: Fusion does not auto-enable, spawn, validate, connect to, or otherwise execute a discovered server. A discovered server becomes trusted only after an operator clicks **Add**, reviews the definition, binds any required secrets, and saves Settings.
The scanner reads only these well-known paths; missing files are normal and malformed files produce non-fatal notes in the card:
| Tool | Scope | macOS / Linux path | Windows path |
|---|---|---|---|
| Claude Desktop | Global | macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`; Linux: `~/.config/Claude/claude_desktop_config.json` | `%APPDATA%\\Claude\\claude_desktop_config.json` |
| Claude Code | Global | `~/.claude.json` | `%USERPROFILE%\\.claude.json` |
| Cursor | Global | `~/.cursor/mcp.json` | `%USERPROFILE%\\.cursor\\mcp.json` |
| Windsurf | Global | `~/.codeium/windsurf/mcp_config.json` | `%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json` |
| Cursor | Project | `<projectRootDir>/.cursor/mcp.json` | `<projectRootDir>\\.cursor\\mcp.json` |
| VS Code | Project | `<projectRootDir>/.vscode/mcp.json` | `<projectRootDir>\\.vscode\\mcp.json` |
Claude Desktop, Claude Code, Cursor, and Windsurf use the Claude-style `{ "mcpServers": { ... } }` shape. VS Code project config can use `{ "servers": { ... } }`; Fusion normalizes it to the same import parser before rendering candidates.
Sensitive discovery follows the same no-plaintext rule as manual import. If a third-party file contains inline environment values, header values, or token-like values, the API response includes only secret descriptor metadata (`field`, `key`, `suggestedKey`, `scope`) and the candidate definition uses Fusion `McpSecretRef` placeholders. The dashboard **Add** flow opens the server editor so operators choose existing Fusion secrets or create new Fusion-managed secrets; the settings blob stores only `{ secretRef, scope }` references.
The dashboard uses this route:
```http
GET /api/mcp/discovered?scope=global|project
```
Response shape:
```json
{
"sources": [{ "id": "vscode-project", "tool": "VS Code", "label": "VS Code project", "scope": "project", "path": "/repo/.vscode/mcp.json" }],
"servers": [
{
"source": { "id": "vscode-project", "tool": "VS Code", "label": "VS Code project", "scope": "project", "path": "/repo/.vscode/mcp.json" },
"definition": { "name": "docs", "transport": "stdio", "command": "node", "env": { "API_KEY": { "secretRef": "mcp.docs.env.API_KEY", "scope": "project" } } },
"alreadyConfigured": false,
"hasPlaintextSecrets": true,
"secretDescriptors": [{ "field": "env", "key": "API_KEY", "suggestedKey": "mcp.docs.env.API_KEY", "scope": "project" }]
}
],
"errors": []
}
```
Expected outcome: API clients can display candidates, source labels, configured badges, and secret-binding prompts without receiving plaintext secret values.
## Managing servers from the CLI ## Managing servers from the CLI

View File

@@ -166,7 +166,9 @@ Secret rule: `env` and `headers` maps are sensitive. Values must be Fusion secre
`POST /api/mcp/validate` validates an MCP server definition or configured server name against the current project context. The route resolves and materializes the target server with the same secret rules, then performs a bounded reachability probe (`stdio` supervised spawn, `sse`/`streamable-http` bounded fetch) and returns only `{ status, message? }` without resolved env/header contents. `POST /api/mcp/validate` validates an MCP server definition or configured server name against the current project context. The route resolves and materializes the target server with the same secret rules, then performs a bounded reachability probe (`stdio` supervised spawn, `sse`/`streamable-http` bounded fetch) and returns only `{ status, message? }` without resolved env/header contents.
See [MCP](./mcp.md) for the full configuration and usage guide, including dashboard, CLI, Claude Desktop import, Fusion export, and reachability procedures. `GET /api/mcp/discovered?scope=global|project` powers the Settings auto-discovery region. It reads only known Claude Desktop, Claude Code, Cursor, Windsurf, and VS Code config paths, returns inert candidates plus configured badges, and strips plaintext secret material from secret descriptors before responding.
See [MCP](./mcp.md) for the full configuration and usage guide, including dashboard, CLI, auto-discovery, Claude Desktop import, Fusion export, and reachability procedures.
### Notification providers (pluggable) ### Notification providers (pluggable)

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { getMcpDiscoverySources, parseDiscoveredMcpServersFromFile } from "../mcp-discovery.js";
function pathsFor(platform: NodeJS.Platform, projectRootDir?: string) {
return getMcpDiscoverySources({ homeDir: platform === "win32" ? "C:\\Users\\Ada" : "/Users/ada", platform, projectRootDir });
}
describe("MCP discovery core helpers", () => {
it("resolves well-known global and project paths per platform", () => {
expect(pathsFor("darwin").map((source) => source.path)).toEqual([
"/Users/ada/Library/Application Support/Claude/claude_desktop_config.json",
"/Users/ada/.claude.json",
"/Users/ada/.cursor/mcp.json",
"/Users/ada/.codeium/windsurf/mcp_config.json",
]);
expect(pathsFor("linux", "/repo").map((source) => source.path)).toEqual([
"/Users/ada/.config/Claude/claude_desktop_config.json",
"/Users/ada/.claude.json",
"/Users/ada/.cursor/mcp.json",
"/Users/ada/.codeium/windsurf/mcp_config.json",
"/repo/.cursor/mcp.json",
"/repo/.vscode/mcp.json",
]);
expect(pathsFor("win32", "C:\\repo").map((source) => source.path)).toEqual([
"C:\\Users\\Ada\\AppData\\Roaming\\Claude\\claude_desktop_config.json",
"C:\\Users\\Ada\\.claude.json",
"C:\\Users\\Ada\\.cursor\\mcp.json",
"C:\\Users\\Ada\\.codeium\\windsurf\\mcp_config.json",
"C:\\repo\\.cursor\\mcp.json",
"C:\\repo\\.vscode\\mcp.json",
]);
});
it("parses Claude-style MCP config and converts plaintext secrets to descriptors", () => {
const [source] = pathsFor("darwin");
const result = parseDiscoveredMcpServersFromFile({
source,
contents: JSON.stringify({
mcpServers: {
github: { command: "github-mcp", args: ["serve"], env: { GITHUB_TOKEN: "ghp_secret" } },
docs: { transport: "streamable-http", url: "https://docs.example.test/mcp", headers: { Authorization: "Bearer secret" } },
},
}),
});
expect(result.errors).toEqual([]);
expect(result.servers.map((server) => server.definition.name)).toEqual(["github", "docs"]);
expect(result.servers[0]?.definition).toMatchObject({
name: "github",
transport: "stdio",
env: { GITHUB_TOKEN: { secretRef: "mcp.github.env.GITHUB_TOKEN", scope: "global" } },
});
expect(result.servers.flatMap((server) => server.secretsToCreate).map(({ plaintextValue, ...rest }) => rest)).toEqual([
{ serverName: "github", field: "env", key: "GITHUB_TOKEN", scope: "global", suggestedKey: "mcp.github.env.GITHUB_TOKEN" },
{ serverName: "docs", field: "headers", key: "Authorization", scope: "global", suggestedKey: "mcp.docs.headers.Authorization" },
]);
expect(JSON.stringify(result.servers.map((server) => server.definition))).not.toContain("ghp_secret");
expect(JSON.stringify(result.servers.map((server) => server.definition))).not.toContain("Bearer secret");
});
it("normalizes VS Code servers and reports malformed JSON without throwing", () => {
const source = pathsFor("linux", "/repo").find((candidate) => candidate.id === "vscode-project")!;
const parsed = parseDiscoveredMcpServersFromFile({
source,
contents: JSON.stringify({ servers: { local: { command: "node", args: ["server.js"] } } }),
});
expect(parsed.errors).toEqual([]);
expect(parsed.servers[0]?.definition).toMatchObject({ name: "local", transport: "stdio", command: "node" });
const malformed = parseDiscoveredMcpServersFromFile({ source, contents: "{" });
expect(malformed.servers).toEqual([]);
expect(malformed.errors[0]).toMatch(/JSON|position|Expected/i);
});
});

View File

@@ -56,6 +56,13 @@ export type {
McpSecretImportDescriptor, McpSecretImportDescriptor,
McpServersImportResult, McpServersImportResult,
} from "./mcp-config.js"; } from "./mcp-config.js";
export {
getMcpDiscoverySources,
parseDiscoveredMcpServersFromFile,
type McpDiscoverySource,
type McpDiscoverySourcesOptions,
type DiscoveredMcpServer,
} from "./mcp-discovery.js";
export { export {
resolveAgentMemoryInclusionMode, resolveAgentMemoryInclusionMode,
type AgentMemoryInclusionModeSource, type AgentMemoryInclusionModeSource,

View File

@@ -0,0 +1,125 @@
import type { McpSecretImportDescriptor } from "./mcp-config.js";
import { importMcpServersJson } from "./mcp-config.js";
import type { McpServerDefinition } from "./types.js";
export interface McpDiscoverySource {
id: string;
tool: string;
label: string;
scope: "global" | "project";
path: string;
}
export interface DiscoveredMcpServer {
source: McpDiscoverySource;
definition: McpServerDefinition;
secretsToCreate: McpSecretImportDescriptor[];
}
export interface McpDiscoverySourcesOptions {
homeDir: string;
platform: NodeJS.Platform;
projectRootDir?: string;
}
function joinDiscoveryPath(platform: NodeJS.Platform, ...segments: string[]): string {
const separator = platform === "win32" ? "\\" : "/";
const [first = "", ...rest] = segments;
return [first.replace(/[\\/]+$/, ""), ...rest.map((segment) => segment.replace(/^[\\/]+|[\\/]+$/g, ""))].filter(Boolean).join(separator);
}
function claudeDesktopConfigDir(opts: { homeDir: string; platform: NodeJS.Platform }): string {
if (opts.platform === "darwin") return joinDiscoveryPath(opts.platform, opts.homeDir, "Library", "Application Support");
if (opts.platform === "win32") return joinDiscoveryPath(opts.platform, opts.homeDir, "AppData", "Roaming");
return joinDiscoveryPath(opts.platform, opts.homeDir, ".config");
}
/**
* FNXC:McpConfig 2026-06-26-10:31:
* MCP discovery resolves only well-known third-party config paths and parses them as inert candidates. Discovery is read-only and opt-in; sensitive env/header/token values are converted into secretsToCreate descriptors by the shared import parser instead of remaining inline plaintext in returned definitions. This resolver/parser never throws so a malformed on-host tool config cannot break settings reads.
*/
export function getMcpDiscoverySources(opts: McpDiscoverySourcesOptions): McpDiscoverySource[] {
const homeDir = opts.homeDir.trim();
if (!homeDir) return [];
const sources: McpDiscoverySource[] = [
{
id: "claude-desktop-global",
tool: "Claude Desktop",
label: "Claude Desktop",
scope: "global",
path: joinDiscoveryPath(opts.platform, claudeDesktopConfigDir({ homeDir, platform: opts.platform }), "Claude", "claude_desktop_config.json"),
},
{
id: "claude-code-global",
tool: "Claude Code",
label: "Claude Code",
scope: "global",
path: joinDiscoveryPath(opts.platform, homeDir, ".claude.json"),
},
{
id: "cursor-global",
tool: "Cursor",
label: "Cursor global",
scope: "global",
path: joinDiscoveryPath(opts.platform, homeDir, ".cursor", "mcp.json"),
},
{
id: "windsurf-global",
tool: "Windsurf",
label: "Windsurf",
scope: "global",
path: joinDiscoveryPath(opts.platform, homeDir, ".codeium", "windsurf", "mcp_config.json"),
},
];
if (opts.projectRootDir?.trim()) {
sources.push(
{
id: "cursor-project",
tool: "Cursor",
label: "Cursor project",
scope: "project",
path: joinDiscoveryPath(opts.platform, opts.projectRootDir, ".cursor", "mcp.json"),
},
{
id: "vscode-project",
tool: "VS Code",
label: "VS Code project",
scope: "project",
path: joinDiscoveryPath(opts.platform, opts.projectRootDir, ".vscode", "mcp.json"),
},
);
}
return sources;
}
function normalizeDiscoveryContents(source: McpDiscoverySource, contents: string): string | unknown {
if (source.id !== "vscode-project") return contents;
const parsed = JSON.parse(contents) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const record = parsed as Record<string, unknown>;
if (record.mcpServers === undefined && record.servers !== undefined) {
return { ...record, mcpServers: record.servers };
}
}
return parsed;
}
export function parseDiscoveredMcpServersFromFile(args: { source: McpDiscoverySource; contents: string }): { servers: DiscoveredMcpServer[]; errors: string[] } {
try {
const normalized = normalizeDiscoveryContents(args.source, args.contents);
const result = importMcpServersJson(normalized, { scope: args.source.scope });
const servers = result.definitions.map((definition) => ({
source: args.source,
definition,
secretsToCreate: result.secretsToCreate.filter((secret) => secret.serverName === definition.name),
}));
return { servers, errors: result.errors };
} catch (error) {
return {
servers: [],
errors: [error instanceof Error ? error.message : String(error)],
};
}
}

View File

@@ -26,9 +26,25 @@ const secret = {
lastReadAt: null, lastReadAt: null,
}; };
function mockFetch(statusByName: Record<string, { status: "valid" | "unreachable" | "error"; message: string }> = {}) { function discoveredResponse(scope: McpSettingsScope) {
return {
sources: [{ id: `${scope}-source`, tool: scope === "global" ? "Claude Desktop" : "VS Code", label: scope === "global" ? "Claude Desktop" : "VS Code project", scope, path: scope === "global" ? "/home/ada/claude.json" : "/repo/.vscode/mcp.json" }],
servers: [
{ source: { id: `${scope}-source`, tool: scope === "global" ? "Claude Desktop" : "VS Code", label: scope === "global" ? "Claude Desktop" : "VS Code project", scope, path: "config.json" }, definition: { name: `${scope}-plain`, transport: "stdio", command: "plain-mcp" }, alreadyConfigured: false, hasPlaintextSecrets: false, secretDescriptors: [] },
{ source: { id: `${scope}-source`, tool: scope === "global" ? "Claude Desktop" : "VS Code", label: scope === "global" ? "Claude Desktop" : "VS Code project", scope, path: "config.json" }, definition: { name: `${scope}-secure`, transport: "stdio", command: "secure-mcp", env: { TOKEN: { secretRef: `mcp.${scope}-secure.env.TOKEN`, scope } } }, alreadyConfigured: false, hasPlaintextSecrets: true, secretDescriptors: [{ field: "env", key: "TOKEN", suggestedKey: `mcp.${scope}-secure.env.TOKEN`, scope }] },
{ source: { id: `${scope}-source`, tool: scope === "global" ? "Claude Desktop" : "VS Code", label: scope === "global" ? "Claude Desktop" : "VS Code project", scope, path: "config.json" }, definition: { name: `${scope}-configured`, transport: "stdio", command: "configured-mcp" }, alreadyConfigured: true, hasPlaintextSecrets: false, secretDescriptors: [] },
],
errors: [`${scope} source: skipped malformed config`],
};
}
function mockFetch(statusByName: Record<string, { status: "valid" | "unreachable" | "error"; message: string }> = {}, discoveryByScope?: Partial<Record<McpSettingsScope, unknown>>) {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); const url = String(input);
if (url.startsWith("/api/mcp/discovered")) {
const scope = (new URL(url, "https://fusion.test").searchParams.get("scope") === "global" ? "global" : "project") as McpSettingsScope;
return new Response(JSON.stringify(discoveryByScope?.[scope] ?? { sources: [], servers: [], errors: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url === "/api/secrets" && (!init?.method || init.method === "GET")) { if (url === "/api/secrets" && (!init?.method || init.method === "GET")) {
return new Response(JSON.stringify({ secrets: [secret] }), { status: 200, headers: { "Content-Type": "application/json" } }); return new Response(JSON.stringify({ secrets: [secret] }), { status: 200, headers: { "Content-Type": "application/json" } });
} }
@@ -200,6 +216,64 @@ describe("MCP Settings UI", () => {
expect(screen.getByTestId("mcp-validation-bad")).toHaveTextContent("HTTP 503"); expect(screen.getByTestId("mcp-validation-bad")).toHaveTextContent("HTTP 503");
}); });
it("renders discovered MCP regions in both global and project cards", async () => {
mockFetch({}, { global: discoveredResponse("global"), project: discoveredResponse("project") });
render(<GlobalMcpSection scopeBanner={<div>Global scope</div>} form={{} as Settings} setForm={vi.fn()} addToast={vi.fn()} />);
const globalDiscovery = await screen.findByTestId("mcp-discovery-global");
expect(within(globalDiscovery).getByText("Discovered on this machine")).toBeInTheDocument();
expect(within(globalDiscovery).getByText("global-plain")).toBeInTheDocument();
cleanup();
render(<ProjectMcpSection scopeBanner={<div>Project scope</div>} form={{} as Settings} setForm={vi.fn()} globalSettings={{ mcpServers: { enabled: true, servers: [] } }} addToast={vi.fn()} />);
const projectDiscovery = await screen.findByTestId("mcp-discovery-project");
expect(within(projectDiscovery).getByText("VS Code project")).toBeInTheDocument();
expect(within(projectDiscovery).getByText("project source: skipped malformed config")).toBeInTheDocument();
});
it("adds discovered servers, opens secret binding for sensitive entries, and disables configured entries", async () => {
mockFetch({}, { project: discoveredResponse("project") });
const { getForm } = renderCard({
scope: "project",
form: { mcpServers: { enabled: false, servers: [{ name: "project-configured", transport: "stdio", command: "configured-mcp" }] } } as Settings,
});
const plainRow = await screen.findByTestId("mcp-discovery-row-project-project-source-project-plain");
fireEvent.click(within(plainRow).getByRole("button", { name: /^Add$/i }));
await waitFor(() => expect(screen.getByTestId("mcp-server-row-project-plain")).toBeInTheDocument());
expect(getForm().mcpServers?.enabled).toBe(true);
expect(getForm().mcpServers?.servers?.find((server) => server.name === "project-plain")).toMatchObject({ command: "plain-mcp" });
const secureRow = screen.getByTestId("mcp-discovery-row-project-project-source-project-secure");
fireEvent.click(within(secureRow).getByRole("button", { name: /^Add$/i }));
expect(await screen.findByTestId("mcp-server-editor")).toBeInTheDocument();
expect(screen.getByText(/Bind or create Fusion secret references/i)).toBeInTheDocument();
expect(screen.getByText("Required")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Remove secret reference/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^Save$/i }));
expect(screen.getByText(/Bind or create Fusion secret references/i)).toBeInTheDocument();
expect(getForm().mcpServers?.servers?.find((server) => server.name === "project-secure")).toBeUndefined();
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-project-secure");
const saved = JSON.stringify(getForm().mcpServers);
expect(saved).toContain("secret-token");
expect(saved).not.toContain("typed-secret");
const configuredRow = screen.getByTestId("mcp-discovery-row-project-project-source-project-configured");
expect(configuredRow).toHaveTextContent("Configured");
expect(within(configuredRow).getByRole("button", { name: /^Configured$/i })).toBeDisabled();
});
it("renders empty discovery and mobile controls without empty button shells", async () => {
mockFetch({}, { project: { sources: [], servers: [], errors: [] } });
renderCard({ scope: "project", form: {} as Settings });
const discovery = await screen.findByTestId("mcp-discovery-project");
expect(within(discovery).getByText("No MCP servers found in supported tool configs yet.")).toBeInTheDocument();
for (const button of within(discovery).getAllByRole("button")) {
expect(button.textContent?.trim() || button.getAttribute("aria-label") || "").not.toBe("");
}
});
it("imports Claude JSON through secret creation and exports Fusion JSON", async () => { it("imports Claude JSON through secret creation and exports Fusion JSON", async () => {
const { getForm } = renderCard({ scope: "project", form: {} as Settings }); const { getForm } = renderCard({ scope: "project", form: {} as Settings });
const importPayload = JSON.stringify({ const importPayload = JSON.stringify({

View File

@@ -6,6 +6,8 @@
.mcp-servers-card__header, .mcp-servers-card__header,
.mcp-server-row, .mcp-server-row,
.mcp-discovery__header,
.mcp-discovery-row,
.mcp-import-export, .mcp-import-export,
.mcp-sensitive-row, .mcp-sensitive-row,
.mcp-inline-actions { .mcp-inline-actions {
@@ -14,19 +16,24 @@
} }
.mcp-servers-card__header, .mcp-servers-card__header,
.mcp-server-row { .mcp-server-row,
.mcp-discovery__header,
.mcp-discovery-row {
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
} }
.mcp-servers-card__title, .mcp-servers-card__title,
.mcp-server-row p, .mcp-server-row p,
.mcp-discovery p,
.mcp-discovery h6,
.mcp-import-export h6 { .mcp-import-export h6 {
margin: 0; margin: 0;
} }
.mcp-servers-card__description, .mcp-servers-card__description,
.mcp-server-row p, .mcp-server-row p,
.mcp-discovery p,
.mcp-empty, .mcp-empty,
.mcp-empty-inline, .mcp-empty-inline,
.mcp-effective-count { .mcp-effective-count {
@@ -34,6 +41,8 @@
} }
.mcp-server-list, .mcp-server-list,
.mcp-discovery,
.mcp-discovery__group,
.mcp-editor, .mcp-editor,
.mcp-sensitive-list, .mcp-sensitive-list,
.mcp-import-export__pane, .mcp-import-export__pane,
@@ -44,6 +53,8 @@
} }
.mcp-server-row, .mcp-server-row,
.mcp-discovery,
.mcp-discovery-row,
.mcp-editor, .mcp-editor,
.mcp-import-export__pane { .mcp-import-export__pane {
padding: var(--space-md); padding: var(--space-md);
@@ -52,6 +63,15 @@
background: var(--surface); background: var(--surface);
} }
.mcp-discovery-row {
background: var(--surface-hover);
}
.mcp-discovery__notes,
.mcp-discovery-row__note {
color: var(--color-warning);
}
.mcp-server-row__titleline, .mcp-server-row__titleline,
.mcp-validation-status, .mcp-validation-status,
.mcp-server-row__actions { .mcp-server-row__actions {
@@ -90,6 +110,16 @@
background: color-mix(in srgb, var(--color-warning) 12%, transparent); background: color-mix(in srgb, var(--color-warning) 12%, transparent);
} }
.mcp-state-badge--configured {
color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 12%, transparent);
}
.mcp-state-badge--required {
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
}
.mcp-validation-status { .mcp-validation-status {
color: var(--text-muted); color: var(--text-muted);
} }
@@ -157,6 +187,8 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.mcp-servers-card__header, .mcp-servers-card__header,
.mcp-server-row, .mcp-server-row,
.mcp-discovery__header,
.mcp-discovery-row,
.mcp-import-export, .mcp-import-export,
.mcp-server-row__actions, .mcp-server-row__actions,
.mcp-sensitive-row, .mcp-sensitive-row,

View File

@@ -1,5 +1,5 @@
import "./McpServersCard.css"; import "./McpServersCard.css";
import { Download, Pencil, Play, Plus, Trash2, Upload } from "lucide-react"; import { Download, Pencil, Play, Plus, RefreshCw, Trash2, Upload } from "lucide-react";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -44,6 +44,7 @@ interface SensitiveRowDraft {
scope: SecretScope; scope: SecretScope;
createKey: string; createKey: string;
createValue: string; createValue: string;
required: boolean;
} }
interface EditorDraft { interface EditorDraft {
@@ -63,6 +64,27 @@ interface ValidateState {
message?: string; message?: string;
} }
interface DiscoveredMcpSecretDescriptor {
field: "env" | "headers" | "token";
key: string;
suggestedKey: string;
scope: SecretScope;
}
interface DiscoveredMcpServerEntry {
source: { id: string; tool: string; label: string; scope: McpSettingsScope; path: string };
definition: McpServerDefinition;
alreadyConfigured: boolean;
hasPlaintextSecrets?: boolean;
secretDescriptors: DiscoveredMcpSecretDescriptor[];
}
interface DiscoveredMcpResponse {
sources: Array<{ id: string; tool: string; label: string; scope: McpSettingsScope; path: string }>;
servers: DiscoveredMcpServerEntry[];
errors: string[];
}
export interface McpServersCardProps { export interface McpServersCardProps {
scope: McpSettingsScope; scope: McpSettingsScope;
form: Settings; form: Settings;
@@ -97,6 +119,7 @@ function sensitiveRowsFromMap(values: Record<string, unknown> | undefined): Sens
scope: ref.scope, scope: ref.scope,
createKey: key, createKey: key,
createValue: "", createValue: "",
required: false,
}; };
}); });
} }
@@ -127,6 +150,21 @@ function draftFromServer(server?: McpServerDefinition): EditorDraft {
}; };
} }
function draftFromDiscoveredServer(server: McpServerDefinition, descriptors: DiscoveredMcpSecretDescriptor[]): EditorDraft {
const draft = draftFromServer(server);
/* FNXC:McpConfig 2026-06-26-10:45: Discovered sensitive env/header/token descriptors are required placeholders, not optional editable rows; operators must bind each descriptor to a Fusion secret reference before the server can be saved. */
const rowsFor = (field: "env" | "headers") => descriptors.filter((descriptor) => descriptor.field === field || (field === "headers" && descriptor.field === "token")).map((descriptor) => ({
id: nextRowId(),
key: descriptor.key,
secretRef: "",
scope: descriptor.scope,
createKey: descriptor.suggestedKey,
createValue: "",
required: true,
}));
return server.transport === "stdio" ? { ...draft, env: rowsFor("env") } : { ...draft, headers: rowsFor("headers") };
}
function sensitiveRowsToMap(rows: SensitiveRowDraft[]): Record<string, McpSecretRef> | undefined { function sensitiveRowsToMap(rows: SensitiveRowDraft[]): Record<string, McpSecretRef> | undefined {
const out: Record<string, McpSecretRef> = {}; const out: Record<string, McpSecretRef> = {};
for (const row of rows) { for (const row of rows) {
@@ -201,7 +239,7 @@ function getValidationLabel(status: ValidationStatus): string {
* FNXC:McpConfig 2026-06-26-01:17: * 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. * 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) { export function McpServersCard({ scope, form, setForm, globalSettings, projectId, addToast }: McpServersCardProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const settings = normalizeMcpSettings(form.mcpServers ?? EMPTY_MCP_SETTINGS); const settings = normalizeMcpSettings(form.mcpServers ?? EMPTY_MCP_SETTINGS);
@@ -216,6 +254,9 @@ export function McpServersCard({ scope, form, setForm, globalSettings, addToast
const [importError, setImportError] = useState<string | null>(null); const [importError, setImportError] = useState<string | null>(null);
const [exportText, setExportText] = useState(""); const [exportText, setExportText] = useState("");
const [validateStates, setValidateStates] = useState<Record<string, ValidateState>>({}); const [validateStates, setValidateStates] = useState<Record<string, ValidateState>>({});
const [discovered, setDiscovered] = useState<DiscoveredMcpResponse | null>(null);
const [discoveryLoading, setDiscoveryLoading] = useState(false);
const [discoveryError, setDiscoveryError] = useState<string | null>(null);
const reloadSecrets = useCallback(async () => { const reloadSecrets = useCallback(async () => {
try { try {
@@ -231,6 +272,25 @@ export function McpServersCard({ scope, form, setForm, globalSettings, addToast
void reloadSecrets(); void reloadSecrets();
}, [reloadSecrets]); }, [reloadSecrets]);
const scanDiscoveredServers = useCallback(async () => {
setDiscoveryLoading(true);
setDiscoveryError(null);
try {
const params = new URLSearchParams({ scope });
if (projectId) params.set("projectId", projectId);
setDiscovered(await requestJson<DiscoveredMcpResponse>(`/api/mcp/discovered?${params.toString()}`));
} catch (error) {
setDiscoveryError(error instanceof Error ? error.message : String(error));
setDiscovered({ sources: [], servers: [], errors: [] });
} finally {
setDiscoveryLoading(false);
}
}, [projectId, scope]);
useEffect(() => {
void scanDiscoveredServers();
}, [scanDiscoveredServers]);
const effectiveServers = useMemo( const effectiveServers = useMemo(
() => scope === "project" ? resolveEffectiveMcpServers({ mcpServers: globalMcp }, { mcpServers: form.mcpServers }) : configuredServers.filter((server) => server.enabled !== false), () => scope === "project" ? resolveEffectiveMcpServers({ mcpServers: globalMcp }, { mcpServers: form.mcpServers }) : configuredServers.filter((server) => server.enabled !== false),
[configuredServers, form.mcpServers, globalMcp, scope], [configuredServers, form.mcpServers, globalMcp, scope],
@@ -238,6 +298,16 @@ export function McpServersCard({ scope, form, setForm, globalSettings, addToast
const globalByName = useMemo(() => new Map(globalServers.map((server) => [server.name, server])), [globalServers]); 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 projectByName = useMemo(() => new Map(configuredServers.map((server) => [server.name, server])), [configuredServers]);
const configuredNames = useMemo(() => new Set(configuredServers.map((server) => server.name)), [configuredServers]);
const discoveredGroups = useMemo(() => {
const groups = new Map<string, DiscoveredMcpServerEntry[]>();
for (const entry of discovered?.servers ?? []) {
const existing = groups.get(entry.source.label) ?? [];
existing.push(entry);
groups.set(entry.source.label, existing);
}
return [...groups.entries()];
}, [discovered?.servers]);
const displayRows = useMemo(() => { const displayRows = useMemo(() => {
if (scope === "global") return configuredServers.map((server): { server: McpServerDefinition; state: DisplayState } => ({ server, state: server.enabled === false ? "disabled" : "configured" })); if (scope === "global") return configuredServers.map((server): { server: McpServerDefinition; state: DisplayState } => ({ server, state: server.enabled === false ? "disabled" : "configured" }));
@@ -268,6 +338,11 @@ export function McpServersCard({ scope, form, setForm, globalSettings, addToast
const saveServer = () => { const saveServer = () => {
if (!editor) return; if (!editor) return;
setEditorError(null); setEditorError(null);
const unboundRequired = [...editor.env, ...editor.headers].filter((row) => row.required && (!row.key.trim() || !row.secretRef.trim()));
if (unboundRequired.length > 0) {
setEditorError(t("settings.mcp.discoverySecretsRequired", "Bind or create Fusion secret references before saving this discovered server."));
return;
}
const parsed = validateMcpServerDefinitionDetailed(draftToServer(editor)); const parsed = validateMcpServerDefinitionDetailed(draftToServer(editor));
if (!parsed.value) { if (!parsed.value) {
setEditorError(parsed.errors.map((error) => error.message).join("; ")); setEditorError(parsed.errors.map((error) => error.message).join("; "));
@@ -298,6 +373,19 @@ export function McpServersCard({ scope, form, setForm, globalSettings, addToast
}); });
}; };
const addDiscoveredServer = (entry: DiscoveredMcpServerEntry) => {
if (configuredNames.has(entry.definition.name)) return;
/* FNXC:McpConfig 2026-06-26-10:31: Discovered MCP servers remain inert until this explicit Add action. Servers without sensitive fields can be copied into settings; servers with env/header/token descriptors open the editor with blank secret bindings so plaintext is never persisted. */
if (entry.secretDescriptors.length > 0 || entry.hasPlaintextSecrets) {
setEditor(draftFromDiscoveredServer(entry.definition, entry.secretDescriptors));
setEditorError(t("settings.mcp.discoverySecretsRequired", "Bind or create Fusion secret references before saving this discovered server."));
return;
}
const next = [...configuredServers.filter((server) => server.name !== entry.definition.name), entry.definition];
updateMcpSettings({ ...settings, enabled: true, servers: next });
addToast(t("settings.mcp.discoveredAdded", "Discovered MCP server added"), "success");
};
const disableInheritedServer = (name: string) => { const disableInheritedServer = (name: string) => {
const inherited = globalByName.get(name); const inherited = globalByName.get(name);
const tombstone: McpServerDefinition = inherited?.transport === "sse" || inherited?.transport === "streamable-http" const tombstone: McpServerDefinition = inherited?.transport === "sse" || inherited?.transport === "streamable-http"
@@ -415,7 +503,8 @@ export function McpServersCard({ scope, form, setForm, globalSettings, addToast
{rows.length === 0 ? <p className="mcp-empty-inline">{t("settings.mcp.noSensitiveRows", "No secret references configured.")}</p> : null} {rows.length === 0 ? <p className="mcp-empty-inline">{t("settings.mcp.noSensitiveRows", "No secret references configured.")}</p> : null}
{rows.map((row) => ( {rows.map((row) => (
<div className="mcp-sensitive-row" key={row.id}> <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"} /> <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"} readOnly={row.required} />
{row.required ? <span className="mcp-state-badge mcp-state-badge--required">{t("settings.mcp.requiredSecret", "Required")}</span> : null}
<select className="select" aria-label={t("settings.mcp.secretReference", "Secret reference")} value={`${row.scope}:${row.secretRef}`} onChange={(event) => { <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(":"); 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) }); setEditor((current) => current && { ...current, [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, scope: nextScope as SecretScope, secretRef: nextRef } : candidate) });
@@ -426,10 +515,10 @@ export function McpServersCard({ scope, form, setForm, globalSettings, addToast
<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" 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")} /> <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-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> {row.required ? null : <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> </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> <button type="button" className="btn btn-sm touch-target" onClick={() => setEditor((current) => current && { ...current, [field]: [...current[field], { id: nextRowId(), key: "", secretRef: "", scope, createKey: "", createValue: "", required: false }] })}><Plus aria-hidden="true" /> {t("settings.mcp.addSecretRef", "Add secret reference")}</button>
{secretsError ? <p className="form-error">{secretsError}</p> : null} {secretsError ? <p className="form-error">{secretsError}</p> : null}
</div> </div>
); );
@@ -449,6 +538,37 @@ export function McpServersCard({ scope, form, setForm, globalSettings, addToast
{t("settings.mcp.enabled", "Enable MCP servers for this scope")} {t("settings.mcp.enabled", "Enable MCP servers for this scope")}
</label> </label>
<div className="mcp-discovery card" data-testid={`mcp-discovery-${scope}`}>
<div className="mcp-discovery__header">
<div>
<h6>{t("settings.mcp.discoveryTitle", "Discovered on this machine")}</h6>
<p>{t("settings.mcp.discoveryDescription", "Read-only scan of Claude, Cursor, Windsurf, and VS Code MCP config files. Add a server to enable it in Fusion.")}</p>
</div>
<button type="button" className="btn btn-sm touch-target" onClick={() => void scanDiscoveredServers()} disabled={discoveryLoading}><RefreshCw aria-hidden="true" /> {discoveryLoading ? t("settings.mcp.scanning", "Scanning…") : t("settings.mcp.scanAgain", "Scan again")}</button>
</div>
{discoveryError ? <p className="form-error">{discoveryError}</p> : null}
{(discovered?.errors.length ?? 0) > 0 ? <div className="mcp-discovery__notes" role="note">{discovered?.errors.map((error) => <p key={error}>{error}</p>)}</div> : null}
{!discoveryLoading && discoveredGroups.length === 0 ? <p className="mcp-empty" data-testid={`mcp-discovery-empty-${scope}`}>{t("settings.mcp.discoveryEmpty", "No MCP servers found in supported tool configs yet.")}</p> : null}
{discoveredGroups.map(([label, entries]) => (
<div className="mcp-discovery__group" key={label}>
<div className="mcp-subheading">{label}</div>
{entries.map((entry) => {
const isConfigured = entry.alreadyConfigured || configuredNames.has(entry.definition.name);
return (
<article className="mcp-discovery-row" key={`${entry.source.id}:${entry.definition.name}`} data-testid={`mcp-discovery-row-${scope}-${entry.source.id}-${entry.definition.name}`}>
<div className="mcp-server-row__main">
<div className="mcp-server-row__titleline"><strong>{entry.definition.name}</strong><span className="mcp-transport-badge">{entry.definition.transport}</span>{isConfigured ? <span className="mcp-state-badge mcp-state-badge--configured">{t("settings.mcp.configuredBadge", "Configured")}</span> : null}</div>
<p>{serverSummary(entry.definition)}</p>
{entry.secretDescriptors.length > 0 ? <p className="mcp-discovery-row__note">{t("settings.mcp.discoverySecrets", "Requires Fusion secret references before saving.")}</p> : null}
</div>
<div className="mcp-server-row__actions"><button type="button" className="btn btn-sm touch-target" onClick={() => addDiscoveredServer(entry)} disabled={isConfigured}>{isConfigured ? t("settings.mcp.configured", "Configured") : t("settings.mcp.addDiscovered", "Add")}</button></div>
</article>
);
})}
</div>
))}
</div>
{displayRows.length === 0 ? <p className="mcp-empty" data-testid={`mcp-empty-${scope}`}>{t("settings.mcp.empty", "No MCP servers configured.")}</p> : ( {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"> <div className="mcp-server-list">
{displayRows.map(({ server, state }) => { {displayRows.map(({ server, state }) => {

View File

@@ -0,0 +1,109 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import { createApiRoutes } from "../routes.js";
import { request } from "../test-request.js";
const engineMocks = vi.hoisted(() => ({
discoverMcpServers: vi.fn(),
validateMcpServer: vi.fn(),
resolveMcpServersForRuntime: vi.fn(),
resolveMcpServersForStore: vi.fn(),
}));
vi.mock("@fusion/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/engine")>();
return {
...actual,
createFnAgent: vi.fn(),
getExemptToolNames: vi.fn(() => []),
promptWithFallback: vi.fn(),
reloadExemptTools: vi.fn(),
resolveIntegrationBranch: vi.fn(() => "main"),
discoverMcpServers: engineMocks.discoverMcpServers,
resolveMcpServersForRuntime: engineMocks.resolveMcpServersForRuntime,
resolveMcpServersForStore: engineMocks.resolveMcpServersForStore,
validateMcpServer: engineMocks.validateMcpServer,
};
});
const projectSource = { id: "vscode-project", tool: "VS Code", label: "VS Code project", scope: "project" as const, path: "/repo/.vscode/mcp.json" };
const globalSource = { id: "claude-desktop-global", tool: "Claude Desktop", label: "Claude Desktop", scope: "global" as const, path: "/home/ada/claude.json" };
function createMockStore(settingsByScope = {
global: { mcpServers: { enabled: true, servers: [{ name: "global-configured", transport: "stdio" as const, command: "node" }] } },
project: { mcpServers: { enabled: true, servers: [{ name: "project-configured", transport: "stdio" as const, command: "node" }] } },
}) {
return {
getRootDir: () => "/repo",
getSettingsByScopeFast: async () => settingsByScope,
};
}
function createApp(store = createMockStore()) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store as never));
return app;
}
describe("GET /api/mcp/discovered", () => {
beforeEach(() => {
vi.clearAllMocks();
engineMocks.discoverMcpServers.mockResolvedValue({ sources: [], servers: [], errors: [] });
});
it("returns project discoveries with configured markers and stripped plaintext descriptors", async () => {
engineMocks.discoverMcpServers.mockResolvedValue({
sources: [projectSource],
servers: [
{
source: projectSource,
definition: { name: "project-configured", transport: "stdio", command: "node" },
secretsToCreate: [],
},
{
source: projectSource,
definition: { name: "secure", transport: "stdio", command: "secure-mcp", env: { TOKEN: { secretRef: "mcp.secure.env.TOKEN", scope: "project" } } },
secretsToCreate: [{ serverName: "secure", field: "env", key: "TOKEN", scope: "project", suggestedKey: "mcp.secure.env.TOKEN", plaintextValue: "do-not-return" }],
},
],
errors: ["VS Code project: skipped malformed entry"],
});
const response = await request(createApp(), "GET", "/api/mcp/discovered?scope=project");
expect(response.status).toBe(200);
expect(engineMocks.discoverMcpServers).toHaveBeenCalledWith({ scope: "project", projectRootDir: "/repo" });
expect(response.body.servers[0]).toMatchObject({ alreadyConfigured: true, hasPlaintextSecrets: false });
expect(response.body.servers[1]).toMatchObject({
alreadyConfigured: false,
hasPlaintextSecrets: true,
secretDescriptors: [{ field: "env", key: "TOKEN", scope: "project", suggestedKey: "mcp.secure.env.TOKEN" }],
});
expect(JSON.stringify(response.body)).not.toContain("do-not-return");
expect(response.body.errors).toEqual(["VS Code project: skipped malformed entry"]);
});
it("uses global configured settings when scope=global", async () => {
engineMocks.discoverMcpServers.mockResolvedValue({
sources: [globalSource],
servers: [{ source: globalSource, definition: { name: "global-configured", transport: "stdio", command: "node" }, secretsToCreate: [] }],
errors: [],
});
const response = await request(createApp(), "GET", "/api/mcp/discovered?scope=global");
expect(response.status).toBe(200);
expect(engineMocks.discoverMcpServers).toHaveBeenCalledWith({ scope: "global", projectRootDir: "/repo" });
expect(response.body.servers[0].alreadyConfigured).toBe(true);
});
it("degrades to an empty result when no supported files exist", async () => {
const response = await request(createApp(), "GET", "/api/mcp/discovered");
expect(response.status).toBe(200);
expect(response.body).toEqual({ sources: [], servers: [], errors: [] });
});
});

View File

@@ -363,6 +363,7 @@ import {
promptWithFallback as enginePromptWithFallback, promptWithFallback as enginePromptWithFallback,
reloadExemptTools as engineReloadExemptTools, reloadExemptTools as engineReloadExemptTools,
resolveIntegrationBranch, resolveIntegrationBranch,
discoverMcpServers,
resolveMcpServersForRuntime, resolveMcpServersForRuntime,
resolveMcpServersForStore, resolveMcpServersForStore,
validateMcpServer, validateMcpServer,
@@ -410,6 +411,22 @@ function parseMcpValidationBody(body: unknown): { name?: string; definition?: Mc
return { name, definition, timeoutMs: parseMcpValidationTimeout(input.timeoutMs) }; return { name, definition, timeoutMs: parseMcpValidationTimeout(input.timeoutMs) };
} }
function parseMcpDiscoveryScope(value: unknown): "global" | "project" {
if (value === undefined) return "project";
const raw = Array.isArray(value) ? value[0] : value;
if (raw === "global" || raw === "project") return raw;
throw badRequest("scope must be either global or project");
}
function stripMcpSecretDescriptor(secret: { field: "env" | "headers" | "token"; key: string; suggestedKey: string; scope: "global" | "project" }) {
return {
field: secret.field,
key: secret.key,
suggestedKey: secret.suggestedKey,
scope: secret.scope,
};
}
async function resolveMcpServerForValidation( async function resolveMcpServerForValidation(
scopedStore: TaskStore, scopedStore: TaskStore,
request: { name?: string; definition?: McpServerDefinition }, request: { name?: string; definition?: McpServerDefinition },
@@ -1300,6 +1317,35 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
}); });
router.get("/mcp/discovered", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const scope = parseMcpDiscoveryScope(req.query.scope);
const [discovered, settingsByScope] = await Promise.all([
discoverMcpServers({ scope, projectRootDir: scopedStore.getRootDir() }),
scopedStore.getSettingsByScopeFast(),
]);
const configured = new Set((scope === "global" ? settingsByScope.global.mcpServers?.servers : settingsByScope.project.mcpServers?.servers)?.map((server) => server.name) ?? []);
/*
* FNXC:McpConfig 2026-06-26-10:31:
* The discovery API is read-only: it reports inert third-party MCP definitions for explicit user opt-in and strips plaintextValue before crossing the wire. The dashboard can create Fusion-managed secret references from these descriptors, but API clients never receive raw env/header/token material.
*/
res.json({
sources: discovered.sources,
servers: discovered.servers.map((server) => ({
source: server.source,
definition: server.definition,
alreadyConfigured: configured.has(server.definition.name),
hasPlaintextSecrets: server.secretsToCreate.length > 0,
secretDescriptors: server.secretsToCreate.map(stripMcpSecretDescriptor),
})),
errors: discovered.errors,
});
} catch (error) {
rethrowAsApiError(error, "Failed to discover MCP servers");
}
});
router.post("/mcp/validate", async (req, res) => { router.post("/mcp/validate", async (req, res) => {
try { try {
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);

View File

@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { discoverMcpServers } from "../mcp-discovery-service.js";
describe("discoverMcpServers", () => {
it("reads only requested-scope sources through the injectable reader", async () => {
const readFile = vi.fn(async (path: string) => {
if (path.endsWith(".cursor/mcp.json")) {
return JSON.stringify({ mcpServers: { cursor: { command: "cursor-mcp" } } });
}
return undefined;
});
const result = await discoverMcpServers({ scope: "global", homeDir: "/home/ada", projectRootDir: "/repo", readFile });
expect(result.sources.every((source) => source.scope === "global")).toBe(true);
expect(readFile).toHaveBeenCalledTimes(result.sources.length);
expect(readFile.mock.calls.every(([path]) => !String(path).startsWith("/repo/"))).toBe(true);
expect(result.errors).toEqual([]);
expect(result.servers).toHaveLength(1);
expect(result.servers[0]?.source.label).toBe("Cursor global");
expect(result.servers[0]?.definition).toMatchObject({ name: "cursor", transport: "stdio", command: "cursor-mcp" });
});
it("returns project servers, treats missing files as non-errors, and never exposes plaintext in definitions", async () => {
const readFile = vi.fn(async (path: string) => {
if (path.endsWith(".vscode/mcp.json")) {
return JSON.stringify({ servers: { secure: { transport: "sse", url: "https://secure.example.test/sse", headers: { Authorization: "Bearer plaintext" } } } });
}
return undefined;
});
const result = await discoverMcpServers({ scope: "project", homeDir: "/home/ada", projectRootDir: "/repo", readFile });
expect(result.sources.map((source) => source.id)).toEqual(["cursor-project", "vscode-project"]);
expect(result.errors).toEqual([]);
expect(result.servers).toHaveLength(1);
expect(result.servers[0]?.source.label).toBe("VS Code project");
expect(result.servers[0]?.secretsToCreate).toHaveLength(1);
expect(JSON.stringify(result.servers[0]?.definition)).not.toContain("Bearer plaintext");
expect(result.servers[0]?.definition).toMatchObject({
name: "secure",
headers: { Authorization: { secretRef: "mcp.secure.headers.Authorization", scope: "project" } },
});
});
it("captures parse and reader failures without spawning or connecting to servers", async () => {
const readFile = vi.fn(async (path: string) => {
if (path.endsWith(".cursor/mcp.json")) throw new Error("permission denied");
if (path.endsWith(".vscode/mcp.json")) return "{";
return undefined;
});
const result = await discoverMcpServers({ scope: "project", homeDir: "/home/ada", projectRootDir: "/repo", readFile });
expect(result.servers).toEqual([]);
expect(result.errors.join("\n")).toContain("Cursor project: permission denied");
expect(result.errors.join("\n")).toContain("VS Code project");
expect(readFile).toHaveBeenCalledTimes(2);
});
});

View File

@@ -299,6 +299,7 @@ export {
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js"; export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js"; export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js";
export { resolveMcpServersForRuntime, resolveMcpServersForStore, type ResolvedMcpServersForRuntime } from "./mcp-resolution.js"; export { resolveMcpServersForRuntime, resolveMcpServersForStore, type ResolvedMcpServersForRuntime } from "./mcp-resolution.js";
export { discoverMcpServers, type DiscoverMcpServersOptions, type DiscoverMcpServersResult } from "./mcp-discovery-service.js";
export { runtimeSupportsMcp, logMcpForwardingSkipped } from "./mcp-runtime-support.js"; export { runtimeSupportsMcp, logMcpForwardingSkipped } from "./mcp-runtime-support.js";
export { validateMcpServer, type McpValidationResult, type ValidateMcpServerOptions } from "./mcp-validation-service.js"; export { validateMcpServer, type McpValidationResult, type ValidateMcpServerOptions } from "./mcp-validation-service.js";
export { export {

View File

@@ -0,0 +1,65 @@
import { readFile as fsReadFile } from "node:fs/promises";
import { homedir as osHomedir, platform as osPlatform } from "node:os";
import {
getMcpDiscoverySources,
parseDiscoveredMcpServersFromFile,
type DiscoveredMcpServer,
type McpDiscoverySource,
} from "@fusion/core";
export interface DiscoverMcpServersOptions {
scope: "global" | "project";
homeDir?: string;
projectRootDir?: string;
readFile?: (path: string) => Promise<string | undefined>;
}
export interface DiscoverMcpServersResult {
sources: McpDiscoverySource[];
servers: DiscoveredMcpServer[];
errors: string[];
}
async function readOptionalTextFile(filePath: string): Promise<string | undefined> {
try {
return await fsReadFile(filePath, "utf8");
} catch (error) {
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") {
return undefined;
}
throw error;
}
}
/**
* FNXC:McpConfig 2026-06-26-10:31:
* Host MCP discovery only reads known config files and delegates parsing to @fusion/core. It never spawns, connects to, or validates discovered servers, and it never resolves Fusion secret references; inline third-party secrets stay confined to import descriptors until the dashboard opt-in flow converts them to managed secret references.
*/
export async function discoverMcpServers(opts: DiscoverMcpServersOptions): Promise<DiscoverMcpServersResult> {
const homeDir = opts.homeDir ?? process.env.HOME ?? process.env.USERPROFILE ?? osHomedir();
const readFile = opts.readFile ?? readOptionalTextFile;
const sources = getMcpDiscoverySources({
homeDir,
platform: osPlatform(),
projectRootDir: opts.projectRootDir,
}).filter((source) => source.scope === opts.scope);
const servers: DiscoveredMcpServer[] = [];
const errors: string[] = [];
for (const source of sources) {
let contents: string | undefined;
try {
contents = await readFile(source.path);
} catch (error) {
errors.push(`${source.label}: ${error instanceof Error ? error.message : String(error)}`);
continue;
}
if (contents === undefined) continue;
const parsed = parseDiscoveredMcpServersFromFile({ source, contents });
servers.push(...parsed.servers);
errors.push(...parsed.errors.map((message) => `${source.label}: ${message}`));
}
return { sources, servers, errors };
}