From 056e9db553c9b6a5c15c6addc3c31b2edf2958b1 Mon Sep 17 00:00:00 2001 From: Misha Milovidov <42074547+mishamilovidov@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:09:49 -0700 Subject: [PATCH 1/3] feat: add github copilot target Add user and project MCP configuration for GitHub Copilot CLI and Desktop while preserving unmanaged native entries. Reuse Claude-compatible project serialization and protect the user MCP file with mode 0600 on POSIX systems. --- README.md | 7 +- docs/public/llms.txt | 14 +- docs/src/content/docs/cli.mdx | 4 +- docs/src/content/docs/index.mdx | 5 +- .../src/cli/commands/install-user.test.ts | 75 ++++++++++- .../src/cli/commands/install.test.ts | 15 ++- .../src/targets/definitions/copilot.ts | 27 ++++ .../dotagents/src/targets/mcp-writer.test.ts | 123 +++++++++++++++++- packages/dotagents/src/targets/mcp-writer.ts | 75 ++++++++--- packages/dotagents/src/targets/paths.test.ts | 43 ++++++ packages/dotagents/src/targets/paths.ts | 7 + .../dotagents/src/targets/registry.test.ts | 1 + packages/dotagents/src/targets/registry.ts | 3 +- .../src/targets/skill-symlinks.test.ts | 2 +- specs/SPEC.md | 10 +- 15 files changed, 373 insertions(+), 38 deletions(-) create mode 100644 packages/dotagents/src/targets/definitions/copilot.ts diff --git a/README.md b/README.md index e353caf4..7eb3e40d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Shared tooling for coding agents. Declare skills, MCP servers, hooks, subagents, **Shareable.** Skills are directories with a `SKILL.md`. Host them in any git repo, discover them automatically, install with one command. -**Multi-agent.** Configure Claude, Cursor, Codex, Grok, VS Code, and OpenCode from a single `agents.toml` -- skills, MCP servers, hooks, subagents, and plugins where supported. Pi reads `.agents/skills/` directly. +**Multi-agent.** Configure Claude, Cursor, Codex, GitHub Copilot CLI/Desktop, Grok, VS Code, and OpenCode from a single `agents.toml` -- skills, MCP servers, hooks, subagents, and plugins where supported. Pi reads `.agents/skills/` directly. ## Quick Start: Global by Default @@ -115,7 +115,7 @@ Shorthand (`owner/repo`) resolves to GitHub by default. Set `defaultRepositorySo The `agents` field tells dotagents which tools to configure: ```toml -agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +agents = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] ``` | Agent | Config Dir | MCP Config | Hooks | Subagents | @@ -126,6 +126,7 @@ agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] | `grok` | `.grok` | -- | -- | -- | | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | -- | | `opencode` | `.opencode` | `.opencode/opencode.jsonc` | -- | `.opencode/agents/*.md` | +| `copilot` | `.copilot` | `.mcp.json` or `.github/mcp.json` | -- | -- | Custom subagents are declared with `[[subagents]]` entries. dotagents writes generated runtime-specific files during `install` and repairs them during `sync`: @@ -153,6 +154,8 @@ dotagents can also import native runtime subagent files from `.claude/agents/`, OpenCode reuses an existing project config from `.opencode/opencode.jsonc`, `.opencode/opencode.json`, `opencode.jsonc`, or `opencode.json`, in that order. New projects use `.opencode/opencode.jsonc`. +GitHub Copilot reads project skills from `.agents/skills/` without a symlink. New project MCP configuration uses `.mcp.json`. An existing `.github/mcp.json` is reused when `.mcp.json` is absent. Global MCP configuration uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs the global file with mode `0600`, which matches Copilot CLI. + Plugins are declared with `[[plugins]]` entries. In project scope, dotagents installs canonical bundles into `.agents/plugins//` and generates runtime plugin outputs such as `.claude-plugin/marketplace.json`, `.agents/plugins//.claude-plugin/plugin.json`, `.cursor-plugin/marketplace.json`, `.agents/plugins//.cursor-plugin/plugin.json`, `.agents/plugins/marketplace.json`, `.agents/plugins//.codex-plugin/plugin.json`, `.grok/plugins//`, `.opencode/skills//`, OpenCode MCP entries, and Pi skill links under `.agents/skills//` where supported. During legacy migration, generalized bundles can also project Markdown agents into `.opencode/agents/`; standard extension agents are preserved but are not projected yet: ```toml diff --git a/docs/public/llms.txt b/docs/public/llms.txt index b3cf0673..c92e292a 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -2,7 +2,7 @@ > Shared tooling for coding agents -dotagents manages agent skills, MCP servers, hooks, subagents, and plugins declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code, Cursor, Codex, Grok, VS Code, and OpenCode are configured from a single source of truth. +dotagents manages agent skills, MCP servers, hooks, subagents, and plugins declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code, Cursor, Codex, GitHub Copilot CLI/Desktop, Grok, VS Code, and OpenCode are configured from a single source of truth. Install: `npm install -g @sentry/dotagents` Run without installing: `npx @sentry/dotagents ` @@ -66,7 +66,7 @@ Full example with all sections: ```toml version = 1 -agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +agents = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] minimum_release_age = 60 minimum_release_age_exclude = ["getsentry/*"] @@ -158,7 +158,7 @@ targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] |-------|------|----------|---------|-------------| | `version` | integer | Yes | -- | Schema version. Always `1`. | | `defaultRepositorySource` | string | No | `github` | Host used for shorthand `owner/repo` skill sources. Valid values: `github`, `gitlab`. | -| `agents` | string[] | No | `[]` | Agent tool IDs: `claude`, `cursor`, `codex`, `grok`, `vscode`, `opencode`, `pi`. Creates symlinks and config files for each where supported. `grok` and `pi` are plugin-only targets. | +| `agents` | string[] | No | `[]` | Agent tool IDs: `claude`, `cursor`, `codex`, `copilot`, `grok`, `vscode`, `opencode`, `pi`. Creates symlinks and config files for each where supported. `grok` and `pi` are plugin-only targets. | | `subagents` | table[] | No | `[]` | Custom subagent declarations. Generates runtime-specific files for Claude, Cursor, Codex, and OpenCode. | | `plugins` | table[] | No | `[]` | Plugin declarations. Installs canonical bundles into `.agents/plugins/` and generates runtime plugin outputs for Claude, Cursor, Codex, Grok, OpenCode, and Pi skill projection where supported. | | `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install. | @@ -211,7 +211,7 @@ Each `[[mcp]]` entry requires `name` and either `command` (stdio) or `url` (HTTP | `headers` | table | No | HTTP headers (url servers only). Supports `${VAR}` syntax for env var interpolation. | | `env` | string[] | No | Environment variable names to pass through | -Use `${VAR}` in header values and `url` to reference secrets from the environment. Write `${VAR}` in `agents.toml` — dotagents translates it to each agent's native syntax when generating config files. Claude keeps `${VAR}`, Cursor and VS Code use `${env:VAR}`, OpenCode uses `{env:VAR}`, and Codex splits pure refs into a separate `env_http_headers` field (mixed values like `"Bearer ${TOKEN}"` stay as literals). +Use `${VAR}` in header values and `url` to reference secrets from the environment. Write `${VAR}` in `agents.toml`. Dotagents keeps this syntax for Claude and GitHub Copilot. Cursor and VS Code use `${env:VAR}`, OpenCode uses `{env:VAR}`, and Codex moves pure references to `env_http_headers`. Mixed Codex values such as `"Bearer ${TOKEN}"` stay as literals. Config files generated per agent: - Claude: `.mcp.json` (JSON) @@ -219,6 +219,9 @@ Config files generated per agent: - Codex: `.codex/config.toml` (TOML, shared with other Codex config) - VS Code: `.vscode/mcp.json` (JSON) - OpenCode: `.opencode/opencode.jsonc` by default (JSONC, shared). Existing `.opencode/opencode.json`, `opencode.jsonc`, or `opencode.json` files are reused in precedence order. +- GitHub Copilot: `.mcp.json` by default (JSON). An existing `.github/mcp.json` is reused when `.mcp.json` is absent. + +GitHub Copilot accepts the same implicit stdio shape as Claude, so both targets produce stable shared `.mcp.json` output. In user scope, GitHub Copilot uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs this file with mode `0600`. ### Hooks @@ -501,8 +504,9 @@ Check selected-scope health: gitignore setup where applicable, installed skills | `codex` | Codex | `.codex` | (reads `.agents/skills/` natively) | `.codex/config.toml` | Not supported | `.codex/agents/*.toml` | | `vscode` | VS Code Copilot | `.vscode` | (reads `.agents/skills/` natively) | `.vscode/mcp.json` | `.claude/settings.json` | Not supported | | `opencode` | OpenCode | `.opencode` | (reads `.agents/skills/` natively) | `.opencode/opencode.jsonc` by default | Not supported | `.opencode/agents/*.md` | +| `copilot` | GitHub Copilot CLI/Desktop | `.copilot` | (reads `.agents/skills/` natively) | `.mcp.json` or `.github/mcp.json` | Not supported | Not supported | -Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, and OpenCode read `.agents/skills/` directly. +Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, OpenCode, and GitHub Copilot read `.agents/skills/` directly. [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. Normal skills need no Pi-specific target or symlink configuration; plugin bundles can target `pi` when their `skills/` components should be exposed there. diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index 5a645107..9305ec53 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -43,7 +43,7 @@ agents can discover CLI guidance. It then runs `install` best-effort. Options: - `--agents ` comma-separated agent targets (`claude`, `cursor`, `codex`, - `vscode`, `opencode`, `grok`, `pi`). + `copilot`, `vscode`, `opencode`, `grok`, `pi`). - `--force` overwrites existing `agents.toml`. Examples: @@ -369,7 +369,7 @@ Status output: | Field | Type | Default | Description | | --- | --- | --- | --- | | `version` | integer | -- | Schema version. Always `1`. | -| `agents` | string[] | `[]` | Agent targets: `claude`, `cursor`, `codex`, `grok`, `vscode`, `opencode`, `pi`. `grok` and `pi` are plugin-only targets. | +| `agents` | string[] | `[]` | Agent targets: `claude`, `cursor`, `codex`, `copilot`, `grok`, `vscode`, `opencode`, `pi`. `grok` and `pi` are plugin-only targets. | | `subagents` | table[] | `[]` | Custom subagent declarations for Claude, Cursor, Codex, and OpenCode | | `plugins` | table[] | `[]` | Plugin declarations for Claude, Cursor, Codex, Grok, OpenCode, and Pi. | | `minimum_release_age` | integer | -- | Minimum commit age, in minutes, before a git skill, subagent, or plugin can install. | diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 3d620f22..4f78ed67 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -97,7 +97,7 @@ targets = ["claude", "cursor", "codex", "opencode"]`}

Supported Agents

-

The `agents` array tells dotagents which tools to configure. Grok and Pi are plugin-only targets; Pi also reads `.agents/skills/` directly.

+

The `agents` array tells dotagents which tools to configure. Grok and Pi are plugin-only targets. Copilot and Pi read `.agents/skills/` directly.

| Agent | Config Dir | MCP Config | Hooks | Subagents | @@ -105,9 +105,12 @@ targets = ["claude", "cursor", "codex", "opencode"]`} | `claude` | `.claude` | `.mcp.json` | `.claude/settings.json` | `.claude/agents/*.md` | | `cursor` | `.cursor` | `.cursor/mcp.json` | `.cursor/hooks.json` | `.cursor/agents/*.md` | | `codex` | `.codex` | `.codex/config.toml` | None | `.codex/agents/*.toml` | + | `copilot` | `.copilot` | `.mcp.json` | None | None | | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | None | | `opencode` | `.opencode` | `.opencode/opencode.jsonc` | None | `.opencode/agents/*.md` | + GitHub Copilot reads user MCP configuration from `~/.copilot/mcp-config.json`. Projects use `.mcp.json`, or reuse `.github/mcp.json` when it exists. + OpenCode reuses existing project config from `.opencode/opencode.jsonc`, `.opencode/opencode.json`, `opencode.jsonc`, or `opencode.json`, in that order. New projects use `.opencode/opencode.jsonc`.
diff --git a/packages/dotagents/src/cli/commands/install-user.test.ts b/packages/dotagents/src/cli/commands/install-user.test.ts index 1c4ee71f..09c787a5 100644 --- a/packages/dotagents/src/cli/commands/install-user.test.ts +++ b/packages/dotagents/src/cli/commands/install-user.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, vi } from "vitest"; -import { mkdtemp, mkdir, readFile, readlink, rm, writeFile, lstat } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readlink, rm, writeFile, lstat, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; @@ -17,6 +17,7 @@ describe("runInstall user scope", () => { const previousHome = process.env["HOME"]; const previousDotagentsHome = process.env["DOTAGENTS_HOME"]; const previousStateDir = process.env["DOTAGENTS_STATE_DIR"]; + const previousCopilotHome = process.env["COPILOT_HOME"]; afterEach(async () => { if (previousHome === undefined) { @@ -34,6 +35,11 @@ describe("runInstall user scope", () => { } else { process.env["DOTAGENTS_STATE_DIR"] = previousStateDir; } + if (previousCopilotHome === undefined) { + delete process.env["COPILOT_HOME"]; + } else { + process.env["COPILOT_HOME"] = previousCopilotHome; + } vi.resetModules(); if (tmpDir) { @@ -128,4 +134,71 @@ source = "path:skill-source/pdf" const lockfile = await loadLockfile(scope.lockPath); expect(lockfile!.skills["pdf"]).toEqual({ source: "path:skill-source/pdf" }); }); + + it("writes copilot MCP config without a skill symlink", async () => { + tmpDir = await mkdtemp(join(tmpdir(), "dotagents-user-copilot-")); + const homeDir = join(tmpDir, "home"); + const dotagentsHome = join(tmpDir, "agents"); + const stateDir = join(tmpDir, "state"); + const copilotHome = join(tmpDir, "copilot"); + const sourceDir = join(dotagentsHome, "skill-source", "pdf"); + + process.env["HOME"] = homeDir; + process.env["DOTAGENTS_HOME"] = dotagentsHome; + process.env["DOTAGENTS_STATE_DIR"] = stateDir; + process.env["COPILOT_HOME"] = copilotHome; + vi.resetModules(); + + const [{ runInstall }, { resolveScope }] = await Promise.all([ + import("./install.js"), + import("../../scope.js"), + ]); + + await mkdir(sourceDir, { recursive: true }); + await mkdir(copilotHome, { recursive: true }); + await writeFile(join(sourceDir, "SKILL.md"), SKILL_MD); + await writeFile( + join(copilotHome, "mcp-config.json"), + JSON.stringify({ + note: "keep", + mcpServers: { + manual: { command: "manual", args: [] }, + fixture: { command: "old", args: [] }, + }, + }), + ); + + const scope = resolveScope("user"); + await mkdir(scope.root, { recursive: true }); + await writeFile( + scope.configPath, + `version = 1 +agents = ["copilot"] + +[[skills]] +name = "pdf" +source = "path:skill-source/pdf" + +[[mcp]] +name = "fixture" +command = "node" +args = ["server.js"] +`, + ); + + await runInstall({ scope }); + + expect(existsSync(join(scope.skillsDir, "pdf", "SKILL.md"))).toBe(true); + expect(existsSync(join(copilotHome, "skills"))).toBe(false); + expect(JSON.parse(await readFile(join(copilotHome, "mcp-config.json"), "utf-8"))).toEqual({ + note: "keep", + mcpServers: { + manual: { command: "manual", args: [] }, + fixture: { command: "node", args: ["server.js"] }, + }, + }); + if (process.platform !== "win32") { + expect((await stat(join(copilotHome, "mcp-config.json"))).mode & 0o777).toBe(0o600); + } + }); }); diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 86f94823..e309cd9a 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -1087,8 +1087,14 @@ source = "path:./.agents/plugins/local-tools/source" const userHome = join(tmpDir, "home"); process.env["DOTAGENTS_HOME"] = dotagentsHome; process.env["HOME"] = userHome; + vi.resetModules(); try { - const scope = resolveScope("user"); + const [{ runInstall: runUserInstall }, { resolveScope: resolveUserScope }] = + await Promise.all([ + import("./install.js"), + import("../../scope.js"), + ]); + const scope = resolveUserScope("user"); const sourceDir = join(scope.root, "plugin-source", "review-tools"); await mkdir(join(sourceDir, "skills", "review"), { recursive: true }); await writeFile( @@ -1120,12 +1126,16 @@ source = "path:plugin-source/review-tools" `, ); - const result = await runInstall({ scope }); + const result = await runUserInstall({ scope }); expect(result.installedPlugins).toEqual(["review-tools"]); expect(existsSync(join(scope.pluginsDir, "review-tools", "plugin.json"))).toBe(true); expect(existsSync(join(scope.root, ".claude-plugin", "marketplace.json"))).toBe(true); expect(existsSync(join(scope.root, ".agents", "plugins", "marketplace.json"))).toBe(true); expect(await readlink(join(scope.skillsDir, "review"))).toBe("../plugins/review-tools/skills/review"); + expect((await lstat(join(userHome, ".claude", "skills"))).isSymbolicLink()).toBe(true); + expect(await readlink(join(userHome, ".claude", "skills"))).toBe( + relative(join(userHome, ".claude"), scope.skillsDir), + ); expect(await readlink(join(userHome, ".config", "opencode", "skills", "review"))).toContain( join("user-agents", "plugins", "review-tools", "skills", "review"), ); @@ -1158,6 +1168,7 @@ source = "path:plugin-source/review-tools" } else { process.env["HOME"] = previousOsHome; } + vi.resetModules(); } }); diff --git a/packages/dotagents/src/targets/definitions/copilot.ts b/packages/dotagents/src/targets/definitions/copilot.ts new file mode 100644 index 00000000..e41ab360 --- /dev/null +++ b/packages/dotagents/src/targets/definitions/copilot.ts @@ -0,0 +1,27 @@ +import type { AgentDefinition } from "../types.js"; +import { UnsupportedFeature } from "../errors.js"; +import claude from "./claude.js"; + +const copilot: AgentDefinition = { + id: "copilot", + displayName: "GitHub Copilot", + configDir: ".copilot", + // reads .agents/skills/ natively at both project and user scope + skillsParentDir: undefined, + userSkillsParentDirs: undefined, + mcp: { + filePath: ".mcp.json", + fallbackFilePaths: [".github/mcp.json"], + rootKey: "mcpServers", + format: "json", + shared: false, + }, + // Copilot accepts Claude's MCP shape, and both clients can share project .mcp.json. + serializeServer: claude.serializeServer, + hooks: undefined, + serializeHooks() { + throw new UnsupportedFeature("copilot", "hooks"); + }, +}; + +export default copilot; diff --git a/packages/dotagents/src/targets/mcp-writer.test.ts b/packages/dotagents/src/targets/mcp-writer.test.ts index 0b977bb3..797b1cba 100644 --- a/packages/dotagents/src/targets/mcp-writer.test.ts +++ b/packages/dotagents/src/targets/mcp-writer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from "node:fs/promises"; +import { chmod, mkdtemp, mkdir, readFile, writeFile, rm, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { existsSync } from "node:fs"; @@ -93,6 +93,17 @@ describe("writeMcpConfigs", () => { expect(raw).toContain("github"); }); + it("writes copilot .mcp.json with environment references", async () => { + await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); + + const content = JSON.parse(await readFile(join(dir, ".mcp.json"), "utf-8")); + expect(content.mcpServers.github).toEqual({ + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }); + }); + it("writes .opencode/opencode.jsonc by default", async () => { await writeMcpConfigs(["opencode"], [STDIO_SERVER], projectMcpResolver(dir)); @@ -221,6 +232,69 @@ describe("writeMcpConfigs", () => { expect(existsSync(join(dir, ".opencode", "opencode.jsonc"))).toBe(false); }); + it("reuses .github/mcp.json for copilot when .mcp.json is absent", async () => { + const filePath = join(dir, ".github", "mcp.json"); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify({ + note: "keep", + mcpServers: { manual: { command: "manual", args: [] } }, + })); + + await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); + + expect(existsSync(join(dir, ".mcp.json"))).toBe(false); + expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({ + note: "keep", + mcpServers: { + manual: { command: "manual", args: [] }, + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + }, + }); + }); + + it("prefers copilot .mcp.json when both project paths exist", async () => { + const preferredPath = join(dir, ".mcp.json"); + const fallbackPath = join(dir, ".github", "mcp.json"); + await mkdir(dirname(fallbackPath), { recursive: true }); + await writeFile(preferredPath, JSON.stringify({ mcpServers: {} })); + await writeFile(fallbackPath, JSON.stringify({ + mcpServers: { fallback: { command: "fallback", args: [] } }, + })); + + await writeMcpConfigs(["copilot"], [STDIO_SERVER], projectMcpResolver(dir)); + + expect(JSON.parse(await readFile(preferredPath, "utf-8")).mcpServers.github).toBeDefined(); + expect(JSON.parse(await readFile(fallbackPath, "utf-8"))).toEqual({ + mcpServers: { fallback: { command: "fallback", args: [] } }, + }); + }); + + it("writes the same shared .mcp.json for claude and copilot in either order", async () => { + const firstDir = join(dir, "first"); + const secondDir = join(dir, "second"); + await mkdir(firstDir); + await mkdir(secondDir); + + await writeMcpConfigs( + ["claude", "copilot"], + [STDIO_SERVER, HTTP_SERVER], + projectMcpResolver(firstDir), + ); + await writeMcpConfigs( + ["copilot", "claude"], + [STDIO_SERVER, HTTP_SERVER], + projectMcpResolver(secondDir), + ); + + expect(await readFile(join(firstDir, ".mcp.json"), "utf-8")).toBe( + await readFile(join(secondDir, ".mcp.json"), "utf-8"), + ); + }); + it("handles multiple servers", async () => { await writeMcpConfigs(["claude"], [STDIO_SERVER, HTTP_SERVER], projectMcpResolver(dir)); @@ -406,6 +480,42 @@ describe("writeMcpConfigs", () => { expect(second.mtimeNs).toBe(first.mtimeNs); }); + it.skipIf(process.platform === "win32")( + "creates, repairs, and preserves a secure user config mode", + async () => { + const filePath = join(dir, "copilot", "mcp-config.json"); + const resolver = () => ({ filePath, shared: false, mode: 0o600 }); + + await writeMcpConfigs(["copilot"], [STDIO_SERVER], resolver); + expect((await stat(filePath)).mode & 0o777).toBe(0o600); + + await chmod(filePath, 0o644); + const before = await stat(filePath, { bigint: true }); + const repaired = await reconcileMcpConfigs( + ["copilot"], + [STDIO_SERVER], + resolver, + "apply", + ); + const after = await stat(filePath, { bigint: true }); + expect(repaired.written).toEqual([filePath]); + expect(repaired.issues).toEqual([ + expect.objectContaining({ issue: expect.stringContaining("expected 600") }), + ]); + expect(after.mode & 0o777n).toBe(0o600n); + expect(after.mtimeNs).toBe(before.mtimeNs); + + const unchanged = await reconcileMcpConfigs( + ["copilot"], + [STDIO_SERVER], + resolver, + "apply", + ); + expect(unchanged.issues).toEqual([]); + expect(unchanged.written).toEqual([]); + }, + ); + it("interpolates env refs in claude HTTP headers/URL with ${VAR} syntax", async () => { await writeMcpConfigs(["claude"], [HTTP_SERVER_WITH_ENV_REFS], projectMcpResolver(dir)); @@ -451,6 +561,17 @@ describe("writeMcpConfigs", () => { }); }); + it("interpolates env refs in copilot HTTP headers/URL with ${VAR} syntax", async () => { + await writeMcpConfigs(["copilot"], [HTTP_SERVER_WITH_ENV_REFS], projectMcpResolver(dir)); + + const content = JSON.parse(await readFile(join(dir, ".mcp.json"), "utf-8")); + expect(content.mcpServers["authed-api"]).toEqual({ + type: "http", + url: "https://${API_HOST}/mcp", + headers: { "X-Api-Key": "${API_KEY}", Authorization: "Bearer ${TOKEN}" }, + }); + }); + it("preserves literal HTTP placeholder-like values for adapter declarations", async () => { const literal = { ...HTTP_SERVER_WITH_ENV_REFS, interpolateEnvRefs: false }; await writeMcpConfigs(["opencode", "codex"], [literal], projectMcpResolver(dir)); diff --git a/packages/dotagents/src/targets/mcp-writer.ts b/packages/dotagents/src/targets/mcp-writer.ts index 15899cf7..78df1bbe 100644 --- a/packages/dotagents/src/targets/mcp-writer.ts +++ b/packages/dotagents/src/targets/mcp-writer.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile, mkdir, rm } from "node:fs/promises"; +import { chmod, readFile, writeFile, mkdir, rm, stat } from "node:fs/promises"; import { join, dirname } from "node:path"; import { existsSync } from "node:fs"; import { isDeepStrictEqual } from "node:util"; @@ -22,6 +22,7 @@ import { isSerializedObject, type SerializedObject } from "@sentry/dotagents-lib export interface McpResolvedTarget { filePath: string; shared: boolean; + mode?: number; } export type McpTargetResolver = (agentId: string, spec: McpConfigSpec) => McpResolvedTarget; @@ -126,7 +127,8 @@ export async function reconcileMcpConfigs( if (!agent) {continue;} const { mcp } = agent; - const { filePath } = resolveTarget(id, mcp); + const target = resolveTarget(id, mcp); + const { filePath } = target; if (seen.has(filePath)) {continue;} seen.add(filePath); @@ -136,7 +138,7 @@ export async function reconcileMcpConfigs( if (!existsSync(filePath)) { issues.push({ agent: id, issue: `MCP config missing: ${filePath}` }); if (mode === "apply") { - await writeDocument(filePath, mcp, expected); + await writeDocument(filePath, mcp, expected, target.mode); written.push(filePath); } continue; @@ -157,13 +159,18 @@ export async function reconcileMcpConfigs( const targetIssues = desiredIssues(id, filePath, existingServers, expectedServers); issues.push(...targetIssues); - - if (mode === "apply" && targetIssues.length > 0) { - const next = { - ...existing, - [mcp.rootKey]: { ...existingServers, ...expectedServers }, - }; - await writeReconciledDocument(filePath, mcp, next, expectedServers); + const modeIssue = await desiredModeIssue(id, filePath, target.mode); + if (modeIssue) {issues.push(modeIssue);} + + if (mode === "apply" && (targetIssues.length > 0 || modeIssue)) { + if (targetIssues.length > 0) { + const next = { + ...existing, + [mcp.rootKey]: { ...existingServers, ...expectedServers }, + }; + await writeReconciledDocument(filePath, mcp, next, expectedServers, target.mode); + } + if (modeIssue && target.mode !== undefined) {await chmod(filePath, target.mode);} written.push(filePath); } } @@ -217,7 +224,12 @@ export async function reconcileManagedMcpConfig( issues.push({ agent: agentId, issue: `MCP config missing: ${target.filePath}` }); if (mode === "apply") { const expected = Object.fromEntries(managed.map((name) => [name, desired[name]])); - await writeDocument(target.filePath, agent.mcp, { [agent.mcp.rootKey]: expected }); + await writeDocument( + target.filePath, + agent.mcp, + { [agent.mcp.rootKey]: expected }, + target.mode, + ); written.push(target.filePath); if (await writeManagedMcpState(statePath, managed)) {written.push(statePath);} } @@ -267,6 +279,8 @@ export async function reconcileManagedMcpConfig( } } issues.push(...desiredIssues(agentId, target.filePath, existingServers, expected)); + const modeIssue = await desiredModeIssue(agentId, target.filePath, target.mode); + if (modeIssue) {issues.push(modeIssue);} if (mode === "apply") { const targetChanged = stale.some((name) => name in existingServers) || @@ -279,10 +293,12 @@ export async function reconcileManagedMcpConfig( existingServers, expected, stale, + target.mode, ); - written.push(target.filePath); removed.push(...stale.filter((name) => name in existingServers)); } + if (modeIssue && target.mode !== undefined) {await chmod(target.filePath, target.mode);} + if (targetChanged || modeIssue) {written.push(target.filePath);} if (managed.length > 0) { if (await writeManagedMcpState(statePath, managed)) {written.push(statePath);} } else if (stateResult.state) { @@ -363,9 +379,10 @@ async function writeDocument( filePath: string, spec: McpConfigSpec, doc: SerializedObject, + mode?: number, ): Promise { await mkdir(dirname(filePath), { recursive: true }); - await writeFileIfChanged(filePath, serialize(doc, spec.format)); + await writeFileIfChanged(filePath, serialize(doc, spec.format), mode); } async function readExisting( @@ -403,9 +420,10 @@ async function writeReconciledDocument( spec: McpConfigSpec, doc: SerializedObject, expectedServers: SerializedObject, + mode?: number, ): Promise { if (spec.format !== "jsonc") { - await writeDocument(filePath, spec, doc); + await writeDocument(filePath, spec, doc, mode); return; } @@ -416,7 +434,7 @@ async function writeReconciledDocument( }); raw = applyJsoncEdits(raw, edits); } - await writeFileIfChanged(filePath, raw.endsWith("\n") ? raw : `${raw}\n`); + await writeFileIfChanged(filePath, raw.endsWith("\n") ? raw : `${raw}\n`, mode); } async function writeManagedReconciledDocument( @@ -426,12 +444,13 @@ async function writeManagedReconciledDocument( existingServers: SerializedObject, expectedServers: SerializedObject, removedNames: string[], + mode?: number, ): Promise { if (spec.format !== "jsonc") { const servers = { ...existingServers }; for (const name of removedNames) {delete servers[name];} Object.assign(servers, expectedServers); - await writeDocument(filePath, spec, { ...document, [spec.rootKey]: servers }); + await writeDocument(filePath, spec, { ...document, [spec.rootKey]: servers }, mode); return; } @@ -446,7 +465,7 @@ async function writeManagedReconciledDocument( formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, })); } - await writeFileIfChanged(filePath, raw.endsWith("\n") ? raw : `${raw}\n`); + await writeFileIfChanged(filePath, raw.endsWith("\n") ? raw : `${raw}\n`, mode); } async function readManagedMcpState( @@ -489,14 +508,32 @@ function serialize(doc: SerializedObject, format: "json" | "jsonc" | "toml"): st return `${JSON.stringify(doc, null, 2)}\n`; } -async function writeFileIfChanged(filePath: string, content: string): Promise { +async function writeFileIfChanged( + filePath: string, + content: string, + mode?: number, +): Promise { try { if ((await readFile(filePath, "utf-8")) === content) {return;} } catch (err) { if (!isNotFoundError(err)) {throw err;} } - await writeFile(filePath, content, "utf-8"); + await writeFile(filePath, content, { encoding: "utf-8", mode }); +} + +async function desiredModeIssue( + agent: string, + filePath: string, + expectedMode?: number, +): Promise { + if (expectedMode === undefined) {return undefined;} + const actualMode = (await stat(filePath)).mode & 0o777; + if (actualMode === expectedMode) {return undefined;} + return { + agent, + issue: `MCP config mode is ${actualMode.toString(8)}, expected ${expectedMode.toString(8)}: ${filePath}`, + }; } function isNotFoundError(err: unknown): boolean { diff --git a/packages/dotagents/src/targets/paths.test.ts b/packages/dotagents/src/targets/paths.test.ts index de834574..69b72e07 100644 --- a/packages/dotagents/src/targets/paths.test.ts +++ b/packages/dotagents/src/targets/paths.test.ts @@ -38,6 +38,39 @@ describe("getUserMcpTarget", () => { expect(t.shared).toBe(true); }); + it("copilot targets ~/.copilot/mcp-config.json by default", () => { + const previous = process.env["COPILOT_HOME"]; + delete process.env["COPILOT_HOME"]; + try { + const t = getUserMcpTarget("copilot"); + expect(t.filePath).toBe(join(home, ".copilot", "mcp-config.json")); + expect(t.shared).toBe(false); + expect(t.mode).toBe(process.platform === "win32" ? undefined : 0o600); + } finally { + if (previous === undefined) { + delete process.env["COPILOT_HOME"]; + } else { + process.env["COPILOT_HOME"] = previous; + } + } + }); + + it("copilot honors COPILOT_HOME", () => { + const previous = process.env["COPILOT_HOME"]; + process.env["COPILOT_HOME"] = join(home, "custom-copilot"); + try { + expect(getUserMcpTarget("copilot").filePath).toBe( + join(home, "custom-copilot", "mcp-config.json"), + ); + } finally { + if (previous === undefined) { + delete process.env["COPILOT_HOME"]; + } else { + process.env["COPILOT_HOME"] = previous; + } + } + }); + it("throws for unknown agent", () => { expect(() => getUserMcpTarget("emacs")).toThrow("Unknown agent"); }); @@ -77,6 +110,12 @@ describe("skill discovery paths", () => { expect(agent.skillsParentDir).toBeUndefined(); expect(agent.userSkillsParentDirs).toBeUndefined(); }); + + it("copilot reads .agents/skills/ natively", () => { + const agent = getAgent("copilot")!; + expect(agent.skillsParentDir).toBeUndefined(); + expect(agent.userSkillsParentDirs).toBeUndefined(); + }); }); describe("subagent paths", () => { @@ -109,4 +148,8 @@ describe("subagent paths", () => { it("vscode does not support custom subagents", () => { expect(getAgent("vscode")!.subagents).toBeUndefined(); }); + + it("copilot does not support custom subagents", () => { + expect(getAgent("copilot")!.subagents).toBeUndefined(); + }); }); diff --git a/packages/dotagents/src/targets/paths.ts b/packages/dotagents/src/targets/paths.ts index a69e48c7..9dfb5d77 100644 --- a/packages/dotagents/src/targets/paths.ts +++ b/packages/dotagents/src/targets/paths.ts @@ -5,6 +5,7 @@ import type { McpTargetResolver } from "./mcp-writer.js"; export interface UserMcpTarget { filePath: string; shared: boolean; + mode?: number; } /** @@ -25,6 +26,12 @@ export function getUserMcpTarget(agentId: string): UserMcpTarget { return { filePath: vscodeMcpPath(), shared: false }; case "opencode": return { filePath: join(home, ".config", "opencode", "opencode.json"), shared: true }; + case "copilot": + return { + filePath: join(process.env["COPILOT_HOME"] ?? join(home, ".copilot"), "mcp-config.json"), + shared: false, + ...(process.platform === "win32" ? {} : { mode: 0o600 }), + }; default: throw new Error(`Unknown agent for user-scope MCP: ${agentId}`); } diff --git a/packages/dotagents/src/targets/registry.test.ts b/packages/dotagents/src/targets/registry.test.ts index 5adeec1d..5152c9b7 100644 --- a/packages/dotagents/src/targets/registry.test.ts +++ b/packages/dotagents/src/targets/registry.test.ts @@ -9,6 +9,7 @@ describe("allAgentIds", () => { expect(ids).toContain("codex"); expect(ids).toContain("vscode"); expect(ids).toContain("opencode"); + expect(ids).toContain("copilot"); }); }); diff --git a/packages/dotagents/src/targets/registry.ts b/packages/dotagents/src/targets/registry.ts index 8bffe056..b45f7c39 100644 --- a/packages/dotagents/src/targets/registry.ts +++ b/packages/dotagents/src/targets/registry.ts @@ -4,8 +4,9 @@ import cursor from "./definitions/cursor.js"; import codex from "./definitions/codex.js"; import vscode from "./definitions/vscode.js"; import opencode from "./definitions/opencode.js"; +import copilot from "./definitions/copilot.js"; -const ALL_AGENTS: AgentDefinition[] = [claude, cursor, codex, vscode, opencode]; +const ALL_AGENTS: AgentDefinition[] = [claude, cursor, codex, vscode, opencode, copilot]; const AGENT_REGISTRY = new Map( ALL_AGENTS.map((a) => [a.id, a]), diff --git a/packages/dotagents/src/targets/skill-symlinks.test.ts b/packages/dotagents/src/targets/skill-symlinks.test.ts index c6ce124f..7ba21b6a 100644 --- a/packages/dotagents/src/targets/skill-symlinks.test.ts +++ b/packages/dotagents/src/targets/skill-symlinks.test.ts @@ -26,7 +26,7 @@ describe("skillSymlinkTargets", () => { expect( skillSymlinkTargets( scope, - ["claude", "cursor", "codex", "vscode", "opencode"], + ["claude", "cursor", "codex", "vscode", "opencode", "copilot"], [".legacy"], ), ).toEqual([join(homedir(), ".claude")]); diff --git a/specs/SPEC.md b/specs/SPEC.md index e67967b6..3fd39b80 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -31,7 +31,7 @@ The manifest file. Lives at the selected scope root: `~/.agents/agents.toml` by ```toml version = 1 -agents = ["claude", "cursor", "codex", "grok", "opencode", "pi"] +agents = ["claude", "cursor", "codex", "copilot", "grok", "opencode", "pi"] [project] name = "my-project" # Optional. For display purposes. @@ -93,7 +93,7 @@ targets = ["claude", "cursor", "codex", "grok", "opencode", "pi"] |-------|----------|-------------| | `version` | Yes | Schema version. Always `1`. | | `defaultRepositorySource` | No | Host used for shorthand `owner/repo` skill sources. Valid values: `github`, `gitlab`. Defaults to `github`. | -| `agents` | No | Array of agent tool IDs. Valid: `claude`, `cursor`, `codex`, `vscode`, `grok`, `opencode`, `pi`. Defaults to `[]`. When set, dotagents creates skills symlinks and runtime config files for each agent where supported. `grok` and `pi` are plugin-only targets. | +| `agents` | No | Array of agent tool IDs. Valid: `claude`, `cursor`, `codex`, `copilot`, `vscode`, `grok`, `opencode`, `pi`. Defaults to `[]`. When set, dotagents creates skills symlinks and runtime config files for each agent where supported. `grok` and `pi` are plugin-only targets. | | `project` | No | Project metadata. | | `symlinks` | No | Symlink configuration (legacy — prefer `agents` for new projects). | | `skills` | No | Skill dependencies (array of tables). | @@ -186,6 +186,7 @@ A server must have either `command` (stdio) or `url` (HTTP), but not both. | Agent | Output syntax | |-------|---------------| | Claude Code | `${VAR}` (unchanged) | +| GitHub Copilot | `${VAR}` (unchanged) | | Cursor | `${env:VAR}` | | VS Code | `${env:VAR}` | | OpenCode | `{env:VAR}` | @@ -281,9 +282,12 @@ Global scope installs canonical plugins into `~/.agents/plugins//`. It gen | `grok` | Grok Build | `.grok` | Not generated | Not generated | Not generated | | `vscode` | VS Code Copilot | `.vscode` | `.vscode/mcp.json` | JSON | Not supported | | `opencode` | OpenCode | `.opencode` | `.opencode/opencode.jsonc` | JSONC (shared) | `.opencode/agents/*.md` | +| `copilot` | GitHub Copilot CLI/Desktop | `.copilot` | `.mcp.json` or `.github/mcp.json` | JSON | Not supported | Each agent has its own MCP config format. dotagents translates the universal `[[mcp]]` declarations into the format each tool expects during `install` and `sync`. Grok is currently supported for plugin projections only. +GitHub Copilot accepts the same implicit stdio shape as Claude, so both targets produce stable shared `.mcp.json` output. For user scope, GitHub Copilot uses `COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set and `~/.copilot/mcp-config.json` otherwise. On POSIX systems, dotagents creates and repairs this file with mode `0600`. + ### Source Types The source format is inferred from the value. Shorthand `owner/repo` resolves using `defaultRepositorySource` (default: GitHub). @@ -892,7 +896,7 @@ dotagents/ doctor.ts targets/ types.ts # Target agent interfaces and MCP/hook declarations - registry.ts # Target registry (claude, cursor, codex, vscode, opencode) + registry.ts # Target registry (claude, cursor, codex, vscode, opencode, copilot) definitions/ # Per-target definitions mcp-writer.ts # MCP config file generation per target hook-writer.ts # Hook config file generation per target From 3c3871e3f0ca5f1c8a326ffdc310f25fbe109ae2 Mon Sep 17 00:00:00 2001 From: Misha Milovidov <42074547+mishamilovidov@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:45:29 -0700 Subject: [PATCH 2/3] fix: harden mcp reconciliation Preserve fallback MCP entries when Claude and Copilot converge on `.mcp.json`. Recover if a mode check races with external file deletion. --- .../dotagents/src/targets/mcp-writer.test.ts | 29 +++++ packages/dotagents/src/targets/mcp-writer.ts | 120 ++++++++++++++---- 2 files changed, 125 insertions(+), 24 deletions(-) diff --git a/packages/dotagents/src/targets/mcp-writer.test.ts b/packages/dotagents/src/targets/mcp-writer.test.ts index 797b1cba..7e8d3002 100644 --- a/packages/dotagents/src/targets/mcp-writer.test.ts +++ b/packages/dotagents/src/targets/mcp-writer.test.ts @@ -273,6 +273,35 @@ describe("writeMcpConfigs", () => { }); }); + it.each([ + ["claude", "copilot"], + ["copilot", "claude"], + ])("seeds shared .mcp.json from the copilot fallback for %s first", async (first, second) => { + const preferredPath = join(dir, ".mcp.json"); + const fallbackPath = join(dir, ".github", "mcp.json"); + const fallback = { + note: "keep", + mcpServers: { manual: { command: "manual", args: [] } }, + }; + await mkdir(dirname(fallbackPath), { recursive: true }); + await writeFile(fallbackPath, JSON.stringify(fallback)); + + await writeMcpConfigs([first, second], [STDIO_SERVER], projectMcpResolver(dir)); + + expect(JSON.parse(await readFile(preferredPath, "utf-8"))).toEqual({ + note: "keep", + mcpServers: { + manual: { command: "manual", args: [] }, + github: { + command: "npx", + args: ["-y", "@mcp/server-github"], + env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" }, + }, + }, + }); + expect(JSON.parse(await readFile(fallbackPath, "utf-8"))).toEqual(fallback); + }); + it("writes the same shared .mcp.json for claude and copilot in either order", async () => { const firstDir = join(dir, "first"); const secondDir = join(dir, "second"); diff --git a/packages/dotagents/src/targets/mcp-writer.ts b/packages/dotagents/src/targets/mcp-writer.ts index 78df1bbe..d41ffb84 100644 --- a/packages/dotagents/src/targets/mcp-writer.ts +++ b/packages/dotagents/src/targets/mcp-writer.ts @@ -23,6 +23,7 @@ export interface McpResolvedTarget { filePath: string; shared: boolean; mode?: number; + preferredFilePath?: string; } export type McpTargetResolver = (agentId: string, spec: McpConfigSpec) => McpResolvedTarget; @@ -74,9 +75,12 @@ export function projectMcpResolver(projectRoot: string): McpTargetResolver { const candidates = [spec.filePath, ...(spec.fallbackFilePaths ?? [])]; const relativePath = candidates.find((candidate) => existsSync(join(projectRoot, candidate))) ?? spec.filePath; + const filePath = join(projectRoot, relativePath); + const preferredFilePath = join(projectRoot, spec.filePath); return { - filePath: join(projectRoot, relativePath), + filePath, shared: spec.shared, + ...(filePath !== preferredFilePath && { preferredFilePath }), }; }; } @@ -122,12 +126,24 @@ export async function reconcileMcpConfigs( const normalized = servers.map(normalizeMcpDeclaration); if (normalized.length === 0) {return { issues, unresolved, written };} - for (const id of agentIds) { + const initialTargets = agentIds.flatMap((id) => { const agent = getAgent(id); - if (!agent) {continue;} + if (!agent) {return [];} + return [{ id, agent, target: resolveTarget(id, agent.mcp) }]; + }); + const claimedPaths = new Set(initialTargets.map(({ target }) => target.filePath)); + const promotedFallbacks = new Map(); + const targets = initialTargets.map((entry) => { + const preferredFilePath = entry.target.preferredFilePath; + if (!preferredFilePath || !claimedPaths.has(preferredFilePath)) {return entry;} + promotedFallbacks.set(preferredFilePath, entry.target.filePath); + return Object.assign({}, entry, { + target: Object.assign({}, entry.target, { filePath: preferredFilePath }), + }); + }); + for (const { id, agent, target } of targets) { const { mcp } = agent; - const target = resolveTarget(id, mcp); const { filePath } = target; if (seen.has(filePath)) {continue;} seen.add(filePath); @@ -138,7 +154,24 @@ export async function reconcileMcpConfigs( if (!existsSync(filePath)) { issues.push({ agent: id, issue: `MCP config missing: ${filePath}` }); if (mode === "apply") { - await writeDocument(filePath, mcp, expected, target.mode); + const fallbackPath = promotedFallbacks.get(filePath); + if (fallbackPath) { + try { + const fallback = await readExisting(fallbackPath, mcp); + const fallbackServers = readServerRoot(fallback, mcp.rootKey, fallbackPath); + await writeDocument(filePath, mcp, { + ...fallback, + [mcp.rootKey]: { ...fallbackServers, ...expectedServers }, + }, target.mode); + } catch { + const issue = { agent: id, issue: `Failed to read MCP config: ${fallbackPath}` }; + issues.push(issue); + unresolved.push(issue); + continue; + } + } else { + await writeDocument(filePath, mcp, expected, target.mode); + } written.push(filePath); } continue; @@ -159,18 +192,29 @@ export async function reconcileMcpConfigs( const targetIssues = desiredIssues(id, filePath, existingServers, expectedServers); issues.push(...targetIssues); - const modeIssue = await desiredModeIssue(id, filePath, target.mode); - if (modeIssue) {issues.push(modeIssue);} - - if (mode === "apply" && (targetIssues.length > 0 || modeIssue)) { + const modeCheck = await desiredModeIssue(id, filePath, target.mode); + if (modeCheck) {issues.push(modeCheck.issue);} + + if (mode === "apply" && (targetIssues.length > 0 || modeCheck)) { + const next = { + ...existing, + [mcp.rootKey]: { ...existingServers, ...expectedServers }, + }; if (targetIssues.length > 0) { - const next = { - ...existing, - [mcp.rootKey]: { ...existingServers, ...expectedServers }, - }; await writeReconciledDocument(filePath, mcp, next, expectedServers, target.mode); } - if (modeIssue && target.mode !== undefined) {await chmod(filePath, target.mode);} + if (modeCheck && target.mode !== undefined) { + if (modeCheck.missing && targetIssues.length === 0) { + await writeDocument(filePath, mcp, next, target.mode); + } else if (!modeCheck.missing) { + try { + await chmod(filePath, target.mode); + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + await writeDocument(filePath, mcp, next, target.mode); + } + } + } written.push(filePath); } } @@ -278,13 +322,14 @@ export async function reconcileManagedMcpConfig( issues.push({ agent: agentId, issue: `Managed MCP server "${name}" is stale in ${target.filePath}` }); } } - issues.push(...desiredIssues(agentId, target.filePath, existingServers, expected)); - const modeIssue = await desiredModeIssue(agentId, target.filePath, target.mode); - if (modeIssue) {issues.push(modeIssue);} + const targetIssues = desiredIssues(agentId, target.filePath, existingServers, expected); + issues.push(...targetIssues); + const modeCheck = await desiredModeIssue(agentId, target.filePath, target.mode); + if (modeCheck) {issues.push(modeCheck.issue);} if (mode === "apply") { const targetChanged = stale.some((name) => name in existingServers) || - desiredIssues(agentId, target.filePath, existingServers, expected).length > 0; + targetIssues.length > 0; if (targetChanged) { await writeManagedReconciledDocument( target.filePath, @@ -297,8 +342,23 @@ export async function reconcileManagedMcpConfig( ); removed.push(...stale.filter((name) => name in existingServers)); } - if (modeIssue && target.mode !== undefined) {await chmod(target.filePath, target.mode);} - if (targetChanged || modeIssue) {written.push(target.filePath);} + if (modeCheck && target.mode !== undefined) { + const nextServers = { ...existingServers }; + for (const name of stale) {delete nextServers[name];} + Object.assign(nextServers, expected); + const next = { ...existing, [agent.mcp.rootKey]: nextServers }; + if (modeCheck.missing && !targetChanged) { + await writeDocument(target.filePath, agent.mcp, next, target.mode); + } else if (!modeCheck.missing) { + try { + await chmod(target.filePath, target.mode); + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + await writeDocument(target.filePath, agent.mcp, next, target.mode); + } + } + } + if (targetChanged || modeCheck) {written.push(target.filePath);} if (managed.length > 0) { if (await writeManagedMcpState(statePath, managed)) {written.push(statePath);} } else if (stateResult.state) { @@ -526,13 +586,25 @@ async function desiredModeIssue( agent: string, filePath: string, expectedMode?: number, -): Promise { +): Promise<{ issue: McpReconcileIssue; missing: boolean } | undefined> { if (expectedMode === undefined) {return undefined;} - const actualMode = (await stat(filePath)).mode & 0o777; + let actualMode: number; + try { + actualMode = (await stat(filePath)).mode & 0o777; + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + return { + issue: { agent, issue: `MCP config missing: ${filePath}` }, + missing: true, + }; + } if (actualMode === expectedMode) {return undefined;} return { - agent, - issue: `MCP config mode is ${actualMode.toString(8)}, expected ${expectedMode.toString(8)}: ${filePath}`, + issue: { + agent, + issue: `MCP config mode is ${actualMode.toString(8)}, expected ${expectedMode.toString(8)}: ${filePath}`, + }, + missing: false, }; } From 2f451cdc2b02a3a7c6780e1dc4283db7b7cde80e Mon Sep 17 00:00:00 2001 From: Misha Milovidov <42074547+mishamilovidov@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:54:39 -0700 Subject: [PATCH 3/3] fix: preserve mcp write failures Propagate preferred-file write errors instead of reporting them as fallback read failures. Recreate JSONC state if the config file disappears during reconciliation. --- packages/dotagents/src/targets/mcp-writer.ts | 42 ++++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/dotagents/src/targets/mcp-writer.ts b/packages/dotagents/src/targets/mcp-writer.ts index d41ffb84..66e6d39f 100644 --- a/packages/dotagents/src/targets/mcp-writer.ts +++ b/packages/dotagents/src/targets/mcp-writer.ts @@ -156,19 +156,21 @@ export async function reconcileMcpConfigs( if (mode === "apply") { const fallbackPath = promotedFallbacks.get(filePath); if (fallbackPath) { + let fallback: SerializedObject; + let fallbackServers: SerializedObject; try { - const fallback = await readExisting(fallbackPath, mcp); - const fallbackServers = readServerRoot(fallback, mcp.rootKey, fallbackPath); - await writeDocument(filePath, mcp, { - ...fallback, - [mcp.rootKey]: { ...fallbackServers, ...expectedServers }, - }, target.mode); + fallback = await readExisting(fallbackPath, mcp); + fallbackServers = readServerRoot(fallback, mcp.rootKey, fallbackPath); } catch { const issue = { agent: id, issue: `Failed to read MCP config: ${fallbackPath}` }; issues.push(issue); unresolved.push(issue); continue; } + await writeDocument(filePath, mcp, { + ...fallback, + [mcp.rootKey]: { ...fallbackServers, ...expectedServers }, + }, target.mode); } else { await writeDocument(filePath, mcp, expected, target.mode); } @@ -487,7 +489,14 @@ async function writeReconciledDocument( return; } - let raw = await readFile(filePath, "utf-8"); + let raw: string; + try { + raw = await readFile(filePath, "utf-8"); + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + await writeDocument(filePath, spec, doc, mode); + return; + } for (const [name, server] of Object.entries(expectedServers)) { const edits = modifyJsonc(raw, [spec.rootKey, name], server, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, @@ -506,15 +515,24 @@ async function writeManagedReconciledDocument( removedNames: string[], mode?: number, ): Promise { + const servers = { ...existingServers }; + for (const name of removedNames) {delete servers[name];} + Object.assign(servers, expectedServers); + const next = { ...document, [spec.rootKey]: servers }; + if (spec.format !== "jsonc") { - const servers = { ...existingServers }; - for (const name of removedNames) {delete servers[name];} - Object.assign(servers, expectedServers); - await writeDocument(filePath, spec, { ...document, [spec.rootKey]: servers }, mode); + await writeDocument(filePath, spec, next, mode); return; } - let raw = await readFile(filePath, "utf-8"); + let raw: string; + try { + raw = await readFile(filePath, "utf-8"); + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + await writeDocument(filePath, spec, next, mode); + return; + } for (const name of removedNames) { raw = applyJsoncEdits(raw, modifyJsonc(raw, [spec.rootKey, name], undefined, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },