import { resolve } from "node:path"; import { commitPreparedChanges, getPathValue, prepareFileChanges, readConfigDocument, type ValueMutation } from "./file-editor.ts"; import { discoverMcpSources } from "./discovery.ts"; import { loadState, saveState, withStateLock } from "./state-store.ts"; import type { ActionResult, ArchivedEntry, BlockRecord, DiscoveryOptions, DiscoverySnapshot, McpcState, ServerListItem, SourceOccurrence, } from "./types.ts"; export interface McpcControllerOptions extends DiscoveryOptions { statePath: string; now?: () => Date; } function pathIdentity(filePath: string): string { const normalized = resolve(filePath); return process.platform === "win32" ? normalized.toLowerCase() : normalized; } function entryIdentity(entry: Pick): string { return `${pathIdentity(entry.path)}\0${entry.format}\0${entry.keyPath.join("\0")}`; } function dedupeOccurrences(occurrences: readonly SourceOccurrence[]): SourceOccurrence[] { const seen = new Set(); const result: SourceOccurrence[] = []; for (const occurrence of occurrences) { const identity = entryIdentity(occurrence); if (seen.has(identity)) continue; seen.add(identity); result.push(occurrence); } return result; } function archiveOccurrence(occurrence: SourceOccurrence): ArchivedEntry { return { path: occurrence.path, format: occurrence.format, keyPath: [...occurrence.keyPath], value: structuredClone(occurrence.value), source: occurrence.source, ...(occurrence.sourceId ? { sourceId: occurrence.sourceId } : {}), ...(occurrence.importKind ? { importKind: occurrence.importKind } : {}), }; } function recordByName(state: McpcState, name: string): BlockRecord | undefined { return state.records.find((record) => record.name === name); } function uniqueStrings(values: readonly string[]): string[] { return [...new Set(values)]; } export class McpcController { private readonly options: McpcControllerOptions; constructor(options: McpcControllerOptions) { this.options = { ...options, cwd: resolve(options.cwd), statePath: resolve(options.statePath) }; } private discover(): DiscoverySnapshot { return discoverMcpSources(this.options); } private inspectRestoreTargets(record: BlockRecord): string[] { const conflicts: string[] = []; const inspected = new Set(); for (const entry of record.entries) { const identity = entryIdentity(entry); if (inspected.has(identity)) continue; inspected.add(identity); try { const document = readConfigDocument(entry.path, entry.format); if (!document) continue; if (getPathValue(document.value, entry.keyPath).exists) { conflicts.push(`restore target is occupied: ${entry.path} (${entry.keyPath.join(".")})`); } } catch (error) { const message = error instanceof Error ? error.message : String(error); conflicts.push(`cannot inspect restore target ${entry.path}: ${message}`); } } return conflicts; } private conflictDetails(record: BlockRecord, snapshot: DiscoverySnapshot): string[] { const details: string[] = []; for (const occurrence of dedupeOccurrences(snapshot.occurrencesByName.get(record.name) ?? [])) { details.push(`active definition exists: ${occurrence.path} (${occurrence.keyPath.join(".")})`); } details.push(...this.inspectRestoreTargets(record)); return uniqueStrings(details); } list(): ServerListItem[] { const state = loadState(this.options.statePath); const snapshot = this.discover(); const records = new Map(state.records.map((record) => [record.name, record])); const names = new Set([...snapshot.activeNames, ...records.keys()]); const items: ServerListItem[] = []; for (const name of names) { const record = records.get(name); if (!record) { items.push({ name, visibility: "enabled", sourceCount: dedupeOccurrences(snapshot.occurrencesByName.get(name) ?? []).length, conflictDetails: [], }); continue; } const conflicts = this.conflictDetails(record, snapshot); items.push({ name, visibility: conflicts.length > 0 ? "conflict" : "blocked", sourceCount: record.entries.length, conflictDetails: conflicts, }); } return items.sort((left, right) => left.name.localeCompare(right.name)); } getConflicts(): ServerListItem[] { return this.list().filter((item) => item.visibility === "conflict"); } block(name: string): ActionResult { return withStateLock(this.options.statePath, () => { const state = loadState(this.options.statePath); const snapshot = this.discover(); const existing = recordByName(state, name); if (existing) { const conflicts = this.conflictDetails(existing, snapshot); return { name, changed: false, outcome: conflicts.length > 0 ? "conflict" : "already-blocked", details: conflicts, }; } const occurrences = dedupeOccurrences(snapshot.occurrencesByName.get(name) ?? []); if (occurrences.length === 0) { return { name, changed: false, outcome: "not-found", details: snapshot.diagnostics, }; } const record: BlockRecord = { name, blockedAt: (this.options.now?.() ?? new Date()).toISOString(), cwd: this.options.cwd, entries: occurrences.map(archiveOccurrence), }; const nextState: McpcState = { version: 1, records: [...state.records, record] }; saveState(this.options.statePath, nextState); try { const mutations: ValueMutation[] = record.entries.map((entry) => ({ path: entry.path, format: entry.format, keyPath: entry.keyPath, operation: "delete", expected: entry.value, })); const changes = prepareFileChanges(mutations); commitPreparedChanges(changes); } catch (error) { try { saveState(this.options.statePath, state); } catch (stateRollbackError) { throw new AggregateError( [error, stateRollbackError], `Failed to block ${name}; config was rolled back but MCPC state cleanup failed`, ); } throw error; } return { name, changed: true, outcome: "blocked", details: record.entries.map((entry) => entry.path), }; }); } enable(name: string): ActionResult { return withStateLock(this.options.statePath, () => { const state = loadState(this.options.statePath); const record = recordByName(state, name); if (!record) { return { name, changed: false, outcome: "not-blocked", details: [] }; } const snapshot = this.discover(); const conflicts = this.conflictDetails(record, snapshot); if (conflicts.length > 0) { return { name, changed: false, outcome: "conflict", details: conflicts }; } const mutations: ValueMutation[] = record.entries.map((entry) => ({ path: entry.path, format: entry.format, keyPath: entry.keyPath, operation: "set", value: structuredClone(entry.value), })); const changes = prepareFileChanges(mutations); const rollback = commitPreparedChanges(changes); const nextState: McpcState = { version: 1, records: state.records.filter((candidate) => candidate.name !== name), }; try { saveState(this.options.statePath, nextState); } catch (error) { try { rollback(); } catch (rollbackError) { throw new AggregateError( [error, rollbackError], `Failed to finalize enable for ${name}; config rollback was incomplete`, ); } throw error; } const inactiveImports = uniqueStrings( record.entries .filter((entry) => entry.importKind && !snapshot.activeImportKinds.has(entry.importKind)) .map((entry) => `restored ${entry.importKind} definition, but that import is not currently active`), ); return { name, changed: true, outcome: "enabled", details: [...uniqueStrings(record.entries.map((entry) => entry.path)), ...inactiveImports], }; }); } cleanup(name: string): ActionResult { return withStateLock(this.options.statePath, () => { const state = loadState(this.options.statePath); const record = recordByName(state, name); if (!record) return { name, changed: false, outcome: "not-blocked", details: [] }; const conflicts = this.conflictDetails(record, this.discover()); if (conflicts.length === 0) { return { name, changed: false, outcome: "not-stale", details: [] }; } saveState(this.options.statePath, { version: 1, records: state.records.filter((candidate) => candidate.name !== name), }); return { name, changed: true, outcome: "cleaned", details: conflicts }; }); } cleanupAllConflicts(): ActionResult[] { return withStateLock(this.options.statePath, () => { const state = loadState(this.options.statePath); const snapshot = this.discover(); const stale = state.records .map((record) => ({ record, conflicts: this.conflictDetails(record, snapshot) })) .filter(({ conflicts }) => conflicts.length > 0); if (stale.length === 0) return []; const staleNames = new Set(stale.map(({ record }) => record.name)); saveState(this.options.statePath, { version: 1, records: state.records.filter((record) => !staleNames.has(record.name)), }); return stale.map(({ record, conflicts }) => ({ name: record.name, changed: true, outcome: "cleaned" as const, details: conflicts, })); }); } }