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
This commit is contained in:
gsxdsm
2026-06-25 22:05:30 -07:00
parent 2c46cdc1e2
commit 429143ecf0
5 changed files with 817 additions and 0 deletions

View File

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

View File

@@ -986,6 +986,55 @@ fn settings import <file> [--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 <name>] [--json]
fn mcp add <name> --scope global|project --transport stdio --command <cmd> [--arg <arg> ...]
fn mcp add <name> --scope global|project --transport sse|http --url <url>
fn mcp edit <name> [--scope global|project] [--transport stdio|sse|http] [--command <cmd>|--url <url>]
fn mcp remove <name> [--scope global|project]
fn mcp enable <name> [--scope global|project]
fn mcp disable <name> [--scope global|project]
fn mcp import <claude-desktop.json> [--scope global|project] [--yes]
fn mcp export [--scope global|project|effective] [--output <file>] [--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 <arg>` / `--args <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 <file>` | 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.

View File

@@ -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 <fail|fallback-native>
fn settings export [opts] Export settings to a JSON file
fn settings import <file> [opts] Import settings from a JSON file
fn mcp list [--project <name>] [--json] List MCP servers by scope and effective resolution
fn mcp add <name> --scope <global|project> --transport <stdio|sse|http> [opts]
Add an MCP server using secret references for env/header values
fn mcp edit|remove|enable|disable <name> [--scope <global|project>]
Update, remove, or toggle a scoped MCP server
fn mcp import <file> [--scope <global|project>] [--yes]
Import Claude Desktop mcpServers JSON and create Fusion secrets
fn mcp export [--scope <global|project|effective>] [--output <file>]
Export Fusion MCP JSON with secret references only
fn mcp validate [--scope <global|project|effective>] [--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 <name> --scope global|project --transport stdio|sse|http [--command <cmd>|--url <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 <name> [--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 <name> [--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 <name> [--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 <name> [--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 <file> [--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) {

View File

@@ -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<T extends (...args: any[]) => unknown>(impl?: T) {
const mock = vi.fn(function () {});
const originalMockImplementation = mock.mockImplementation.bind(mock);
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
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<typeof import("@fusion/core")>();
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);
});
});

View File

@@ -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<GlobalSettingsStore> {
const store = new GlobalSettingsStore();
await store.init();
return store;
}
async function loadContext(projectName?: string, requireProject = false): Promise<McpContext> {
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<GlobalSettings | ProjectSettings | Settings, "mcpServers"> | 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<McpServersSettings> {
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<void> {
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<GlobalSettings> & Record<string, unknown>);
return;
}
const project = ensureProject(context);
await project.store.updateSettings({ mcpServers: normalized } as Partial<Settings>);
}
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<McpSecretRef> {
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<McpSecretRef> {
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<Record<string, McpSecretRef> | undefined> {
const out: Record<string, McpSecretRef> = {};
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<McpServerDefinition> {
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>): 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, McpSecretRef>();
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<string, McpSecretRef>): McpServerDefinition {
const rewrite = (field: "env" | "headers", values: Record<string, unknown> | undefined): Record<string, McpSecretRef> | undefined => {
if (!values) return undefined;
const out: Record<string, McpSecretRef> = {};
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<void> {
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<void> {
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));
}