Files
fusion/packages/cli/src/commands/mcp.ts
gsxdsm c15c78feeb feat: migrate storage from SQLite to PostgreSQL (#1793)
# Migrate storage from SQLite to PostgreSQL — full dashboard cutover

Migrates Fusion's storage layer to the embedded PostgreSQL
`AsyncDataLayer` (the default backend) and **completes the
satellite-store + feature cutover** so every dashboard and Command
Center surface works in PG mode.

## Status — every surface works in embedded-PG mode

Verified live against a running embedded-Postgres dashboard (all
**200**, zero 5xx) and gate-tested (**23 files / 99 tests** on embedded
PG, plus engine-core 294 and ci-shape 63 in the blocking merge gate;
core/engine/cli/dashboard typecheck clean).

| Area | Surfaces | State |
|---|---|---|
| Satellite stores | workflows, todos, insights, research, missions,
goals, mailbox | ✅ |
| Views | artifacts, documents, evals | ✅ |
| Command Center | activity, productivity, team, tokens, tools,
**workflows**, **github**, **signals**, **plugin-activations**, **live**
(all 10) | ✅ |
| Run execution | insight generation, research run execution | ✅
(store-path; AI step needs a provider) |
| Live updates | SSE push for mission/research/insight events | ✅ |
| Workflow editing | create / update / delete / select (+ id counter) |
✅ |
| Engine | mission autopilot, incident-signal ingestion, regression
storm-guard, agent wake-on-message | ✅ |
| Core | tasks, agents, secrets, automations, memory, chat, usage, PRs,
git | ✅ |

## Approach

Each satellite store gets an `Async<Store>` wrapper exposing the sync
store's method names over the existing `async-*-store.ts` helpers;
`get<Store>Store()` returns a `Sync | Async` union; consumers `await`
(harmless on sync), and engine/CLI paths that can't convert use
`instanceof Sync` graceful fallback. Analytics aggregators branch on
`"ping" in dbOrLayer` to run schema-qualified raw SQL over `project.*`
(snake_case) in PG. Executors/orchestrators/autopilot are
await-converted to drive the union store; the async store wrappers
extend `EventEmitter` so SSE live-push fires in both backends.

Not-yet-ported capabilities degrade gracefully (never 500) and are
individually called out in commits.

## Sync with main

The branch is kept continuously merged with `main` (currently through
FN-7845, 2026-07-12); the earlier "final rebase deferred" note no longer
applies. Use **Create a merge commit** (or squash) to land it — GitHub's
rebase-merge cannot replay a merge-maintained branch.

## Residual Review Findings

Multi-agent code review of the PostgreSQL satellite-store ports (U1–U5)
applied 3 safe fixes (see `fix(review): apply autofix feedback`). The
following are **real but gated** — recorded here as follow-up work
rather than auto-applied. All are SQLite→PostgreSQL
**concurrency/atomicity regressions**: the sync stores were immune only
by SQLite's single-writer, single-threaded-handler execution; the async
ports open multi-await read-modify-write windows. **Reachability is low
today** because the execution engines that generate concurrent same-run
mutations (insight run executor, research orchestrator/dispatcher) are
`instanceof`-gated to sync mode in PG. No process-crash class survived
(all engine fallbacks correctly guard the sync store).

- **[P1] Research `appendResearchEvent` dual-write is non-atomic**
(`packages/core/src/async-research-store.ts`, corroborated: adversarial
+ reliability). The `research_run_events` insert (own transaction) and
the `run.events` jsonb update are separate writes — a crash between
them, or two concurrent appends, splits the table count from the jsonb
array. **Fix:** perform the seq-insert and the jsonb update in one
`layer.transactionImmediate`.
- **[P1] Research run terminal-reversion via stale full-row persist**
(`async-research-store.ts` `persistResearchRun`/`updateResearchStatus`).
Concurrent `PATCH /runs/:id/status` + `POST /runs/:id/events` can revert
a terminal run to `running` by overwriting the whole row, bypassing the
transition guard. **Fix:** scoped column `UPDATE`s with a `WHERE status
…` guard, or optimistic version column.
- **[P2] `updateResearchRun`/`updateInsightRun` read-then-write TOCTOU**
— concurrent PATCHes last-writer-wins on the lifecycle merge. **Fix:**
`SELECT … FOR UPDATE` / enclosing transaction.
- **[P2] `upsertRun`/`createRunOrThrowConflict` check-then-create race**
(`async-insight-store.ts`) — two callers can each create an "active"
run. **Fix:** partial unique index on `(projectId, trigger) WHERE status
IN ('pending','running')`.
- **[P3] `createResearchRetryRun` return-value divergence** — sync
returns the pre-update `queued` snapshot; async returns the reloaded
`retry_waiting` run (persisted state is identical). Pick one side for
cross-backend parity.
- **[P2/perf] Mission `getMissionWithHierarchy`/`getMissionHealth` N+1
fan-out** — O(milestones×slices) sequential round-trips hold one pool
slot per request; can starve the pool for large hierarchies. **Fix:**
batched/joined reads.
- **Testing gaps:** no PG-mode concurrency tests (interleaved
status/event mutations), no sync↔async parity assertion for the
lifecycle-error codes, and no mission status/health rollup parity test
vs the sync `MissionStore`.

~~Out of scope (deferred): AI run *execution* (insight/research) +
mission autopilot + live SSE mission events remain sync-gated/degraded
in PG mode.~~ **Since ported** — insight/research run execution, mission
autopilot, and SSE live push all run on the async layer now, which also
makes the concurrency findings above genuinely reachable; they remain
open follow-ups.







---

## Update — 2026-07-12: production-readiness hardening & live acceptance

Everything below landed on this branch since the description above was
written:

**Production blockers from review — fixed**
- `recoverStaleTransitionPending` ported to the async layer (backend
moves write + clear the crash-safe marker; startup/maintenance sweeps no
longer throw).
- Lost-update class fixed: `atomicWriteTaskJson`/`WithAudit` write
changed columns only (full-row upserts silently resurrected stale fields
across concurrent store instances — the "task stuck unplanned forever"
bug).
- First-boot **auto-migration**: booting the PG backend over a project
with a legacy `fusion.db` migrates it automatically (loud failure,
SQLite kept as backup), and the dashboard shows a one-time **"your data
was migrated" banner** with the backup paths and a Need-help Discord
link.
- `pg_dump`/`pg_restore` discovered from common install locations for
embedded-mode backups.
- The PG suite is part of the blocking merge gate (`test:pg-gate`).

**Multi-project isolation (PR #2007, merged into this branch)**
- `project_id` partition key on tasks / archived tasks / config,
`taskProjectScope` threaded through every scan/claim/count, per-project
config rows, layer bound to the project at startup.
- Review P1 follow-up: the shared cold-storage `archive.archived_tasks`
table is also partitioned and all archived-board reads/counts/searches
are scoped.
- Schema drift self-heal generalized to schema-qualified columns so
existing databases upgrade in place.

**Other changes**
- Node settings sync **removed** in PG mode (409
`settings-sync-disabled-postgres`) — nodes share state by connecting to
the same database; auth sync kept (per-machine file).
- Perf (review findings): `listTasks` pushes column filter + ORDER BY +
LIMIT/OFFSET into SQL; `getConversation` capped to the most recent 200
messages.
- Fixed a false "operator action required" pause-abort log fired on
every successfully auto-merged task.

**Live acceptance — PASSED (2026-07-12)**
A sandboxed instance (isolated HOME, embedded PG, real Opus executor)
ran a task through the complete cycle: create → triage (AI spec) →
execute → in-review → AI squash-merge landed on the project's `main` →
done. A write+read sweep of every data surface (settings, comments,
documents, attachments + artifact bridge + artifact edit, chat with real
generation, goals, missions, agent mail, secrets, workflows, memory, CC
analytics) was green on embedded PG.

**Known remaining work**
- The per-project `config` PK re-key has no upgrade path for
pre-isolation embedded-PG databases (needs a real `DROP
CONSTRAINT`/re-key migration; fresh databases are fine).
- `pg_dump`/`pg_restore` binaries are not yet bundled in release
artifacts (PATH/common-location discovery only).
- The satellite-store concurrency findings listed above.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Phil Larson <hello@phillarson.xyz>
Co-authored-by: fusion-merge <fusion-merge@local>
2026-07-13 19:07:58 -07:00

626 lines
28 KiB
TypeScript

import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { TaskStore,
GlobalSettingsStore,
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, createLocalStore, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.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;
}
/**
* FNXC:CliBoardMutation 2026-07-09-00:00:
* FN-7739 audit finding: `loadContext` resolves an optional cached project
* `TaskStore` (via `resolveProject`), and `getSecretsStore` may separately
* build an UNCACHED `new TaskStore(process.cwd())` (when no project is in
* scope) — before this change, NEITHER was ever closed on any exit path, so
* every `runMcp*` read and mutation leaked a live SQLite/WAL handle that
* kept the CLI process's event loop alive after the command finished. Only
* the project-scope `writeScopedSettings` -> `store.updateSettings` call
* ever touches the board DB; it did not retry through a momentary
* `database is locked`. `GlobalSettingsStore` is file-backed
* (`~/.fusion/settings.json`, no SQLite handle, no `close()`) — it is
* intentionally left with no close/retry (confirmed via
* packages/core/src/global-settings.ts). `McpContext.secretsStore` caches
* the uncached ad-hoc secrets `TaskStore` per-invocation (created lazily by
* `getSecretsStore`, which may be called multiple times inside a single
* mutation's `buildSensitiveMap` loop) so it is opened at most once and
* closed exactly once via `closeMcpContext`, which closes BOTH the cached
* project store (`closeProjectStore`) and the ad-hoc secrets store
* (`asLocalProjectContext` + `closeProjectStore`) on every exit path.
* Reuses the FN-7731/FN-7738 `retryOnLock`/`closeProjectStore` helpers — no
* forked implementation.
*/
interface McpContext {
project?: ProjectContext;
globalStore: GlobalSettingsStore;
/** Ad-hoc uncached secrets store, created lazily; closed via closeMcpContext. */
secretsStore?: TaskStore;
}
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 };
}
/**
* Close the cached project store (if resolved) AND the ad-hoc uncached
* secrets store (if one was created) on every exit path. Best-effort and
* idempotent — see `closeProjectStore`.
*/
async function closeMcpContext(context: McpContext): Promise<void> {
if (context.project) {
await closeProjectStore(context.project);
}
if (context.secretsStore) {
await closeProjectStore(asLocalProjectContext(context.secretsStore));
}
}
async function failMcpCommand(error: unknown, context?: McpContext): Promise<never> {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
if (context) {
await closeMcpContext(context);
}
return process.exit(1);
}
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 retryOnLock(async () => project.store.getSettingsByScope(), { id: "mcp-settings", action: "read project MCP settings" });
return mcpSettings(scoped.project);
}
/**
* Global-scope writes target the file-backed `GlobalSettingsStore` (no
* SQLite handle behind it) and are NOT retried for lock. Only the
* project-scope `store.updateSettings` board write is wrapped in
* `retryOnLock` — it is the discrete SQLite interaction that can race a
* momentary engine/agent writer.
*/
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 retryOnLock(
async () => project.store.updateSettings({ mcpServers: normalized } as Partial<Settings>),
{ id: "mcp-settings", action: "write project MCP 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.");
}
}
/**
* Return the secrets store for this context, reusing the cached project
* store when in scope, or lazily creating (and caching on `context`) the
* uncached ad-hoc `TaskStore(process.cwd())` fallback exactly once per
* invocation so repeated calls inside `buildSensitiveMap` do not open a new
* handle each time — `closeMcpContext` closes it on every exit path.
*/
async function getSecretsStore(context: McpContext) {
const project = context.project;
if (project) {
return project.store.getSecretsStore();
}
if (!context.secretsStore) {
// FNXC:PostgresCutover 2026-07-05-12:00: boot the cwd fallback through the
// PostgreSQL startup factory; bare `new TaskStore` throws in backend mode.
context.secretsStore = await createLocalStore(process.cwd());
}
return context.secretsStore.getSecretsStore();
}
async function resolveExistingSecret(context: McpContext, secretRef: string, scope: SecretScope): Promise<McpSecretRef> {
const secrets = await getSecretsStore(context);
const byId = await secrets.getSecretMetadata(secretRef, scope);
if (byId) return { secretRef: byId.id, scope };
const byKey = (await secrets.listSecrets(scope)).find((secret: { id: string; key: string }) => 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> {
let context: McpContext | undefined;
try {
context = await loadContext(opts.projectName, false);
const globalSettings = mcpSettings(await context.globalStore.getSettings());
const project = context.project;
const projectSettings = project ? mcpSettings((await retryOnLock(async () => project.store.getSettingsByScope(), { id: "mcp-settings", action: "read project MCP settings" })).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();
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failMcpCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeMcpContext(context);
}
}
}
/**
* 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> {
let context: McpContext | undefined;
try {
const scope = normalizeScope(opts.scope);
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`);
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failMcpCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeMcpContext(context);
}
}
}
/**
* 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> {
let context: McpContext | undefined;
try {
const scope = normalizeScope(opts.scope);
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`);
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failMcpCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeMcpContext(context);
}
}
}
/**
* 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> {
let context: McpContext | undefined;
try {
const scope = normalizeScope(opts.scope);
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`);
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failMcpCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeMcpContext(context);
}
}
}
async function setEnabled(name: string, enabled: boolean, opts: { projectName?: string; scope?: McpScope } = {}): Promise<void> {
let context: McpContext | undefined;
try {
const scope = normalizeScope(opts.scope);
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`);
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failMcpCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeMcpContext(context);
}
}
}
/**
* 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> {
let context: McpContext | undefined;
try {
const scope = normalizeScope(opts.scope);
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`);
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failMcpCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeMcpContext(context);
}
}
}
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> {
let context: McpContext | undefined;
try {
context = await loadContext(opts.projectName, opts.scope === "project");
const scope = opts.scope ?? "effective";
const globalSettings = mcpSettings(await context.globalStore.getSettings());
const project = context.project;
const projectSettings = project ? mcpSettings((await retryOnLock(async () => project.store.getSettingsByScope(), { id: "mcp-settings", action: "read project MCP settings" })).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);
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failMcpCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeMcpContext(context);
}
}
}
/**
* 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> {
let context: McpContext | undefined;
try {
context = await loadContext(opts.projectName, opts.scope === "project");
const scope = opts.scope ?? "effective";
const globalSettings = mcpSettings(await context.globalStore.getSettings());
const project = context.project;
const projectSettings = project ? mcpSettings((await retryOnLock(async () => project.store.getSettingsByScope(), { id: "mcp-settings", action: "read project MCP settings" })).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));
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failMcpCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeMcpContext(context);
}
}
}