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:
7
.changeset/fn-7024-mcp-cli.md
Normal file
7
.changeset/fn-7024-mcp-cli.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
182
packages/cli/src/commands/__tests__/mcp.test.ts
Normal file
182
packages/cli/src/commands/__tests__/mcp.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
455
packages/cli/src/commands/mcp.ts
Normal file
455
packages/cli/src/commands/mcp.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user