From 429143ecf0b26d3b3fd79a6a42d2badeca264c87 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 22:05:30 -0700 Subject: [PATCH] FN-7024: add MCP server management CLI Add CLI support for managing MCP server configuration without exposing secret values. - Add fn mcp list/add/edit/remove/enable/disable/import/export/validate subcommands. - Store MCP env and header values as Fusion secret references, including Claude Desktop import conversion. - Document MCP CLI usage and add command coverage for scoped configuration, import, export, and validation. - Add a minor changeset for the published CLI feature. Files changed: .changeset/fn-7024-mcp-cli.md | 7 + docs/cli-reference.md | 49 +++ packages/cli/src/bin.ts | 124 +++++++ packages/cli/src/commands/__tests__/mcp.test.ts | 182 ++++++++++ packages/cli/src/commands/mcp.ts | 455 ++++++++++++++++++++++++ 5 files changed, 817 insertions(+) Fusion-Task-Id: FN-7024 Fusion-Task-Lineage: e3d98bfa-b883-4b9e-86d5-f0416808d6f2 --- .changeset/fn-7024-mcp-cli.md | 7 + docs/cli-reference.md | 49 ++ packages/cli/src/bin.ts | 124 +++++ .../cli/src/commands/__tests__/mcp.test.ts | 182 +++++++ packages/cli/src/commands/mcp.ts | 455 ++++++++++++++++++ 5 files changed, 817 insertions(+) create mode 100644 .changeset/fn-7024-mcp-cli.md create mode 100644 packages/cli/src/commands/__tests__/mcp.test.ts create mode 100644 packages/cli/src/commands/mcp.ts diff --git a/.changeset/fn-7024-mcp-cli.md b/.changeset/fn-7024-mcp-cli.md new file mode 100644 index 0000000000..a2b41af042 --- /dev/null +++ b/.changeset/fn-7024-mcp-cli.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add `fn mcp` CLI to manage MCP servers, import Claude Desktop config, and export Fusion MCP JSON. +category: feature +dev: New `packages/cli/src/commands/mcp.ts`; reuses @fusion/core resolveEffectiveMcpServers, validation, and import/export; sensitive fields stored as secret references via SecretsStore, never plaintext. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0a84e66cd4..2ff7d5d1ae 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -986,6 +986,55 @@ fn settings import [--scope global|project|both] [--merge] [--yes] --- +## `fn mcp` + +Manage Fusion MCP server definitions for stdio, SSE, and streamable HTTP transports. + +```bash +fn mcp list [--project ] [--json] +fn mcp add --scope global|project --transport stdio --command [--arg ...] +fn mcp add --scope global|project --transport sse|http --url +fn mcp edit [--scope global|project] [--transport stdio|sse|http] [--command |--url ] +fn mcp remove [--scope global|project] +fn mcp enable [--scope global|project] +fn mcp disable [--scope global|project] +fn mcp import [--scope global|project] [--yes] +fn mcp export [--scope global|project|effective] [--output ] [--json] +fn mcp validate [--scope global|project|effective] [--json] +``` + +Scope semantics: +- `--scope global` writes shared MCP declarations in global settings. +- `--scope project` writes the selected project's declarations. Project servers override same-named global servers; a project server with `enabled:false` disables the inherited global server without deleting it. +- `list`, `export`, and `validate` can show the `effective` resolution, which is global plus project overrides after disabled entries are removed. + +Secret handling: +- Fusion never persists raw MCP env/header/token-like values in settings. Sensitive fields are stored only as Fusion secret references (`{ secretRef, scope }`). +- Use `--env KEY=SECRET_REF` or `--header NAME=SECRET_REF` to attach existing secrets. Add `--secret-scope global|project` when the referenced secret lives outside the command's default scope. +- Use `--create-secret-env KEY=VALUE` or `--create-secret-header NAME=VALUE` when you want the CLI to create a Fusion secret and persist only the resulting reference. +- `--env-raw` and `--header-raw` are rejected by design; they exist only to produce an explicit no-plaintext error for scripts that try to pass inline sensitive values. +- `fn mcp import` accepts Claude Desktop-style `{ "mcpServers": { ... } }` JSON, creates Fusion secrets for imported plaintext env/header values, and writes only secret references. +- `list`, `export`, and `validate` print descriptors/summaries, never decrypted secret values. + +| Option | Description | +|---|---| +| `--scope` | `global` or `project` for writes; `global`, `project`, or `effective` for read/export/validate commands. | +| `--transport` | Server transport for add/edit: `stdio`, `sse`, `http`, or `streamable-http`. | +| `--command` | Command path/name for `stdio` servers. | +| `--arg ` / `--args ` | Arguments for `stdio` servers. Repeat `--arg`; `--args` accepts a space-separated string. | +| `--url` | URL for `sse`, `http`, or `streamable-http` servers. | +| `--env KEY=SECRET_REF` | Attach an env var to an existing secret reference. | +| `--header NAME=SECRET_REF` | Attach an HTTP/SSE header to an existing secret reference. | +| `--secret-scope` | Scope used when resolving `--env`, `--header`, or `--secret-ref` (default: command scope). | +| `--secret-ref` | Existing secret reference for token-like single-secret flows. | +| `--create-secret-env KEY=VALUE` | Create a Fusion secret for an env var and store only the reference. | +| `--create-secret-header NAME=VALUE` | Create a Fusion secret for a header and store only the reference. | +| `--output ` | Write `fn mcp export` output to a file instead of stdout. | +| `--json` | Print machine-readable output for list/export/validate where supported. | +| `--yes` | Skip confirmation during import. | + +--- + ## `fn git` Project git operations. diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 513ddee97e..341819b615 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -126,6 +126,7 @@ async function loadCommandHandlers() { const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js"); const { runSettingsExport } = await import("./commands/settings-export.js"); const { runSettingsImport } = await import("./commands/settings-import.js"); + const { runMcpList, runMcpAdd, runMcpEdit, runMcpRemove, runMcpEnable, runMcpDisable, runMcpImport, runMcpExport, runMcpValidate } = await import("./commands/mcp.js"); const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js"); const { runBranchGroupList, runBranchGroupShow, runBranchGroupPromote, runBranchGroupAbandon } = await import("./commands/branch-group.js"); const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js"); @@ -193,6 +194,15 @@ async function loadCommandHandlers() { runSettingsSet, runSettingsExport, runSettingsImport, + runMcpList, + runMcpAdd, + runMcpEdit, + runMcpRemove, + runMcpEnable, + runMcpDisable, + runMcpImport, + runMcpExport, + runMcpValidate, runGitStatus, runGitFetch, runGitPull, @@ -387,6 +397,17 @@ PR: fn settings set worktrunk.onFailure fn settings export [opts] Export settings to a JSON file fn settings import [opts] Import settings from a JSON file + fn mcp list [--project ] [--json] List MCP servers by scope and effective resolution + fn mcp add --scope --transport [opts] + Add an MCP server using secret references for env/header values + fn mcp edit|remove|enable|disable [--scope ] + Update, remove, or toggle a scoped MCP server + fn mcp import [--scope ] [--yes] + Import Claude Desktop mcpServers JSON and create Fusion secrets + fn mcp export [--scope ] [--output ] + Export Fusion MCP JSON with secret references only + fn mcp validate [--scope ] [--json] + Validate MCP definitions without revealing secrets fn git status Show current branch, commit, dirty state, ahead/behind fn git push Push current branch @@ -679,6 +700,15 @@ async function main() { runSettingsSet, runSettingsExport, runSettingsImport, + runMcpList, + runMcpAdd, + runMcpEdit, + runMcpRemove, + runMcpEnable, + runMcpDisable, + runMcpImport, + runMcpExport, + runMcpValidate, runGitStatus, runGitFetch, runGitPull, @@ -1632,6 +1662,100 @@ async function main() { break; } + case "mcp": { + const subcommand = args[1] ?? "list"; + const scope = getFlagValue(args, "--scope") as "global" | "project" | "effective" | undefined; + const secretScope = getFlagValue(args, "--secret-scope") as "global" | "project" | undefined; + const commonSensitive = { + env: getRepeatedFlagValues(args, "--env"), + headers: getRepeatedFlagValues(args, "--header"), + envRaw: getRepeatedFlagValues(args, "--env-raw"), + headersRaw: getRepeatedFlagValues(args, "--header-raw"), + createEnv: getRepeatedFlagValues(args, "--create-secret-env"), + createHeaders: getRepeatedFlagValues(args, "--create-secret-header"), + secretRef: getFlagValue(args, "--secret-ref"), + secretScope, + }; + switch (subcommand) { + case "list": + case "ls": + await runMcpList({ projectName, json: args.includes("--json") }); + break; + case "add": { + const name = args[2]; + if (!name) { console.error("Usage: fn mcp add --scope global|project --transport stdio|sse|http [--command |--url ]"); process.exit(1); } + const argValues = getRepeatedFlagValues(args, "--arg"); + const argsValue = argValues.length > 0 ? argValues : getFlagValue(args, "--args"); + await runMcpAdd(name, { + projectName, + scope: scope === "effective" ? undefined : scope, + transport: getFlagValue(args, "--transport") as "stdio" | "sse" | "http" | "streamable-http" | undefined, + command: getFlagValue(args, "--command"), + args: argsValue, + url: getFlagValue(args, "--url"), + enabled: args.includes("--disabled") ? false : args.includes("--enabled") ? true : undefined, + ...commonSensitive, + }); + break; + } + case "edit": { + const name = args[2]; + if (!name) { console.error("Usage: fn mcp edit [--scope global|project] [opts]"); process.exit(1); } + const argValues = getRepeatedFlagValues(args, "--arg"); + const argsValue = argValues.length > 0 ? argValues : getFlagValue(args, "--args"); + await runMcpEdit(name, { + projectName, + scope: scope === "effective" ? undefined : scope, + transport: getFlagValue(args, "--transport") as "stdio" | "sse" | "http" | "streamable-http" | undefined, + command: getFlagValue(args, "--command"), + args: argsValue, + url: getFlagValue(args, "--url"), + enabled: args.includes("--disabled") ? false : args.includes("--enabled") ? true : undefined, + ...commonSensitive, + }); + break; + } + case "remove": + case "rm": { + const name = args[2]; + if (!name) { console.error("Usage: fn mcp remove [--scope global|project]"); process.exit(1); } + await runMcpRemove(name, { projectName, scope: scope === "effective" ? undefined : scope }); + break; + } + case "enable": { + const name = args[2]; + if (!name) { console.error("Usage: fn mcp enable [--scope global|project]"); process.exit(1); } + await runMcpEnable(name, { projectName, scope: scope === "effective" ? undefined : scope }); + break; + } + case "disable": { + const name = args[2]; + if (!name) { console.error("Usage: fn mcp disable [--scope global|project]"); process.exit(1); } + await runMcpDisable(name, { projectName, scope: scope === "effective" ? undefined : scope }); + break; + } + case "import": { + const file = args[2]; + if (!file) { console.error("Usage: fn mcp import [--scope global|project] [--yes]"); process.exit(1); } + await runMcpImport(file, { projectName, scope: scope === "effective" ? undefined : scope, yes: args.includes("--yes") }); + break; + } + case "export": + await runMcpExport({ projectName, scope, output: getFlagValue(args, "--output"), json: args.includes("--json") }); + break; + case "validate": + case "test": + await runMcpValidate({ projectName, scope, json: args.includes("--json") }); + break; + default: + console.error(`Unknown subcommand: mcp ${subcommand || ""}`); + console.log("Try: fn mcp list | add | edit | remove | enable | disable | import | export | validate"); + process.exit(1); + } + break; + } + + case "git": { const subcommand = args[1]; switch (subcommand) { diff --git a/packages/cli/src/commands/__tests__/mcp.test.ts b/packages/cli/src/commands/__tests__/mcp.test.ts new file mode 100644 index 0000000000..c68809718b --- /dev/null +++ b/packages/cli/src/commands/__tests__/mcp.test.ts @@ -0,0 +1,182 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function makeConstructibleMock unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + if (impl) mock.mockImplementation(impl); + return mock; +} + +type SecretRecord = { id: string; key: string; scope: "global" | "project"; plaintextValue: string }; + +const state = vi.hoisted(() => ({ + globalSettings: { mcpServers: { enabled: false, servers: [] as any[] } }, + projectSettings: { mcpServers: { enabled: false, servers: [] as any[] } }, + secrets: [] as SecretRecord[], + nextSecretId: 1, +})); + +vi.mock("@fusion/core", async (importActual) => { + const actual = await importActual(); + const secretsStore = { + getSecretMetadata: vi.fn((id: string, scope: "global" | "project") => state.secrets.find((secret) => secret.id === id && secret.scope === scope) ?? null), + listSecrets: vi.fn((scope?: "global" | "project") => state.secrets.filter((secret) => !scope || secret.scope === scope)), + createSecret: vi.fn(async (input: { scope: "global" | "project"; key: string; plaintextValue: string }) => { + const created = { id: `sec-${state.nextSecretId++}`, key: input.key, scope: input.scope, plaintextValue: input.plaintextValue }; + state.secrets.push(created); + return created; + }), + }; + return { + ...actual, + GlobalSettingsStore: makeConstructibleMock(() => ({ + init: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn(async () => state.globalSettings), + updateSettings: vi.fn(async (patch: any) => { + state.globalSettings = { ...state.globalSettings, ...patch }; + return state.globalSettings; + }), + })), + TaskStore: makeConstructibleMock(() => ({ + init: vi.fn().mockResolvedValue(undefined), + getSecretsStore: vi.fn(async () => secretsStore), + })), + }; +}); + +vi.mock("../../project-context.js", () => ({ + resolveProject: vi.fn(async () => ({ + projectId: "proj-1", + projectName: "demo", + projectPath: "/tmp/demo", + isRegistered: true, + store: { + getSettingsByScope: vi.fn(async () => ({ global: state.globalSettings, project: state.projectSettings })), + updateSettings: vi.fn(async (patch: any) => { + state.projectSettings = { ...state.projectSettings, ...patch }; + return { ...state.globalSettings, ...state.projectSettings }; + }), + getSecretsStore: vi.fn(async () => ({ + getSecretMetadata: (id: string, scope: "global" | "project") => state.secrets.find((secret) => secret.id === id && secret.scope === scope) ?? null, + listSecrets: (scope?: "global" | "project") => state.secrets.filter((secret) => !scope || secret.scope === scope), + createSecret: async (input: { scope: "global" | "project"; key: string; plaintextValue: string }) => { + const created = { id: `sec-${state.nextSecretId++}`, key: input.key, scope: input.scope, plaintextValue: input.plaintextValue }; + state.secrets.push(created); + return created; + }, + })), + }, + })), +})); + +import { + runMcpAdd, + runMcpDisable, + runMcpEnable, + runMcpExport, + runMcpImport, + runMcpList, + runMcpRemove, +} from "../mcp.js"; + +function resetState() { + state.globalSettings = { mcpServers: { enabled: false, servers: [] } }; + state.projectSettings = { mcpServers: { enabled: false, servers: [] } }; + state.secrets = []; + state.nextSecretId = 1; +} + +function captureConsole() { + const output: string[] = []; + const logSpy = vi.spyOn(console, "log").mockImplementation((...args) => output.push(args.map(String).join(" "))); + return { output, restore: () => logSpy.mockRestore() }; +} + +describe("mcp commands", () => { + let tempDir: string | undefined; + + beforeEach(() => { + resetState(); + tempDir = mkdtempSync(join(tmpdir(), "fusion-mcp-cli-")); + }); + + afterEach(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("round-trips add list remove across global and project scopes with effective resolution", async () => { + const consoleCapture = captureConsole(); + await runMcpAdd("github", { scope: "global", transport: "stdio", command: "global-gh" }); + await runMcpAdd("github", { scope: "project", transport: "stdio", command: "project-gh" }); + await runMcpList({ json: true }); + + const listed = JSON.parse(consoleCapture.output.at(-1) ?? "{}"); + expect(listed.global[0]).toMatchObject({ name: "github", command: "global-gh" }); + expect(listed.project[0]).toMatchObject({ name: "github", command: "project-gh" }); + expect(listed.effective).toEqual([expect.objectContaining({ name: "github", command: "project-gh" })]); + + await runMcpDisable("github", { scope: "project" }); + await runMcpList({ json: true }); + expect(JSON.parse(consoleCapture.output.at(-1) ?? "{}").effective).toEqual([]); + + await runMcpRemove("github", { scope: "project" }); + await runMcpList({ json: true }); + expect(JSON.parse(consoleCapture.output.at(-1) ?? "{}").effective).toEqual([expect.objectContaining({ command: "global-gh" })]); + consoleCapture.restore(); + }); + + it("imports Claude Desktop mcpServers JSON by creating secrets and persisting only references", async () => { + const fixture = join(tempDir!, "claude.json"); + writeFileSync(fixture, JSON.stringify({ + mcpServers: { + github: { command: "github-mcp-server", args: ["stdio"], env: { GITHUB_TOKEN: "ghp_raw" } }, + }, + })); + const consoleCapture = captureConsole(); + + await runMcpImport(fixture, { scope: "project", yes: true }); + + expect(state.secrets).toEqual([expect.objectContaining({ key: "mcp.github.env.GITHUB_TOKEN", plaintextValue: "ghp_raw", scope: "project" })]); + expect(JSON.stringify(state.projectSettings)).not.toContain("ghp_raw"); + expect(state.projectSettings.mcpServers.servers[0]).toMatchObject({ + name: "github", + transport: "stdio", + env: { GITHUB_TOKEN: { secretRef: "sec-1", scope: "project" } }, + }); + consoleCapture.restore(); + }); + + it("exports Fusion MCP JSON with secret references instead of raw values", async () => { + state.secrets.push({ id: "sec-existing", key: "GITHUB_TOKEN", scope: "project", plaintextValue: "raw-token" }); + await runMcpAdd("github", { scope: "project", transport: "stdio", command: "github-mcp-server", env: ["GITHUB_TOKEN=GITHUB_TOKEN"] }); + const consoleCapture = captureConsole(); + + await runMcpExport({ scope: "project" }); + + const exported = JSON.parse(consoleCapture.output.at(-1) ?? "{}"); + expect(exported.mcpServers.github.env.GITHUB_TOKEN).toEqual({ secretRef: "sec-existing", scope: "project" }); + expect(JSON.stringify(exported)).not.toContain("raw-token"); + consoleCapture.restore(); + }); + + it("rejects plaintext secrets on add and writes nothing", async () => { + await expect(runMcpAdd("bad", { scope: "project", transport: "stdio", command: "bad", envRaw: ["TOKEN=plaintext"] })).rejects.toThrow(/Plaintext MCP env\/header\/token values are not allowed/); + expect(state.projectSettings.mcpServers.servers).toEqual([]); + }); + + it("toggles enabled state at the chosen scope", async () => { + await runMcpAdd("docs", { scope: "project", transport: "http", url: "https://docs.example.test/mcp" }); + await runMcpDisable("docs", { scope: "project" }); + expect(state.projectSettings.mcpServers.servers[0].enabled).toBe(false); + await runMcpEnable("docs", { scope: "project" }); + expect(state.projectSettings.mcpServers.servers[0].enabled).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 0000000000..944efe684b --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,455 @@ +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + GlobalSettingsStore, + TaskStore, + exportMcpServersJson, + importMcpServersJson, + isMcpSecretRef, + resolveEffectiveMcpServers, + validateMcpServerDefinitionDetailed, + validateMcpServerDefinitionsDetailed, + type GlobalSettings, + type McpSecretRef, + type McpServerDefinition, + type McpServersSettings, + type ProjectSettings, + type SecretScope, + type Settings, +} from "@fusion/core"; +import { resolveProject, type ProjectContext } from "../project-context.js"; + +export type McpScope = "global" | "project"; +export type McpTransportInput = "stdio" | "sse" | "http" | "streamable-http"; + +export interface McpSensitiveInputOptions { + env?: string[]; + headers?: string[]; + envRaw?: string[]; + headersRaw?: string[]; + createEnv?: string[]; + createHeaders?: string[]; + secretRef?: string; + secretScope?: SecretScope; + scope?: McpScope; +} + +export interface McpMutationOptions extends McpSensitiveInputOptions { + projectName?: string; + scope?: McpScope; + transport?: McpTransportInput; + command?: string; + args?: string[] | string; + url?: string; + enabled?: boolean; +} + +interface McpContext { + project?: ProjectContext; + globalStore: GlobalSettingsStore; +} + +const DEFAULT_SCOPE: McpScope = "project"; + +async function createGlobalSettingsStore(): Promise { + const store = new GlobalSettingsStore(); + await store.init(); + return store; +} + +async function loadContext(projectName?: string, requireProject = false): Promise { + const globalStore = await createGlobalSettingsStore(); + let project: ProjectContext | undefined; + try { + project = await resolveProject(projectName); + } catch (error) { + if (requireProject || projectName) throw error; + } + return { project, globalStore }; +} + +function normalizeScope(scope?: McpScope): McpScope { + if (!scope) return DEFAULT_SCOPE; + if (scope !== "global" && scope !== "project") { + throw new Error(`Invalid MCP scope "${scope}". Use global or project.`); + } + return scope; +} + +function normalizeTransport(transport?: McpTransportInput): "stdio" | "sse" | "streamable-http" { + if (!transport) return "stdio"; + if (transport === "http" || transport === "streamable-http") return "streamable-http"; + if (transport === "stdio" || transport === "sse") return transport; + throw new Error(`Invalid MCP transport "${transport}". Use stdio, sse, or http.`); +} + +function ensureProject(context: McpContext): ProjectContext { + if (!context.project) { + throw new Error("Project scope requires --project or running from a Fusion project directory."); + } + return context.project; +} + +function mcpSettings(settings?: Pick | null): McpServersSettings { + return { + enabled: settings?.mcpServers?.enabled ?? false, + servers: Array.isArray(settings?.mcpServers?.servers) ? settings.mcpServers.servers : [], + }; +} + +async function readScopedSettings(context: McpContext, scope: McpScope): Promise { + if (scope === "global") return mcpSettings(await context.globalStore.getSettings()); + const project = ensureProject(context); + const scoped = await project.store.getSettingsByScope(); + return mcpSettings(scoped.project); +} + +async function writeScopedSettings(context: McpContext, scope: McpScope, next: McpServersSettings): Promise { + const validation = validateMcpServerDefinitionsDetailed(next.servers ?? [], "mcpServers.servers"); + if (validation.errors.length > 0) { + throw new Error(formatValidationErrors(validation.errors)); + } + const normalized = { enabled: next.enabled ?? true, servers: validation.value ?? [] }; + if (scope === "global") { + await context.globalStore.updateSettings({ mcpServers: normalized } as Partial & Record); + return; + } + const project = ensureProject(context); + await project.store.updateSettings({ mcpServers: normalized } as Partial); +} + +function upsertServer(servers: McpServerDefinition[], server: McpServerDefinition): McpServerDefinition[] { + const without = servers.filter((entry) => entry.name !== server.name); + return [...without, server]; +} + +function removeServer(servers: McpServerDefinition[], name: string): { servers: McpServerDefinition[]; removed: boolean } { + const next = servers.filter((entry) => entry.name !== name); + return { servers: next, removed: next.length !== servers.length }; +} + +function parseKeyValuePairs(values: string[] | undefined, flag: string): Array<{ key: string; value: string }> { + return (values ?? []).map((entry) => { + const index = entry.indexOf("="); + if (index <= 0 || index === entry.length - 1) { + throw new Error(`Invalid ${flag} value "${entry}". Use KEY=SECRET_REF.`); + } + return { key: entry.slice(0, index).trim(), value: entry.slice(index + 1).trim() }; + }); +} + +function assertNoPlaintextSensitiveOptions(opts: McpSensitiveInputOptions): void { + const raw = [...(opts.envRaw ?? []), ...(opts.headersRaw ?? [])]; + if (raw.length > 0) { + throw new Error("Plaintext MCP env/header/token values are not allowed in settings. Use --secret-ref for an existing Fusion secret or --create-secret-* to store the value in SecretsStore first."); + } +} + +async function getSecretsStore(context: McpContext) { + const project = context.project; + const store = project?.store ?? new TaskStore(process.cwd()); + if (!project) await store.init(); + return store.getSecretsStore(); +} + +async function resolveExistingSecret(context: McpContext, secretRef: string, scope: SecretScope): Promise { + const secrets = await getSecretsStore(context); + const byId = secrets.getSecretMetadata(secretRef, scope); + if (byId) return { secretRef: byId.id, scope }; + const byKey = secrets.listSecrets(scope).find((secret) => secret.key === secretRef); + if (!byKey) { + throw new Error(`Secret "${secretRef}" not found in ${scope} scope. Create it first or use --create-secret-env/--create-secret-header.`); + } + return { secretRef: byKey.id, scope }; +} + +async function createSecretRef(context: McpContext, params: { scope: SecretScope; key: string; plaintextValue: string; description: string }): Promise { + const secrets = await getSecretsStore(context); + const created = await secrets.createSecret({ + scope: params.scope, + key: params.key, + plaintextValue: params.plaintextValue, + description: params.description, + }); + return { secretRef: created.id, scope: params.scope }; +} + +function suggestedSecretKey(serverName: string, field: "env" | "headers", key: string): string { + const clean = (value: string): string => value.trim().replace(/[^A-Za-z0-9_.-]+/gu, "_").replace(/^_+|_+$/gu, ""); + return ["mcp", clean(serverName), field, clean(key)].filter(Boolean).join("."); +} + +async function buildSensitiveMap( + context: McpContext, + serverName: string, + field: "env" | "headers", + refValues: string[] | undefined, + createValues: string[] | undefined, + opts: McpSensitiveInputOptions, +): Promise | undefined> { + const out: Record = {}; + const secretScope = opts.secretScope ?? normalizeScope(opts.scope as McpScope | undefined); + for (const { key, value } of parseKeyValuePairs(refValues, field === "env" ? "--env" : "--header")) { + out[key] = await resolveExistingSecret(context, value, secretScope); + } + const creates = parseKeyValuePairs(createValues, field === "env" ? "--create-secret-env" : "--create-secret-header"); + for (const { key, value } of creates) { + out[key] = await createSecretRef(context, { + scope: secretScope, + key: suggestedSecretKey(serverName, field, key), + plaintextValue: value, + description: `MCP ${field} ${key} for ${serverName}`, + }); + } + if (opts.secretRef && Object.keys(out).length === 0) { + const key = field === "env" ? "TOKEN" : "Authorization"; + out[key] = await resolveExistingSecret(context, opts.secretRef, secretScope); + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function normalizeArgs(value: string[] | string | undefined): string[] | undefined { + if (Array.isArray(value)) return value.length > 0 ? value : undefined; + if (!value) return undefined; + try { + const parsed = JSON.parse(value) as unknown; + if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string")) return parsed; + } catch { + // Fall through to comma/space parsing for CLI convenience. + } + return value.split(",").map((entry) => entry.trim()).filter(Boolean); +} + +function formatValidationErrors(errors: Array<{ path: string; message: string }>): string { + return errors.map((error) => `${error.path}: ${error.message}`).join("\n"); +} + +async function buildServerDefinition(context: McpContext, name: string, opts: McpMutationOptions, existing?: McpServerDefinition): Promise { + assertNoPlaintextSensitiveOptions(opts); + const transport = normalizeTransport(opts.transport ?? existing?.transport as McpTransportInput | undefined); + const enabled = opts.enabled ?? existing?.enabled; + const base = { name, ...(enabled !== undefined ? { enabled } : {}) }; + let candidate: unknown; + if (transport === "stdio") { + candidate = { + ...base, + transport, + command: opts.command ?? (existing?.transport === "stdio" ? existing.command : undefined), + args: normalizeArgs(opts.args) ?? (existing?.transport === "stdio" ? existing.args : undefined), + env: await buildSensitiveMap(context, name, "env", opts.env, opts.createEnv, opts) ?? (existing?.transport === "stdio" ? existing.env : undefined), + }; + } else { + candidate = { + ...base, + transport, + url: opts.url ?? (existing?.transport === "sse" || existing?.transport === "streamable-http" ? existing.url : undefined), + headers: await buildSensitiveMap(context, name, "headers", opts.headers, opts.createHeaders, opts) ?? (existing?.transport === "sse" || existing?.transport === "streamable-http" ? existing.headers : undefined), + }; + } + const validation = validateMcpServerDefinitionDetailed(candidate); + if (!validation.value) throw new Error(formatValidationErrors(validation.errors)); + return validation.value; +} + +function sensitiveSummary(server: McpServerDefinition): string { + const values = server.transport === "stdio" ? server.env : server.headers; + if (!values || Object.keys(values).length === 0) return "none"; + return Object.entries(values).map(([key, value]) => `${key}:${isMcpSecretRef(value) ? `${value.scope} secret` : "INVALID plaintext"}`).join(", "); +} + +function serverLine(server: McpServerDefinition, source: string, effectiveNames: Set): string { + const state = server.enabled === false ? "disabled" : effectiveNames.has(server.name) ? "effective" : "overridden"; + const target = server.transport === "stdio" ? server.command : server.url; + return ` ${server.name.padEnd(20)} ${source.padEnd(8)} ${state.padEnd(10)} ${server.transport.padEnd(15)} ${target ?? ""} secrets=${sensitiveSummary(server)}`; +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Listing must show global declarations, project declarations, and the project-over-global effective result without exposing secret material. Sensitive env/header fields are summarized as Fusion secret references only. + */ +export async function runMcpList(opts: { projectName?: string; json?: boolean } = {}): Promise { + const context = await loadContext(opts.projectName, false); + const globalSettings = mcpSettings(await context.globalStore.getSettings()); + const projectSettings = context.project ? mcpSettings((await context.project.store.getSettingsByScope()).project) : undefined; + const effective = resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null); + if (opts.json) { + console.log(JSON.stringify({ global: globalSettings.servers ?? [], project: projectSettings?.servers ?? [], effective }, null, 2)); + return; + } + console.log(); + console.log(" MCP servers"); + console.log(" " + "─".repeat(80)); + const effectiveNames = new Set(effective.map((server) => server.name)); + for (const server of globalSettings.servers ?? []) console.log(serverLine(server, "global", effectiveNames)); + for (const server of projectSettings?.servers ?? []) console.log(serverLine(server, "project", effectiveNames)); + if ((globalSettings.servers?.length ?? 0) === 0 && (projectSettings?.servers?.length ?? 0) === 0) console.log(" No MCP servers configured."); + console.log(); + console.log(` Effective: ${effective.map((server) => server.name).join(", ") || "none"}`); + console.log(); +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Add persists an MCP server at the chosen global/project scope and lets the shared resolver decide project-over-global behavior. Env/header/token material must be an existing Fusion secret reference or be created in SecretsStore before validation; raw values never enter settings. + */ +export async function runMcpAdd(name: string, opts: McpMutationOptions = {}): Promise { + const scope = normalizeScope(opts.scope); + const context = await loadContext(opts.projectName, scope === "project"); + const current = await readScopedSettings(context, scope); + if ((current.servers ?? []).some((server) => server.name === name)) throw new Error(`MCP server "${name}" already exists in ${scope} scope. Use edit to update it.`); + const server = await buildServerDefinition(context, name, opts); + await writeScopedSettings(context, scope, { enabled: true, servers: upsertServer(current.servers ?? [], server) }); + console.log(`✓ Added MCP server "${name}" to ${scope} scope`); +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Edit updates only the selected scope; project definitions override same-named globals and may be disabled locally. Secret-bearing fields are replaced only with Fusion secret references or newly created SecretsStore records. + */ +export async function runMcpEdit(name: string, opts: McpMutationOptions = {}): Promise { + const scope = normalizeScope(opts.scope); + const context = await loadContext(opts.projectName, scope === "project"); + const current = await readScopedSettings(context, scope); + const existing = (current.servers ?? []).find((server) => server.name === name); + if (!existing) throw new Error(`MCP server "${name}" not found in ${scope} scope.`); + const server = await buildServerDefinition(context, name, opts, existing); + await writeScopedSettings(context, scope, { enabled: true, servers: upsertServer(current.servers ?? [], server) }); + console.log(`✓ Updated MCP server "${name}" in ${scope} scope`); +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Remove deletes only the scoped declaration. Removing a project override can reveal an inherited global declaration again because effective MCP resolution is project-over-global by server name. + */ +export async function runMcpRemove(name: string, opts: { projectName?: string; scope?: McpScope } = {}): Promise { + const scope = normalizeScope(opts.scope); + const context = await loadContext(opts.projectName, scope === "project"); + const current = await readScopedSettings(context, scope); + const next = removeServer(current.servers ?? [], name); + if (!next.removed) throw new Error(`MCP server "${name}" not found in ${scope} scope.`); + await writeScopedSettings(context, scope, { enabled: current.enabled ?? true, servers: next.servers }); + console.log(`✓ Removed MCP server "${name}" from ${scope} scope`); +} + +async function setEnabled(name: string, enabled: boolean, opts: { projectName?: string; scope?: McpScope } = {}): Promise { + const scope = normalizeScope(opts.scope); + const context = await loadContext(opts.projectName, scope === "project"); + const current = await readScopedSettings(context, scope); + const existing = (current.servers ?? []).find((server) => server.name === name); + if (!existing) throw new Error(`MCP server "${name}" not found in ${scope} scope.`); + await writeScopedSettings(context, scope, { enabled: current.enabled ?? true, servers: upsertServer(current.servers ?? [], { ...existing, enabled }) }); + console.log(`✓ ${enabled ? "Enabled" : "Disabled"} MCP server "${name}" in ${scope} scope`); +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Enable flips the scoped server flag only; effective availability is still computed by the foundation resolver. No secret values are read or printed while toggling MCP servers. + */ +export async function runMcpEnable(name: string, opts: { projectName?: string; scope?: McpScope } = {}): Promise { + await setEnabled(name, true, opts); +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Disable records a scoped enabled:false declaration. At project scope this intentionally masks a same-named global server without deleting global configuration or exposing any secret-backed fields. + */ +export async function runMcpDisable(name: string, opts: { projectName?: string; scope?: McpScope } = {}): Promise { + await setEnabled(name, false, opts); +} + +/** + * FNXC:McpConfig 2026-06-25-21:03: + * Claude Desktop imports must delegate parsing to the core importer. Any plaintext env/header values returned by the importer are immediately converted into SecretsStore records, then settings receive only the resulting Fusion secret references. + */ +export async function runMcpImport(filePath: string, opts: { projectName?: string; scope?: McpScope; yes?: boolean } = {}): Promise { + const scope = normalizeScope(opts.scope); + const context = await loadContext(opts.projectName, scope === "project"); + const resolvedPath = resolve(filePath); + if (!existsSync(resolvedPath)) throw new Error(`File not found: ${filePath}`); + const imported = importMcpServersJson(await readFile(resolvedPath, "utf-8"), { scope }); + if (imported.errors.length > 0) throw new Error(`Invalid MCP import file:\n${imported.errors.map((error) => ` - ${error}`).join("\n")}`); + console.log(); + console.log(" MCP Import Summary:"); + console.log(` Source: ${resolvedPath}`); + console.log(` Scope: ${scope}`); + console.log(` Servers: ${imported.definitions.length}`); + console.log(` Secrets to create: ${imported.secretsToCreate.length}`); + console.log(); + if (!opts.yes) throw new Error("Use --yes to confirm this import operation"); + const replacements = new Map(); + for (const secret of imported.secretsToCreate) { + replacements.set(`${secret.serverName}:${secret.field}:${secret.key}:${secret.suggestedKey}`, await createSecretRef(context, { + scope: secret.scope, + key: secret.suggestedKey, + plaintextValue: secret.plaintextValue, + description: `Imported MCP ${secret.field} ${secret.key} for ${secret.serverName}`, + })); + } + const definitions = imported.definitions.map((server) => rewriteImportedSecretRefs(server, replacements)); + const current = await readScopedSettings(context, scope); + await writeScopedSettings(context, scope, { enabled: true, servers: [...(current.servers ?? []).filter((server) => !definitions.some((entry) => entry.name === server.name)), ...definitions] }); + console.log(`✓ Imported ${definitions.length} MCP server(s) into ${scope} scope`); +} + +function rewriteImportedSecretRefs(server: McpServerDefinition, replacements: Map): McpServerDefinition { + const rewrite = (field: "env" | "headers", values: Record | undefined): Record | undefined => { + if (!values) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (!isMcpSecretRef(value)) continue; + out[key] = replacements.get(`${server.name}:${field}:${key}:${value.secretRef}`) ?? value; + } + return Object.keys(out).length > 0 ? out : undefined; + }; + if (server.transport === "stdio") return { ...server, env: rewrite("env", server.env) }; + return { ...server, headers: rewrite("headers", server.headers) }; +} + +/** + * FNXC:McpConfig 2026-06-25-21:03: + * MCP export uses the core JSON exporter so secret-backed fields stay as descriptors and are never materialized. The default export is effective project-over-global configuration; explicit scope exports preserve stored declarations. + */ +export async function runMcpExport(opts: { projectName?: string; scope?: McpScope | "effective"; output?: string; json?: boolean } = {}): Promise { + const context = await loadContext(opts.projectName, opts.scope === "project"); + const scope = opts.scope ?? "effective"; + const globalSettings = mcpSettings(await context.globalStore.getSettings()); + const projectSettings = context.project ? mcpSettings((await context.project.store.getSettingsByScope()).project) : undefined; + const definitions = scope === "global" + ? globalSettings.servers ?? [] + : scope === "project" + ? projectSettings?.servers ?? [] + : resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null); + const exported = exportMcpServersJson(definitions); + const json = JSON.stringify(exported, null, 2); + if (opts.output) { + await writeFile(resolve(opts.output), json); + console.log(`✓ Exported MCP servers to ${resolve(opts.output)}`); + return; + } + console.log(json); +} + +/** + * FNXC:McpConfig 2026-06-25-21:03: + * Validate is intentionally list-only until an optional MCP reachability service exists. It still uses the foundation validator so transport requirements and plaintext-secret rejection match every other MCP settings write path. + */ +export async function runMcpValidate(opts: { projectName?: string; scope?: McpScope | "effective"; json?: boolean } = {}): Promise { + const context = await loadContext(opts.projectName, opts.scope === "project"); + const scope = opts.scope ?? "effective"; + const globalSettings = mcpSettings(await context.globalStore.getSettings()); + const projectSettings = context.project ? mcpSettings((await context.project.store.getSettingsByScope()).project) : undefined; + const definitions = scope === "global" + ? globalSettings.servers ?? [] + : scope === "project" + ? projectSettings?.servers ?? [] + : resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null); + const validation = validateMcpServerDefinitionsDetailed(definitions); + const result = { ok: validation.errors.length === 0, servers: definitions.length, errors: validation.errors }; + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + if (result.ok) console.log(`✓ ${definitions.length} MCP server definition(s) valid`); + else throw new Error(formatValidationErrors(validation.errors)); +}