diff --git a/README.md b/README.md index 770456e..da4dd57 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ agents = ["claude", "cursor", "codex", "opencode"] | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | -- | | `opencode` | `.opencode` | `opencode.json` | -- | `.opencode/agents/*.md` | +Install and sync repair currently declared MCP entries while preserving undeclared servers and unrelated content in every existing target file. When no servers are declared, existing MCP files remain unchanged because dotagents has no durable evidence that it owns them. Unreadable or structurally incompatible files are reported and left unchanged. + Custom subagents are declared with `[[subagents]]` entries. dotagents writes generated runtime-specific files during `install` and repairs them during `sync`: ```toml diff --git a/docs/public/llms.txt b/docs/public/llms.txt index de9261b..60ffc71 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -199,6 +199,8 @@ Config files generated per agent: - VS Code: `.vscode/mcp.json` (JSON) - OpenCode: `opencode.json` (JSON, shared) +Install and sync repair currently declared MCP entries while preserving undeclared servers and unrelated top-level content in every existing target file. When no servers are declared, existing MCP files remain unchanged because dotagents has no durable evidence that it owns them. Unreadable or structurally incompatible files are reported and left unchanged. Entries generated by an earlier declaration may therefore remain after that declaration is removed. + ### Hooks Each `[[hooks]]` entry requires `event` and `command`. Optional: `matcher` to filter by tool name. diff --git a/packages/dotagents/src/cli/commands/add.test.ts b/packages/dotagents/src/cli/commands/add.test.ts index 73191e7..604f08c 100644 --- a/packages/dotagents/src/cli/commands/add.test.ts +++ b/packages/dotagents/src/cli/commands/add.test.ts @@ -250,6 +250,7 @@ describe("runAdd", () => { installed: [], skipped: [], pruned: [], + mcpWarnings: [], hookWarnings: [], subagentWarnings: [], }); @@ -366,6 +367,7 @@ describe("runAdd", () => { installed: [], skipped: [], pruned: [], + mcpWarnings: [], hookWarnings: [], subagentWarnings: [], }); @@ -391,6 +393,7 @@ describe("runAdd", () => { installed: [], skipped: [], pruned: [], + mcpWarnings: [], hookWarnings: [], subagentWarnings: [], }); diff --git a/packages/dotagents/src/cli/commands/install-user.test.ts b/packages/dotagents/src/cli/commands/install-user.test.ts index aeb3a0f..1c4ee71 100644 --- a/packages/dotagents/src/cli/commands/install-user.test.ts +++ b/packages/dotagents/src/cli/commands/install-user.test.ts @@ -61,7 +61,18 @@ describe("runInstall user scope", () => { ]); await mkdir(sourceDir, { recursive: true }); + await mkdir(homeDir, { recursive: true }); await writeFile(join(sourceDir, "SKILL.md"), SKILL_MD); + await writeFile( + join(homeDir, ".claude.json"), + JSON.stringify({ + theme: "dark", + mcpServers: { + manual: { command: "manual" }, + fixture: { command: "old" }, + }, + }), + ); const scope = resolveScope("user"); await mkdir(scope.root, { recursive: true }); await writeFile( @@ -72,6 +83,11 @@ agents = ["claude"] [[skills]] name = "pdf" source = "path:skill-source/pdf" + +[[mcp]] +name = "fixture" +command = "node" +args = ["server.js"] `, ); @@ -86,6 +102,29 @@ source = "path:skill-source/pdf" expect(stat.isSymbolicLink()).toBe(true); expect(await readlink(skillsLink)).toBe(relative(join(homeDir, ".claude"), scope.skillsDir)); + expect(JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf-8"))).toEqual({ + theme: "dark", + mcpServers: { + manual: { command: "manual" }, + fixture: { command: "node", args: ["server.js"] }, + }, + }); + + const mcpPath = join(homeDir, ".claude.json"); + const beforeEmptyInstall = await readFile(mcpPath, "utf-8"); + await writeFile( + scope.configPath, + `version = 1 +agents = ["claude"] + +[[skills]] +name = "pdf" +source = "path:skill-source/pdf" +`, + ); + await runInstall({ scope }); + expect(await readFile(mcpPath, "utf-8")).toBe(beforeEmptyInstall); + const lockfile = await loadLockfile(scope.lockPath); expect(lockfile!.skills["pdf"]).toEqual({ source: "path:skill-source/pdf" }); }); diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 7edf1d9..5ae2567 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -136,16 +136,52 @@ describe("runInstall", () => { ); const scope = resolveScope("project", projectRoot); - await runInstall({ scope }); + const result = await runInstall({ scope }); const mcp = JSON.parse(await readFile(join(projectRoot, ".mcp.json"), "utf-8")); expect(mcp.mcpServers.github).toBeDefined(); + expect(result.mcpWarnings).toEqual([]); // Agent symlinks should also be created const stat = await lstat(join(projectRoot, ".claude", "skills")); expect(stat.isSymbolicLink()).toBe(true); }); + it("preserves a pre-existing MCP config when no servers are declared", async () => { + const configPath = join(projectRoot, "agents.toml"); + const mcpPath = join(projectRoot, ".mcp.json"); + const content = JSON.stringify({ + editor: "manual", + mcpServers: { manual: { command: "manual" } }, + }); + await writeFile(configPath, `version = 1\nagents = ["claude"]\n`); + await writeFile(mcpPath, content); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + expect(await readFile(mcpPath, "utf-8")).toBe(content); + }); + + it("warns without changing an incompatible MCP config", async () => { + const configPath = join(projectRoot, "agents.toml"); + const mcpPath = join(projectRoot, ".mcp.json"); + const content = '{"mcpServers":[]}\n'; + await writeFile( + configPath, + `version = 1\nagents = ["claude"]\n\n[[mcp]]\nname = "github"\ncommand = "github-mcp"\n`, + ); + await writeFile(mcpPath, content); + + const result = await runInstall({ scope: resolveScope("project", projectRoot) }); + + expect(result.mcpWarnings).toEqual([{ + agent: "claude", + message: `Failed to read MCP config: ${mcpPath}`, + }]); + expect(await readFile(mcpPath, "utf-8")).toBe(content); + }); + it("fails with --frozen when no lockfile exists", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index a83dc7b..6d8c7cb 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -34,6 +34,7 @@ export interface InstallResult { installed: string[]; skipped: string[]; pruned: string[]; + mcpWarnings: { agent: string; message: string }[]; hookWarnings: { agent: string; message: string }[]; subagentWarnings: { agent: string; name: string; message: string }[]; } @@ -65,7 +66,7 @@ export async function runInstall(opts: InstallOptions): Promise { subagents: subagents.subagents, }, frozen); await writeSkillSymlinks(config, scope); - await writeMcpRuntime(config, scope); + const mcpWarnings = await writeMcpRuntime(config, scope); const hookWarnings = await writeHookRuntime(config, scope); const subagentWarnings = await writeSubagentRuntime(config, scope, subagents.subagents, frozen); @@ -73,6 +74,7 @@ export async function runInstall(opts: InstallOptions): Promise { installed: skills.installed, skipped: [], pruned: skills.pruned, + mcpWarnings, hookWarnings, subagentWarnings, }; @@ -109,6 +111,9 @@ export default async function install(args: string[], flags?: { user?: boolean } chalk.yellow(`Pruned ${result.pruned.length} stale skill(s): ${result.pruned.join(", ")}`), ); } + for (const w of result.mcpWarnings) { + console.log(chalk.yellow(` warn: ${w.message}`)); + } for (const w of result.hookWarnings) { console.log(chalk.yellow(` warn: ${w.message}`)); } diff --git a/packages/dotagents/src/cli/commands/install/agent-runtime.ts b/packages/dotagents/src/cli/commands/install/agent-runtime.ts index a8f2c6a..ef3c6a1 100644 --- a/packages/dotagents/src/cli/commands/install/agent-runtime.ts +++ b/packages/dotagents/src/cli/commands/install/agent-runtime.ts @@ -2,7 +2,7 @@ import type { AgentsConfig } from "../../../config/schema.js"; import type { ScopeRoot } from "../../../scope.js"; import { ensureSkillsSymlink } from "../../../symlinks/manager.js"; import { skillSymlinkTargets } from "../../../targets/skill-symlinks.js"; -import { projectMcpResolver, toMcpDeclarations, writeMcpConfigs } from "../../../targets/mcp-writer.js"; +import { projectMcpResolver, reconcileMcpConfigs, toMcpDeclarations } from "../../../targets/mcp-writer.js"; import { projectHookResolver, toHookDeclarations, writeHookConfigs } from "../../../targets/hook-writer.js"; import { userMcpResolver } from "../../../targets/paths.js"; import { @@ -32,11 +32,17 @@ export async function writeSkillSymlinks( export async function writeMcpRuntime( config: AgentsConfig, scope: ScopeRoot, -): Promise { +): Promise<{ agent: string; message: string }[]> { const resolver = scope.scope === "user" ? userMcpResolver() : projectMcpResolver(scope.root); - await writeMcpConfigs(config.agents, toMcpDeclarations(config.mcp), resolver); + const result = await reconcileMcpConfigs( + config.agents, + toMcpDeclarations(config.mcp), + resolver, + "apply", + ); + return result.unresolved.map(({ agent, issue }) => ({ agent, message: issue })); } /** Writes project-scoped hook runtime config for configured agents. */ diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index dd46697..b1455e5 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -376,6 +376,75 @@ describe("runSync", () => { expect(existsSync(join(projectRoot, ".mcp.json"))).toBe(true); }); + it("repairs MCP transport drift under unchanged server names", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[mcp]] +name = "github" +command = "npx" +args = ["-y", "@mcp/server-github"] +env = ["GITHUB_TOKEN"] + +[[mcp]] +name = "remote" +url = "https://mcp.example.com/sse" +headers = { Authorization = "Bearer tok" } +`, + ); + const configPath = join(projectRoot, "agents.toml"); + const mcpPath = join(projectRoot, ".mcp.json"); + await writeFile(mcpPath, JSON.stringify({ + editor: "manual", + mcpServers: { + manual: { command: "manual" }, + github: { command: "old", args: ["old"], env: { GITHUB_TOKEN: "old" } }, + remote: { type: "http", url: "https://old.example.com", headers: { Authorization: "old" } }, + }, + })); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.mcpRepaired).toBe(1); + expect(result.issues.filter(({ type }) => type === "mcp")).toEqual([]); + expect(JSON.parse(await readFile(mcpPath, "utf-8"))).toEqual({ + editor: "manual", + mcpServers: { + manual: { command: "manual" }, + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + remote: { + type: "http", + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer tok" }, + }, + }, + }); + + await writeFile(configPath, `version = 1\nagents = ["claude"]\n`); + const beforeEmptySync = await readFile(mcpPath, "utf-8"); + const emptyResult = await runSync({ scope: resolveScope("project", projectRoot) }); + expect(emptyResult.mcpRepaired).toBe(0); + expect(await readFile(mcpPath, "utf-8")).toBe(beforeEmptySync); + + await writeFile(configPath, `version = 1\nagents = ["claude"]\n\n[[mcp]]\nname = "github"\ncommand = "npx"\n`); + const incompatible = '{"mcpServers":[]}\n'; + await writeFile(mcpPath, incompatible); + const incompatibleResult = await runSync({ scope: resolveScope("project", projectRoot) }); + expect(incompatibleResult.mcpRepaired).toBe(0); + expect(incompatibleResult.issues).toEqual([{ + type: "mcp", + name: "claude", + message: `Failed to read MCP config: ${mcpPath}`, + }]); + expect(await readFile(mcpPath, "utf-8")).toBe(incompatible); + }); + it("repairs agent-specific symlinks", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 6357434..735c712 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -11,7 +11,7 @@ import { addSkillToConfig } from "../../config/writer.js"; import { writeAgentsGitignore, checkRootGitignoreEntries } from "../../gitignore/writer.js"; import { ensureSkillsSymlink, verifySymlinks } from "../../symlinks/manager.js"; import { skillSymlinkTargets } from "../../targets/skill-symlinks.js"; -import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../targets/mcp-writer.js"; +import { reconcileMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../targets/mcp-writer.js"; import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../targets/hook-writer.js"; import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../subagents/writer.js"; import { loadInstalledSubagents, pruneInstalledSubagents } from "../../subagents/store.js"; @@ -180,11 +180,10 @@ export async function runSync(opts: SyncOptions): Promise { const mcpServers = toMcpDeclarations(config.mcp); const mcpResolver = scope.scope === "user" ? userMcpResolver() : projectMcpResolver(scope.root); - const mcpIssues = await verifyMcpConfigs(config.agents, mcpServers, mcpResolver); - if (mcpIssues.length > 0) { - await writeMcpConfigs(config.agents, mcpServers, mcpResolver); - mcpRepaired = mcpIssues.length; - for (const issue of mcpIssues) { + const mcpResult = await reconcileMcpConfigs(config.agents, mcpServers, mcpResolver, "apply"); + mcpRepaired = mcpResult.written.length; + if (mcpResult.unresolved.length > 0) { + for (const issue of mcpResult.unresolved) { issues.push({ type: "mcp", name: issue.agent, diff --git a/packages/dotagents/src/targets/mcp-writer.test.ts b/packages/dotagents/src/targets/mcp-writer.test.ts index 4d4c48a..0b4a2c0 100644 --- a/packages/dotagents/src/targets/mcp-writer.test.ts +++ b/packages/dotagents/src/targets/mcp-writer.test.ts @@ -1,10 +1,15 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { existsSync } from "node:fs"; import { parse as parseTOML } from "smol-toml"; -import { writeMcpConfigs, verifyMcpConfigs, projectMcpResolver } from "./mcp-writer.js"; +import { + projectMcpResolver, + reconcileMcpConfigs, + verifyMcpConfigs, + writeMcpConfigs, +} from "./mcp-writer.js"; import type { McpDeclaration } from "./types.js"; const STDIO_SERVER: McpDeclaration = { @@ -38,8 +43,13 @@ describe("writeMcpConfigs", () => { }); it("skips when no servers declared", async () => { + const filePath = join(dir, ".mcp.json"); + const existing = JSON.stringify({ mcpServers: { manual: { command: "manual" } } }); + await writeFile(filePath, existing); + await writeMcpConfigs(["claude"], [], projectMcpResolver(dir)); - expect(existsSync(join(dir, ".mcp.json"))).toBe(false); + + expect(await readFile(filePath, "utf-8")).toBe(existing); }); it("writes claude .mcp.json", async () => { @@ -293,8 +303,141 @@ describe("verifyMcpConfigs", () => { expect(issues.some((i) => i.issue.includes("remote"))).toBe(true); }); + it.each([ + ["non-object document", "null\n"], + ["non-object MCP root", '{"mcpServers": []}\n'], + ])("reports a %s without changing it", async (_description, content) => { + const filePath = join(dir, ".mcp.json"); + await writeFile(filePath, content); + + const inspected = await reconcileMcpConfigs( + ["claude"], + [STDIO_SERVER], + projectMcpResolver(dir), + "inspect", + ); + expect(inspected.issues).toEqual([ + expect.objectContaining({ issue: expect.stringContaining("Failed to read") }), + ]); + expect(await readFile(filePath, "utf-8")).toBe(content); + + const applied = await reconcileMcpConfigs( + ["claude"], + [STDIO_SERVER], + projectMcpResolver(dir), + "apply", + ); + expect(applied.written).toEqual([]); + expect(await readFile(filePath, "utf-8")).toBe(content); + }); + it("returns empty when no servers declared", async () => { + await writeFile( + join(dir, ".mcp.json"), + JSON.stringify({ mcpServers: { manual: { command: "manual" } } }), + ); + const issues = await verifyMcpConfigs(["claude"], [], projectMcpResolver(dir)); + expect(issues).toEqual([]); }); + + it("repairs declared transport drift while preserving external content", async () => { + const filePath = join(dir, ".mcp.json"); + const expected = { + editor: "manual", + mcpServers: { + manual: { command: "manual" }, + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + remote: { + type: "http", + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer tok" }, + }, + }, + }; + await writeFile(filePath, JSON.stringify({ + editor: "manual", + mcpServers: { + manual: { command: "manual" }, + github: { command: "old", args: ["old"], env: { GITHUB_TOKEN: "old" } }, + remote: { type: "http", url: "https://old.example.com", headers: { Authorization: "old" } }, + }, + })); + + const resolver = projectMcpResolver(dir); + const inspected = await reconcileMcpConfigs( + ["claude"], + [STDIO_SERVER, HTTP_SERVER], + resolver, + "inspect", + ); + expect(inspected.issues.map((issue) => issue.issue)).toEqual([ + expect.stringContaining('"github" drifted'), + expect.stringContaining('"remote" drifted'), + ]); + + const applied = await reconcileMcpConfigs( + ["claude"], + [STDIO_SERVER, HTTP_SERVER], + resolver, + "apply", + ); + expect(applied.written).toEqual([filePath]); + expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual(expected); + + await writeFile(filePath, JSON.stringify({ + ...expected, + mcpServers: { ...expected.mcpServers, stale: { command: "stale" } }, + })); + const stale = await reconcileMcpConfigs( + ["claude"], + [STDIO_SERVER, HTTP_SERVER], + resolver, + "apply", + ); + expect(stale).toEqual({ issues: [], unresolved: [], written: [] }); + expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ + ...expected, + mcpServers: { ...expected.mcpServers, stale: { command: "stale" } }, + }); + }); + + it.each([ + { + agent: "cursor", + relativePath: join(".cursor", "mcp.json"), + content: JSON.stringify({ mcpServers: { manual: { command: "manual" } } }), + }, + { + agent: "opencode", + relativePath: "opencode.json", + content: JSON.stringify({ + theme: "dark", + mcp: { manual: { type: "local", command: ["manual"] } }, + }, null, 2), + }, + ])("leaves $agent files unchanged when no servers are desired", async ({ + agent, + relativePath, + content, + }) => { + const filePath = join(dir, relativePath); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + + const result = await reconcileMcpConfigs( + [agent], + [], + projectMcpResolver(dir), + "apply", + ); + + expect(result).toEqual({ issues: [], unresolved: [], written: [] }); + expect(await readFile(filePath, "utf-8")).toBe(content); + }); }); diff --git a/packages/dotagents/src/targets/mcp-writer.ts b/packages/dotagents/src/targets/mcp-writer.ts index 27ec57c..2d458c9 100644 --- a/packages/dotagents/src/targets/mcp-writer.ts +++ b/packages/dotagents/src/targets/mcp-writer.ts @@ -1,9 +1,14 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { join, dirname } from "node:path"; import { existsSync } from "node:fs"; +import { isDeepStrictEqual } from "node:util"; import { stringify as tomlStringify, parse as parseTOML } from "smol-toml"; import { getAgent } from "./registry.js"; -import type { McpDeclaration, McpConfigSpec } from "./types.js"; +import type { + McpDeclaration, + McpConfigSpec, + NormalizedMcpDeclaration, +} from "./types.js"; import type { McpConfig } from "../config/schema.js"; export interface McpResolvedTarget { @@ -13,18 +18,22 @@ export interface McpResolvedTarget { export type McpTargetResolver = (agentId: string, spec: McpConfigSpec) => McpResolvedTarget; +export interface McpReconcileIssue { + agent: string; + issue: string; +} + +export interface McpReconcileResult { + issues: McpReconcileIssue[]; + unresolved: McpReconcileIssue[]; + written: string[]; +} + /** * Convert McpConfig entries (from agents.toml) to universal McpDeclarations. */ -export function toMcpDeclarations(configs: McpConfig[]): McpDeclaration[] { - return configs.map((m) => ({ - name: m.name, - ...(m.command && { command: m.command }), - ...(m.args && { args: m.args }), - ...(m.url && { url: m.url }), - ...(m.headers && { headers: m.headers }), - ...(m.env.length > 0 && { env: m.env }), - })); +export function toMcpDeclarations(configs: McpConfig[]): NormalizedMcpDeclaration[] { + return configs.map(normalizeMcpDeclaration); } /** @@ -39,8 +48,8 @@ export function projectMcpResolver(projectRoot: string): McpTargetResolver { /** * Write MCP config files for each agent. - * - Dedicated files (shared=false): generated from scratch. - * - Shared files (shared=true): read existing and merge dotagents servers under the root key. + * - Existing files preserve undeclared servers and unrelated top-level content. + * - Missing files are created only when servers are declared. * - Files are only written when the serialized output changes. */ export async function writeMcpConfigs( @@ -48,49 +57,35 @@ export async function writeMcpConfigs( servers: McpDeclaration[], resolveTarget: McpTargetResolver, ): Promise { - if (servers.length === 0) {return;} - - // Deduplicate by resolved filePath so shared files aren't written twice - const seen = new Set(); - - for (const id of agentIds) { - const agent = getAgent(id); - if (!agent) {continue;} - - const { mcp } = agent; - const { filePath, shared } = resolveTarget(id, mcp); - if (seen.has(filePath)) {continue;} - seen.add(filePath); - - const serialized: Record = {}; - for (const server of servers) { - const [name, config] = agent.serializeServer(server); - serialized[name] = config; - } - - await mkdir(dirname(filePath), { recursive: true }); - - if (shared) { - await mergeWrite(filePath, mcp, serialized); - } else { - await freshWrite(filePath, mcp, serialized); - } - } + await reconcileMcpConfigs(agentIds, servers, resolveTarget, "apply"); } -/** - * Verify MCP configs exist and contain the expected servers. - * Returns a list of issues found. - */ +/** Inspect MCP configs for semantic drift without applying repairs. */ export async function verifyMcpConfigs( agentIds: string[], servers: McpDeclaration[], resolveTarget: McpTargetResolver, ): Promise<{ agent: string; issue: string }[]> { - if (servers.length === 0) {return [];} + return (await reconcileMcpConfigs(agentIds, servers, resolveTarget, "inspect")).issues; +} - const issues: { agent: string; issue: string }[] = []; +/** + * Inspect or apply currently declared MCP names while preserving all other + * content in existing target files. + * Returned issues describe the drift observed before any repair. + */ +export async function reconcileMcpConfigs( + agentIds: string[], + servers: McpDeclaration[], + resolveTarget: McpTargetResolver, + mode: "inspect" | "apply", +): Promise { + const issues: McpReconcileIssue[] = []; + const unresolved: McpReconcileIssue[] = []; + const written: string[] = []; const seen = new Set(); + const normalized = servers.map(normalizeMcpDeclaration); + if (normalized.length === 0) {return { issues, unresolved, written };} for (const id of agentIds) { const agent = getAgent(id); @@ -101,49 +96,117 @@ export async function verifyMcpConfigs( if (seen.has(filePath)) {continue;} seen.add(filePath); + const expectedServers = renderServers(agent.serializeServer, normalized); + const expected = { [mcp.rootKey]: expectedServers }; + if (!existsSync(filePath)) { issues.push({ agent: id, issue: `MCP config missing: ${filePath}` }); + if (mode === "apply") { + await writeDocument(filePath, mcp, expected); + written.push(filePath); + } continue; } - // Verify content has the expected servers - const expectedNames = servers.map((s) => s.name); + let existing: Record; + let existingServers: Record; try { - const existing = await readExisting(filePath, mcp); - const existingServers = existing[mcp.rootKey] as Record | undefined; - for (const name of expectedNames) { - if (!existingServers || !(name in existingServers)) { - issues.push({ agent: id, issue: `MCP server "${name}" missing from ${filePath}` }); - } - } + existing = await readExisting(filePath, mcp); + existingServers = readServerRoot(existing, mcp.rootKey, filePath); } catch { - issues.push({ agent: id, issue: `Failed to read MCP config: ${filePath}` }); + const issue = { agent: id, issue: `Failed to read MCP config: ${filePath}` }; + issues.push(issue); + unresolved.push(issue); + // Without a mergeable document, overwriting could destroy external config. + continue; + } + + const targetIssues = desiredIssues(id, filePath, existingServers, expectedServers); + issues.push(...targetIssues); + + if (mode === "apply" && targetIssues.length > 0) { + const next = { + ...existing, + [mcp.rootKey]: { ...existingServers, ...expectedServers }, + }; + await writeDocument(filePath, mcp, next); + written.push(filePath); } } - return issues; + return { issues, unresolved, written }; } // --- Internal helpers --- -async function freshWrite( +function normalizeMcpDeclaration(mcp: McpDeclaration): NormalizedMcpDeclaration { + if (mcp.url) { + return { + name: mcp.name, + url: mcp.url, + ...(mcp.headers && { headers: mcp.headers }), + ...(mcp.env?.length && { env: mcp.env }), + }; + } + if (!mcp.command) { + throw new TypeError(`MCP declaration "${mcp.name}" has no transport`); + } + return { + name: mcp.name, + command: mcp.command, + ...(mcp.args && { args: mcp.args }), + ...(mcp.env?.length && { env: mcp.env }), + }; +} + +function renderServers( + serializeServer: (server: McpDeclaration) => [string, unknown], + servers: NormalizedMcpDeclaration[], +): Record { + return Object.fromEntries(servers.map(serializeServer)); +} + +function desiredIssues( + agent: string, filePath: string, - spec: McpConfigSpec, - servers: Record, -): Promise { - const doc = { [spec.rootKey]: servers }; - await writeFileIfChanged(filePath, serialize(doc, spec.format)); + existing: Record, + expected: Record, +): McpReconcileIssue[] { + return Object.entries(expected).flatMap(([name, value]) => { + if (!(name in existing)) { + return [{ agent, issue: `MCP server "${name}" missing from ${filePath}` }]; + } + if (!isDeepStrictEqual(existing[name], value)) { + return [{ agent, issue: `MCP server "${name}" drifted in ${filePath}` }]; + } + return []; + }); } -async function mergeWrite( +function readServerRoot( + document: Record, + rootKey: string, + filePath: string, +): Record { + const root = document[rootKey]; + if (root === undefined) {return {};} + if (!isRecord(root)) { + throw new TypeError(`MCP config root must contain an object: ${filePath}`); + } + return root; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +async function writeDocument( filePath: string, spec: McpConfigSpec, - servers: Record, + doc: Record, ): Promise { - const existing = existsSync(filePath) ? await readExisting(filePath, spec) : {}; - const prev = (existing[spec.rootKey] ?? {}) as Record; - existing[spec.rootKey] = { ...prev, ...servers }; - await writeFileIfChanged(filePath, serialize(existing, spec.format)); + await mkdir(dirname(filePath), { recursive: true }); + await writeFileIfChanged(filePath, serialize(doc, spec.format)); } async function readExisting( @@ -151,10 +214,11 @@ async function readExisting( spec: McpConfigSpec, ): Promise> { const raw = await readFile(filePath, "utf-8"); - if (spec.format === "toml") { - return parseTOML(raw) as Record; + const parsed: unknown = spec.format === "toml" ? parseTOML(raw) : JSON.parse(raw); + if (!isRecord(parsed)) { + throw new TypeError(`MCP config must contain an object: ${filePath}`); } - return JSON.parse(raw) as Record; + return parsed; } function serialize(doc: Record, format: "json" | "toml"): string { diff --git a/packages/dotagents/src/targets/types.ts b/packages/dotagents/src/targets/types.ts index bef9c70..ce6d2d0 100644 --- a/packages/dotagents/src/targets/types.ts +++ b/packages/dotagents/src/targets/types.ts @@ -17,6 +17,24 @@ export interface McpDeclaration { env?: string[]; } +interface McpDeclarationBase { + name: string; + env?: string[]; +} + +interface StdioMcpDeclaration extends McpDeclarationBase { + command: string; + args?: string[]; +} + +interface HttpMcpDeclaration extends McpDeclarationBase { + url: string; + headers?: Record; +} + +/** Validated internal form used while rendering MCP target configs. */ +export type NormalizedMcpDeclaration = StdioMcpDeclaration | HttpMcpDeclaration; + /** * Describes how an agent tool writes its MCP config file. */ @@ -29,7 +47,8 @@ export interface McpConfigSpec { format: "json" | "toml"; /** * If true, the config file is shared with other content and must be - * read-merge-written. If false, dotagents owns the entire file. + * read-merge-written. This describes file shape, not dotagents ownership; + * undeclared MCP servers are preserved for every target. */ shared: boolean; } diff --git a/specs/SPEC.md b/specs/SPEC.md index f44ff7b..7294a0b 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -243,6 +243,8 @@ Generated paths: Each agent has its own MCP config format. dotagents translates the universal `[[mcp]]` declarations into the format each tool expects during `install` and `sync`. +Install and sync repair currently declared MCP entries while preserving undeclared servers and unrelated top-level content in every existing target file. When no servers are declared, existing MCP files remain unchanged because dotagents has no durable evidence that it owns them. Unreadable or structurally incompatible files are reported and left unchanged. Entries generated by an earlier declaration may therefore remain after that declaration is removed. + ### Source Types The source format is inferred from the value. Shorthand `owner/repo` resolves using `defaultRepositorySource` (default: GitHub).