# 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>
626 lines
28 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|