From f685518a9378f9fabf55f580382f42a7538a71e4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 11:42:24 -0700 Subject: [PATCH] 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 --- .changeset/fn-7064-mcp-auto-discovery.md | 7 + docs/dashboard-guide.md | 3 +- docs/mcp.md | 53 ++++++- docs/settings-reference.md | 4 +- .../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 +++++++++++++++++- .../__tests__/mcp-discovered-route.test.ts | 109 +++++++++++++++ packages/dashboard/src/routes.ts | 46 +++++++ .../__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(-) create mode 100644 .changeset/fn-7064-mcp-auto-discovery.md create mode 100644 packages/core/src/__tests__/mcp-discovery.test.ts create mode 100644 packages/core/src/mcp-discovery.ts create mode 100644 packages/dashboard/src/__tests__/mcp-discovered-route.test.ts create mode 100644 packages/engine/src/__tests__/mcp-discovery-service.test.ts create mode 100644 packages/engine/src/mcp-discovery-service.ts diff --git a/.changeset/fn-7064-mcp-auto-discovery.md b/.changeset/fn-7064-mcp-auto-discovery.md new file mode 100644 index 0000000000..f5e8f7942e --- /dev/null +++ b/.changeset/fn-7064-mcp-auto-discovery.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 4ba773017a..0da413ea01 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. - 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 **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 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 diff --git a/docs/mcp.md b/docs/mcp.md index ba41f23108..19f4a6bb41 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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**. 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. -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. -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. -10. Save the Settings modal. Expected outcome: the selected global or project `mcpServers` settings are persisted and used by subsequent MCP-capable AI sessions. +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. 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. 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 | `/.cursor/mcp.json` | `\\.cursor\\mcp.json` | +| VS Code | Project | `/.vscode/mcp.json` | `\\.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 diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 6664fa7209..1de2986acd 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. -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) diff --git a/packages/core/src/__tests__/mcp-discovery.test.ts b/packages/core/src/__tests__/mcp-discovery.test.ts new file mode 100644 index 0000000000..9ac611a69b --- /dev/null +++ b/packages/core/src/__tests__/mcp-discovery.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3e336fafab..01f3a7ebac 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,6 +56,13 @@ export type { McpSecretImportDescriptor, McpServersImportResult, } from "./mcp-config.js"; +export { + getMcpDiscoverySources, + parseDiscoveredMcpServersFromFile, + type McpDiscoverySource, + type McpDiscoverySourcesOptions, + type DiscoveredMcpServer, +} from "./mcp-discovery.js"; export { resolveAgentMemoryInclusionMode, type AgentMemoryInclusionModeSource, diff --git a/packages/core/src/mcp-discovery.ts b/packages/core/src/mcp-discovery.ts new file mode 100644 index 0000000000..ee0516d43a --- /dev/null +++ b/packages/core/src/mcp-discovery.ts @@ -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; + 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)], + }; + } +} diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx index f6ce2b04bd..a3b641b0a0 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx @@ -26,9 +26,25 @@ const secret = { lastReadAt: null, }; -function mockFetch(statusByName: Record = {}) { +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 = {}, discoveryByScope?: Partial>) { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { 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")) { 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"); }); + it("renders discovered MCP regions in both global and project cards", async () => { + mockFetch({}, { global: discoveredResponse("global"), project: discoveredResponse("project") }); + render(Global scope} 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(Project scope} 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 () => { const { getForm } = renderCard({ scope: "project", form: {} as Settings }); const importPayload = JSON.stringify({ diff --git a/packages/dashboard/app/components/settings/sections/McpServersCard.css b/packages/dashboard/app/components/settings/sections/McpServersCard.css index 12c67f2b22..615b97064e 100644 --- a/packages/dashboard/app/components/settings/sections/McpServersCard.css +++ b/packages/dashboard/app/components/settings/sections/McpServersCard.css @@ -6,6 +6,8 @@ .mcp-servers-card__header, .mcp-server-row, +.mcp-discovery__header, +.mcp-discovery-row, .mcp-import-export, .mcp-sensitive-row, .mcp-inline-actions { @@ -14,19 +16,24 @@ } .mcp-servers-card__header, -.mcp-server-row { +.mcp-server-row, +.mcp-discovery__header, +.mcp-discovery-row { align-items: flex-start; justify-content: space-between; } .mcp-servers-card__title, .mcp-server-row p, +.mcp-discovery p, +.mcp-discovery h6, .mcp-import-export h6 { margin: 0; } .mcp-servers-card__description, .mcp-server-row p, +.mcp-discovery p, .mcp-empty, .mcp-empty-inline, .mcp-effective-count { @@ -34,6 +41,8 @@ } .mcp-server-list, +.mcp-discovery, +.mcp-discovery__group, .mcp-editor, .mcp-sensitive-list, .mcp-import-export__pane, @@ -44,6 +53,8 @@ } .mcp-server-row, +.mcp-discovery, +.mcp-discovery-row, .mcp-editor, .mcp-import-export__pane { padding: var(--space-md); @@ -52,6 +63,15 @@ 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-validation-status, .mcp-server-row__actions { @@ -90,6 +110,16 @@ 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 { color: var(--text-muted); } @@ -157,6 +187,8 @@ @media (max-width: 768px) { .mcp-servers-card__header, .mcp-server-row, + .mcp-discovery__header, + .mcp-discovery-row, .mcp-import-export, .mcp-server-row__actions, .mcp-sensitive-row, diff --git a/packages/dashboard/app/components/settings/sections/McpServersCard.tsx b/packages/dashboard/app/components/settings/sections/McpServersCard.tsx index 392d42b1e7..c704cfdbec 100644 --- a/packages/dashboard/app/components/settings/sections/McpServersCard.tsx +++ b/packages/dashboard/app/components/settings/sections/McpServersCard.tsx @@ -1,5 +1,5 @@ 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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -44,6 +44,7 @@ interface SensitiveRowDraft { scope: SecretScope; createKey: string; createValue: string; + required: boolean; } interface EditorDraft { @@ -63,6 +64,27 @@ interface ValidateState { 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 { scope: McpSettingsScope; form: Settings; @@ -97,6 +119,7 @@ function sensitiveRowsFromMap(values: Record | undefined): Sens scope: ref.scope, createKey: key, 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 | undefined { const out: Record = {}; for (const row of rows) { @@ -201,7 +239,7 @@ function getValidationLabel(status: ValidationStatus): string { * 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) { +export function McpServersCard({ scope, form, setForm, globalSettings, projectId, addToast }: McpServersCardProps) { const { t } = useTranslation("app"); const fileInputRef = useRef(null); const settings = normalizeMcpSettings(form.mcpServers ?? EMPTY_MCP_SETTINGS); @@ -216,6 +254,9 @@ export function McpServersCard({ scope, form, setForm, globalSettings, addToast const [importError, setImportError] = useState(null); const [exportText, setExportText] = useState(""); const [validateStates, setValidateStates] = useState>({}); + const [discovered, setDiscovered] = useState(null); + const [discoveryLoading, setDiscoveryLoading] = useState(false); + const [discoveryError, setDiscoveryError] = useState(null); const reloadSecrets = useCallback(async () => { try { @@ -231,6 +272,25 @@ export function McpServersCard({ scope, form, setForm, globalSettings, addToast void 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(`/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( () => scope === "project" ? resolveEffectiveMcpServers({ mcpServers: globalMcp }, { mcpServers: form.mcpServers }) : configuredServers.filter((server) => server.enabled !== false), [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 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(); + 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(() => { 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 = () => { if (!editor) return; 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)); if (!parsed.value) { 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 inherited = globalByName.get(name); 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 ?

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

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

{secretsError}

: null} ); @@ -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.discoveryTitle", "Discovered on this machine")}
+

{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.")}

+
+ +
+ {discoveryError ?

{discoveryError}

: null} + {(discovered?.errors.length ?? 0) > 0 ?
{discovered?.errors.map((error) =>

{error}

)}
: null} + {!discoveryLoading && discoveredGroups.length === 0 ?

{t("settings.mcp.discoveryEmpty", "No MCP servers found in supported tool configs yet.")}

: null} + {discoveredGroups.map(([label, entries]) => ( +
+
{label}
+ {entries.map((entry) => { + const isConfigured = entry.alreadyConfigured || configuredNames.has(entry.definition.name); + return ( +
+
+
{entry.definition.name}{entry.definition.transport}{isConfigured ? {t("settings.mcp.configuredBadge", "Configured")} : null}
+

{serverSummary(entry.definition)}

+ {entry.secretDescriptors.length > 0 ?

{t("settings.mcp.discoverySecrets", "Requires Fusion secret references before saving.")}

: null} +
+
+
+ ); + })} +
+ ))} +
+ {displayRows.length === 0 ?

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

: (
{displayRows.map(({ server, state }) => { diff --git a/packages/dashboard/src/__tests__/mcp-discovered-route.test.ts b/packages/dashboard/src/__tests__/mcp-discovered-route.test.ts new file mode 100644 index 0000000000..60bfb44d51 --- /dev/null +++ b/packages/dashboard/src/__tests__/mcp-discovered-route.test.ts @@ -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(); + 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: [] }); + }); +}); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 8e26fa3730..7a78c3c507 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -363,6 +363,7 @@ import { promptWithFallback as enginePromptWithFallback, reloadExemptTools as engineReloadExemptTools, resolveIntegrationBranch, + discoverMcpServers, resolveMcpServersForRuntime, resolveMcpServersForStore, validateMcpServer, @@ -410,6 +411,22 @@ function parseMcpValidationBody(body: unknown): { name?: string; definition?: Mc 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( scopedStore: TaskStore, 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) => { try { const { store: scopedStore } = await getProjectContext(req); diff --git a/packages/engine/src/__tests__/mcp-discovery-service.test.ts b/packages/engine/src/__tests__/mcp-discovery-service.test.ts new file mode 100644 index 0000000000..047ff74155 --- /dev/null +++ b/packages/engine/src/__tests__/mcp-discovery-service.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e6d62f711c..14d25308b5 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -299,6 +299,7 @@ export { 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 { 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 { validateMcpServer, type McpValidationResult, type ValidateMcpServerOptions } from "./mcp-validation-service.js"; export { diff --git a/packages/engine/src/mcp-discovery-service.ts b/packages/engine/src/mcp-discovery-service.ts new file mode 100644 index 0000000000..f9a7ae62cb --- /dev/null +++ b/packages/engine/src/mcp-discovery-service.ts @@ -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; +} + +export interface DiscoverMcpServersResult { + sources: McpDiscoverySource[]; + servers: DiscoveredMcpServer[]; + errors: string[]; +} + +async function readOptionalTextFile(filePath: string): Promise { + 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 { + 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 }; +}