diff --git a/.changeset/mcp-frontmatter-init.md b/.changeset/mcp-frontmatter-init.md index 9ef59b152..a656c327c 100644 --- a/.changeset/mcp-frontmatter-init.md +++ b/.changeset/mcp-frontmatter-init.md @@ -3,4 +3,4 @@ "@bradygaster/squad-sdk": minor --- -Add `squad init --mcp-frontmatter` to write MCP server config into the Squad agent frontmatter instead of `.copilot/mcp-config.json` for compatible agent harnesses. +Add `squad init --mcp-frontmatter` to write MCP server config into the Squad agent frontmatter instead of `.mcp.json` (repo root) for compatible agent harnesses. diff --git a/.changeset/mcp-json-migration.md b/.changeset/mcp-json-migration.md new file mode 100644 index 000000000..abc62d216 --- /dev/null +++ b/.changeset/mcp-json-migration.md @@ -0,0 +1,23 @@ +--- +"@bradygaster/squad-cli": minor +"@bradygaster/squad-sdk": minor +--- + +Migrate per-repo MCP server config from `.copilot/mcp-config.json` to `.mcp.json` at the repo root. + +`.mcp.json` is the workspace-level MCP config path that GitHub Copilot CLI auto-loads (see `copilot mcp --help`, verified on Copilot CLI 1.0.58+). The legacy `.copilot/mcp-config.json` location was never auto-loaded — users had to pass `--additional-mcp-config .copilot/mcp-config.json` on every invocation, and most teams never noticed their MCP servers weren't actually loading. Closes the loop on [github/copilot-cli#3642](https://github.com/github/copilot-cli/issues/3642). + +**What changes:** + +- `squad init` (both `squad init` SDK flow and the legacy `index.cjs` entry) now writes the sample MCP config to `.mcp.json` at the repo root instead of `.copilot/mcp-config.json`. +- `squad init --mcp-frontmatter` still writes into the agent frontmatter and does not touch either file. +- Templates, internal docs, and user-facing docs (`features/mcp.md`, `concepts/portability.md`, `reference/config.md`, `features/enterprise-platforms.md`, `features/notifications.md`) now lead with `.mcp.json` and explain `~/.copilot/mcp-config.json` as the user-level fallback. +- The JSON schema (`{ mcpServers: { ... } }`) is unchanged — only the file path moves. + +**Migration (Phase 2 — automatic):** + +`squad upgrade` now folds any pre-existing `.copilot/mcp-config.json` into `.mcp.json` at the repo root and reports the count migrated. Conflict policy: when the same server name is defined in both files with different `command`/`args`/`env`, the workspace `.mcp.json` wins and a warning is printed so the user can reconcile. The legacy file is preserved (not deleted) for one deprecation cycle so users can verify the merge — delete it manually once you have confirmed the new file works. + +During the same window, `squad init` dual-writes the `squad_state` pin into both `.mcp.json` (always) and a pre-existing `.copilot/mcp-config.json` (only if already present). Fresh inits never create the legacy file — the migration is one-way. The user-level `~/.copilot/mcp-config.json` path is unaffected. + +If you prefer to migrate by hand, the recipe in `docs/features/mcp.md` still applies — `squad upgrade` just does it for you. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index aeade9aaf..c2af60cb2 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -449,7 +449,7 @@ When spawning agents, include an `MCP TOOLS AVAILABLE` block in the prompt (see Never crash or halt because an MCP tool is missing. MCP tools are enhancements, not dependencies. 1. **CLI fallback** — GitHub MCP missing → use `gh` CLI. Azure MCP missing → use `az` CLI. -2. **Inform the user** — "Trello integration requires the Trello MCP server. Add it to `.copilot/mcp-config.json`." +2. **Inform the user** — "Trello integration requires the Trello MCP server. Add it to `.mcp.json` at the repo root (the path Copilot CLI auto-loads)." 3. **Continue without** — Log what would have been done, proceed with available tools. ### Eager Execution Philosophy diff --git a/.squad-templates/mcp-config.md b/.squad-templates/mcp-config.md index f38425e4c..e9f739f54 100644 --- a/.squad-templates/mcp-config.md +++ b/.squad-templates/mcp-config.md @@ -5,10 +5,12 @@ MCP (Model Context Protocol) servers extend Squad with tools for external servic ## Config File Locations Users configure MCP servers at these locations (checked in priority order): -1. **Repository-level:** `.copilot/mcp-config.json` (team-shared, committed to repo) -2. **Workspace-level:** `.vscode/mcp.json` (VS Code workspaces) -3. **User-level:** `~/.copilot/mcp-config.json` (personal) -4. **CLI override:** `--additional-mcp-config` flag (session-specific) +1. **Repository-level (auto-loaded):** `.mcp.json` at the repo root — team-shared, committed; this is the workspace path Copilot CLI auto-loads (see `copilot mcp --help`). +2. **Workspace-level:** `.vscode/mcp.json` (VS Code workspaces). +3. **User-level:** `~/.copilot/mcp-config.json` (personal, user-wide). +4. **CLI override:** `--additional-mcp-config ` (session-specific). + +> **Migration note:** Older Squad versions wrote the per-repo config to `.copilot/mcp-config.json`. That path was not auto-loaded by Copilot CLI and required `--additional-mcp-config`. New `squad init` writes `.mcp.json` instead. If your repo still has a `.copilot/mcp-config.json`, it is preserved as-is — see `docs/features/mcp.md` for a manual merge recipe until `squad upgrade` ships a guided merge. ## Sample Config — Trello diff --git a/.squad-templates/squad.agent.md b/.squad-templates/squad.agent.md index aeade9aaf..c2af60cb2 100644 --- a/.squad-templates/squad.agent.md +++ b/.squad-templates/squad.agent.md @@ -449,7 +449,7 @@ When spawning agents, include an `MCP TOOLS AVAILABLE` block in the prompt (see Never crash or halt because an MCP tool is missing. MCP tools are enhancements, not dependencies. 1. **CLI fallback** — GitHub MCP missing → use `gh` CLI. Azure MCP missing → use `az` CLI. -2. **Inform the user** — "Trello integration requires the Trello MCP server. Add it to `.copilot/mcp-config.json`." +2. **Inform the user** — "Trello integration requires the Trello MCP server. Add it to `.mcp.json` at the repo root (the path Copilot CLI auto-loads)." 3. **Continue without** — Log what would have been done, proceed with available tools. ### Eager Execution Philosophy diff --git a/docs/src/content/docs/concepts/portability.md b/docs/src/content/docs/concepts/portability.md index 457109c97..db5d43c4c 100644 --- a/docs/src/content/docs/concepts/portability.md +++ b/docs/src/content/docs/concepts/portability.md @@ -226,7 +226,7 @@ MCP (Model Context Protocol) servers extend Squad with external services. Agents | Platform | Config File | |----------|------------| -| **Copilot CLI** | `.copilot/mcp-config.json` | +| **Copilot CLI** | `.mcp.json` (project, repo root) | | **VS Code** | `.vscode/settings.json` (under `copilot.mcp.servers`) | ### Example: GitHub MCP diff --git a/docs/src/content/docs/features/enterprise-platforms.md b/docs/src/content/docs/features/enterprise-platforms.md index 97e3598f7..fbd8e1142 100644 --- a/docs/src/content/docs/features/enterprise-platforms.md +++ b/docs/src/content/docs/features/enterprise-platforms.md @@ -104,7 +104,7 @@ Squad uses the Azure CLI for ADO authentication — **no Personal Access Tokens For GitHub repositories, Squad uses the `gh` CLI for authentication. When working across multiple GitHub accounts (e.g., personal GitHub and Enterprise Managed Users), use `gh auth switch` to toggle between accounts. See [Cross-organization authentication](../scenarios/cross-org-auth) for detailed multi-account setup. -Alternatively, if the Azure DevOps MCP server is configured in your environment, Squad will use it automatically for richer API access. Add it to `.copilot/mcp-config.json`: +Alternatively, if the Azure DevOps MCP server is configured in your environment, Squad will use it automatically for richer API access. Add it to `.mcp.json` at the repo root (the path Copilot CLI auto-loads): ```json { diff --git a/docs/src/content/docs/features/mcp.md b/docs/src/content/docs/features/mcp.md index 597134e6b..57922cf20 100644 --- a/docs/src/content/docs/features/mcp.md +++ b/docs/src/content/docs/features/mcp.md @@ -25,14 +25,17 @@ MCP bridges Squad agents and external services. Agents use MCP tools to send not ## MCP Configuration Files -There are two places to configure MCP, depending on your platform: +There are two scopes to configure MCP, depending on whether the servers are shared with your team or personal to you: -| Platform | Config File | How to Edit | Startup | -|----------|------------|-----------|---------| -| **Copilot CLI** | `.copilot/mcp-config.json` | Text editor | Add to shell initialization (`~/.bashrc`, `~/.zshrc`, etc.) | -| **VS Code** | `.vscode/settings.json` | VS Code Settings GUI or JSON editor | Built-in; restarts Copilot extension | +| Scope | Config File | How to Edit | Loaded By | +|-------|-------------|-------------|-----------| +| **Project (team-shared)** | `.mcp.json` (at repo root) | Text editor; commit to the repo | Auto-loaded by Copilot CLI (`copilot mcp --help`); also discovered by VS Code workspaces. **This is what `squad init` writes.** | +| **User (personal)** | `~/.copilot/mcp-config.json` | Text editor | Loaded by Copilot CLI globally for your account | +| **VS Code workspace** | `.vscode/mcp.json` or `.vscode/settings.json` | VS Code Settings GUI or JSON editor | Built-in; restarts Copilot extension | -This guide covers both. Pick the one that matches your workflow. +This guide covers all three. Pick the one that matches your workflow. + +> **Migration note:** Squad ≤ 0.9.x wrote the per-repo config to `.copilot/mcp-config.json`. That path is **not** auto-loaded by Copilot CLI (it required the `--additional-mcp-config` flag). Newer `squad init` writes `.mcp.json` instead. If your repo already has both files, see the "Migrating from `.copilot/mcp-config.json`" section below. --- @@ -169,7 +172,7 @@ Press `Cmd+Shift+P` (macOS) or `Ctrl+Shift+P` (Windows) and select **"Copilot: R Most Squad installs come with GitHub MCP pre-configured. Here's what it looks like: -### CLI: `.copilot/mcp-config.json` +### CLI: `.mcp.json` (project) or `~/.copilot/mcp-config.json` (user) ```json { @@ -349,6 +352,21 @@ Agents don't need special setup to discover tools. Here's the flow: copilot ``` +### Migrating from `.copilot/mcp-config.json` + +Squad ≤ 0.9.x wrote the per-repo MCP config to `.copilot/mcp-config.json`. That path was **not** auto-loaded by Copilot CLI — users had to pass `--additional-mcp-config .copilot/mcp-config.json` on every invocation, and a lot of teams never noticed their MCP servers weren't actually loading. See [github/copilot-cli#3642](https://github.com/github/copilot-cli/issues/3642). + +Newer `squad init` writes `.mcp.json` at the repo root — the path Copilot CLI auto-loads per `copilot mcp --help`. + +**If your repo already has `.copilot/mcp-config.json`:** + +1. Open both files (the old `.copilot/mcp-config.json` and the new `.mcp.json` that `squad init` wrote). +2. Copy any custom `mcpServers.*` entries from `.copilot/mcp-config.json` into `.mcp.json` — merge them under the existing `mcpServers` key. +3. Delete `.copilot/mcp-config.json` once you've verified the merged `.mcp.json` works (`copilot mcp` should list the merged servers). +4. Commit `.mcp.json`. You can stop passing `--additional-mcp-config`. + +A `squad upgrade` guided merge will ship in a follow-up release; until then, the migration is manual. + ### Tools Not Appearing in Agent Responses **Symptom:** Agent says "I don't have access to GitHub tools" even though you configured MCP. @@ -357,7 +375,9 @@ Agents don't need special setup to discover tools. Here's the flow: 1. **Verify config syntax:** ```bash - # CLI + # Project (auto-loaded by Copilot CLI) + cat ./.mcp.json | jq . + # User-level (also valid) cat ~/.copilot/mcp-config.json | jq . # Should be valid JSON; if not, `jq` will error ``` diff --git a/docs/src/content/docs/features/notifications.md b/docs/src/content/docs/features/notifications.md index f07fd20f5..9bdade7a6 100644 --- a/docs/src/content/docs/features/notifications.md +++ b/docs/src/content/docs/features/notifications.md @@ -375,7 +375,7 @@ For advanced use cases, you can also: ## Sample MCP Configs -Below are complete, copy-pasteable `.copilot/mcp-config.json` examples for each notification platform. Pick the one that matches your setup and copy the entire `mcpServers` block into your config file. +Below are complete, copy-pasteable `.mcp.json` (project, at repo root) examples for each notification platform. Pick the one that matches your setup and copy the entire `mcpServers` block into your config file (use `~/.copilot/mcp-config.json` instead if you want it user-level rather than committed to the repo). ### Teams Webhook (Simplest) diff --git a/docs/src/content/docs/reference/config.md b/docs/src/content/docs/reference/config.md index eb4654c21..cf6cd3adf 100644 --- a/docs/src/content/docs/reference/config.md +++ b/docs/src/content/docs/reference/config.md @@ -112,7 +112,7 @@ These are created only when you opt in during init. - **`.squad/templates/`** — SDK templates, overwritten on upgrade - **`.github/workflows/*.yml`** — CI/CD workflows (opt-in: `--include-workflows`) -- **`.copilot/mcp-config.json`** — MCP server config (opt-in: `--include-mcp-config`) +- **`.mcp.json`** — MCP server config at repo root (opt-in: `--include-mcp-config`); auto-loaded by Copilot CLI > ⚠️ **Hard rule:** Squad NEVER writes temp files, logs, or memory to your repo root. All team state lives in `.squad/` only. Your project tree stays clean. diff --git a/index.cjs b/index.cjs index f3c90c9f2..b5502931c 100644 --- a/index.cjs +++ b/index.cjs @@ -1768,34 +1768,10 @@ Reusable patterns and heuristics learned through work. NOT transcripts — each console.log(`${DIM}identity/wisdom.md already exists — skipping${RESET}`); } -// Create sample MCP config (skip if .copilot/mcp-config.json already exists) -if (!isUpgrade) { - const mcpDir = path.join(dest, '.copilot'); - const mcpConfigPath = path.join(mcpDir, 'mcp-config.json'); - if (!fs.existsSync(mcpConfigPath)) { - try { - fs.mkdirSync(mcpDir, { recursive: true }); - const mcpSample = { - mcpServers: { - "EXAMPLE-trello": { - command: "npx", - args: ["-y", "@trello/mcp-server"], - env: { - TRELLO_API_KEY: "${TRELLO_API_KEY}", - TRELLO_TOKEN: "${TRELLO_TOKEN}" - } - } - } - }; - fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpSample, null, 2) + '\n'); - console.log(`${GREEN}✓${RESET} .copilot/mcp-config.json (MCP sample — rename EXAMPLE-trello to enable)`); - } catch (err) { - // Non-fatal — MCP config is optional - } - } else { - console.log(`${DIM}mcp-config.json already exists — skipping${RESET}`); - } -} +// MCP sample config is now written by the squad-sdk init flow (packages/squad-sdk/src/config/init.ts) +// to `.mcp.json` at the repo root — the workspace path that Copilot CLI auto-loads +// (see `copilot mcp --help`). Legacy `.copilot/mcp-config.json` is no longer written +// here. See github/copilot-cli#3642 and the `mcp-json-migration` changeset. // Copy default ceremonies config const ceremoniesDest = path.join(squadInfo.path, 'ceremonies.md'); diff --git a/packages/squad-cli/src/cli/commands/state-mcp.ts b/packages/squad-cli/src/cli/commands/state-mcp.ts index 733b722d6..fbdafcce6 100644 --- a/packages/squad-cli/src/cli/commands/state-mcp.ts +++ b/packages/squad-cli/src/cli/commands/state-mcp.ts @@ -201,8 +201,9 @@ export function printStateMcpHelp(): void { 'Usage: squad state-mcp', '', 'This command is intended to be launched by GitHub Copilot CLI through', - '.copilot/mcp-config.json. It exposes squad_decide and state.* tools', - 'backed by the configured Squad state backend.', + '.mcp.json (repo root) — the workspace MCP config path that the CLI', + 'auto-loads. It exposes squad_decide and state.* tools backed by the', + 'configured Squad state backend.', '', ].join('\n')); } diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index f11a8e692..40304ae74 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -111,7 +111,7 @@ export interface RunInitOptions { isGlobal?: boolean; /** State backend to configure at init time (local, orphan, two-layer) */ stateBackend?: string; - /** If true, write MCP server config into squad.agent.md frontmatter instead of .copilot/mcp-config.json */ + /** If true, write MCP server config into squad.agent.md frontmatter instead of .mcp.json (repo root) */ mcpFrontmatter?: boolean; } diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 5326f1c92..43f05ce91 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; -import { FSStorageProvider } from '@bradygaster/squad-sdk'; +import { FSStorageProvider, migrateMcpConfig } from '@bradygaster/squad-sdk'; import { success, warn, info, dim, bold } from './output.js'; import { fatal } from './errors.js'; import { detectSquadDir } from './detect-squad-dir.js'; @@ -741,6 +741,36 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr fatal('No squad found — run init first.'); } + // ------------------------------------------------------------------------- + // Phase 2: fold legacy `.copilot/mcp-config.json` into `.mcp.json` so + // Copilot CLI 1.0.58+ actually discovers the user's MCP servers (the + // legacy path was never auto-loaded — see github/copilot-cli#3642). + // Runs BEFORE template/migration work so downstream steps see the merged + // server set. Idempotent — re-runs after success are no-ops. + // ------------------------------------------------------------------------- + try { + const migrateResult = migrateMcpConfig(dest); + if (migrateResult.status === 'migrated') { + success( + `📋 Migrated ${migrateResult.migrated} MCP server(s) from ` + + `.copilot/mcp-config.json → .mcp.json. ` + + `Legacy file preserved; you may delete it once verified.`, + ); + filesUpdated.push('.mcp.json (merged from .copilot/mcp-config.json)'); + } + for (const warningLine of migrateResult.warnings) { + warn(warningLine); + } + if (migrateResult.status === 'malformed-target') { + warn(migrateResult.error ?? 'Could not merge .copilot/mcp-config.json — .mcp.json is malformed.'); + } else if (migrateResult.status === 'malformed-legacy') { + warn(migrateResult.error ?? 'Could not parse .copilot/mcp-config.json — skipping MCP merge.'); + } + } catch (err) { + // Never block an upgrade on a merge failure — surface and continue. + warn(`MCP config merge failed: ${(err as Error).message}`); + } + const agentDest = path.join(dest, '.github', 'agents', 'squad.agent.md'); const oldVersion = readInstalledVersion(agentDest) ?? '0.0.0'; const squadConfig = readSquadConfig(squadDirInfo.path); diff --git a/packages/squad-cli/templates/mcp-config.md b/packages/squad-cli/templates/mcp-config.md index f38425e4c..e9f739f54 100644 --- a/packages/squad-cli/templates/mcp-config.md +++ b/packages/squad-cli/templates/mcp-config.md @@ -5,10 +5,12 @@ MCP (Model Context Protocol) servers extend Squad with tools for external servic ## Config File Locations Users configure MCP servers at these locations (checked in priority order): -1. **Repository-level:** `.copilot/mcp-config.json` (team-shared, committed to repo) -2. **Workspace-level:** `.vscode/mcp.json` (VS Code workspaces) -3. **User-level:** `~/.copilot/mcp-config.json` (personal) -4. **CLI override:** `--additional-mcp-config` flag (session-specific) +1. **Repository-level (auto-loaded):** `.mcp.json` at the repo root — team-shared, committed; this is the workspace path Copilot CLI auto-loads (see `copilot mcp --help`). +2. **Workspace-level:** `.vscode/mcp.json` (VS Code workspaces). +3. **User-level:** `~/.copilot/mcp-config.json` (personal, user-wide). +4. **CLI override:** `--additional-mcp-config ` (session-specific). + +> **Migration note:** Older Squad versions wrote the per-repo config to `.copilot/mcp-config.json`. That path was not auto-loaded by Copilot CLI and required `--additional-mcp-config`. New `squad init` writes `.mcp.json` instead. If your repo still has a `.copilot/mcp-config.json`, it is preserved as-is — see `docs/features/mcp.md` for a manual merge recipe until `squad upgrade` ships a guided merge. ## Sample Config — Trello diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index aeade9aaf..c2af60cb2 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -449,7 +449,7 @@ When spawning agents, include an `MCP TOOLS AVAILABLE` block in the prompt (see Never crash or halt because an MCP tool is missing. MCP tools are enhancements, not dependencies. 1. **CLI fallback** — GitHub MCP missing → use `gh` CLI. Azure MCP missing → use `az` CLI. -2. **Inform the user** — "Trello integration requires the Trello MCP server. Add it to `.copilot/mcp-config.json`." +2. **Inform the user** — "Trello integration requires the Trello MCP server. Add it to `.mcp.json` at the repo root (the path Copilot CLI auto-loads)." 3. **Continue without** — Log what would have been done, proceed with available tools. ### Eager Execution Philosophy diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index 3038834fc..d88b23c4b 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -19,6 +19,7 @@ import type { SubSquadDefinition } from '../streams/types.js'; import { ENGINEERING_ROLE_IDS } from '../roles/catalog.js'; import { getRoleById } from '../roles/index.js'; import { ensureMemoryGovernanceDefaults } from '../memory/index.js'; +import { ensureMcpServerPinned } from '../upgrade/index.js'; // ============================================================================ // Manifest-Curated Skills (must stay in sync with TEMPLATE_MANIFEST in CLI) @@ -715,7 +716,7 @@ function injectMcpFrontmatter(content: string, servers: McpServerSpec[]): string * - .github/agents/squad.agent.md * - .github/workflows/ (optional) * - .squad/templates/ (optional) - * - .copilot/mcp-config.json (optional) + * - .mcp.json (optional; Copilot CLI auto-loaded workspace path) * - Identity files (now.md, wisdom.md) * - ceremonies.md * @@ -1323,7 +1324,11 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre // ------------------------------------------------------------------------- if (mcpConfigMode === 'copilot-file') { - const mcpConfigPath = join(teamRoot, '.copilot', 'mcp-config.json'); + // Write to .mcp.json at the repo root — this is the workspace path that + // GitHub Copilot CLI auto-loads (per `copilot mcp --help`, verified on + // CLI 1.0.58+). The legacy `.copilot/mcp-config.json` is not auto-loaded + // and required the `--additional-mcp-config` flag. See github/copilot-cli#3642. + const mcpConfigPath = join(teamRoot, '.mcp.json'); if (!storage.existsSync(mcpConfigPath)) { const mcpSample = buildMcpConfigJson(mcpServers); await storage.write(mcpConfigPath, JSON.stringify(mcpSample, null, 2) + '\n'); @@ -1331,6 +1336,45 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre } else { skippedFiles.push(toRelativePath(mcpConfigPath)); } + + // Dual-write: if a legacy `.copilot/mcp-config.json` already exists from + // a prior init, keep its `squad_state` pin in sync with the new spec so + // users mid-migration don't lose the pin if they re-init. Never create + // the legacy file on a fresh init — that would defeat the migration. + const squadStateSpec = mcpServers.find(s => s.name === 'squad_state'); + if (squadStateSpec) { + const legacyMcpPath = join(teamRoot, '.copilot', 'mcp-config.json'); + const { name: _name, ...squadStateEntry } = squadStateSpec; + // Worf Condition A: the legacy dual-write is a best-effort mirror; a + // permission error or EISDIR on the legacy path must NOT crash `squad + // init`. Downgrade any thrown error to a warning so the canonical + // `.mcp.json` write (above) stays the source of truth. + try { + const pinResult = ensureMcpServerPinned( + legacyMcpPath, + 'squad_state', + squadStateEntry, + { createIfMissing: false, overwriteOnConflict: true }, + ); + if (pinResult.status === 'updated' || pinResult.status === 'added') { + warnings.push( + `Updated legacy .copilot/mcp-config.json with the latest squad_state pin. ` + + `Run \`squad upgrade\` to fold this file into .mcp.json and delete the legacy copy.`, + ); + } else if (pinResult.status === 'conflict' || pinResult.status === 'malformed') { + warnings.push( + pinResult.warning ?? + 'Legacy .copilot/mcp-config.json present but could not be reconciled — run `squad upgrade`.', + ); + } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code ?? 'UNKNOWN'; + warnings.push( + `Legacy .copilot/mcp-config.json could not be reconciled (${code}): ` + + `${(err as Error).message}. Run \`squad upgrade\` after fixing permissions.`, + ); + } + } } // ------------------------------------------------------------------------- diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index a06142f6a..6fad727dc 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -62,6 +62,7 @@ export * from './sharing/index.js'; export * from './upstream/index.js'; export * from './remote/index.js'; export * from './streams/index.js'; +export * from './upgrade/index.js'; // Builder functions (SDK-First Squad Mode) export { diff --git a/packages/squad-sdk/src/upgrade/index.ts b/packages/squad-sdk/src/upgrade/index.ts new file mode 100644 index 000000000..15aaf2692 --- /dev/null +++ b/packages/squad-sdk/src/upgrade/index.ts @@ -0,0 +1,7 @@ +/** + * Squad upgrade helpers (Phase 2 — MCP config migration). + * + * @module upgrade + */ + +export * from './migrate-mcp-config.js'; diff --git a/packages/squad-sdk/src/upgrade/migrate-mcp-config.ts b/packages/squad-sdk/src/upgrade/migrate-mcp-config.ts new file mode 100644 index 000000000..545818b28 --- /dev/null +++ b/packages/squad-sdk/src/upgrade/migrate-mcp-config.ts @@ -0,0 +1,409 @@ +/** + * Squad upgrade helpers — MCP config migration (Phase 2). + * + * Folds the legacy per-repo `.copilot/mcp-config.json` into the new + * workspace-loaded `.mcp.json`. Also exposes a small shared primitive + * for keeping the `squad_state` server entry in sync across both files + * during the additive deprecation window (used by `squad init` for + * the dual-write path). + * + * Conflict policy per server name (matches Seven's precedence finding — + * `.mcp.json` fully shadows `~/.copilot/mcp-config.json`, no key-level + * merging — so we resolve conflicts on the workspace side defensively): + * - missing in target → copy from source (migrated) + * - present and equivalent → no-op (skipped) + * - present but different → warn + KEEP target (conflict) + * + * Writes are atomic (temp-in-same-dir + rename) so a crashed run never + * leaves Copilot CLI 1.0.58 staring at malformed JSON (which it silently + * drops — exit 0, no stderr, all workspace servers vanish). + * + * @module upgrade/migrate-mcp-config + */ + +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Shape of an MCP server entry as written by Copilot CLI / `squad init`. + * + * Other fields (e.g. `type`, `url`) are tolerated and preserved verbatim, + * but only `command` / `args` / `env` participate in equivalence checks + * because those are the runtime-meaningful fields for the merge. + */ +export interface McpServerEntry { + command?: string; + args?: string[]; + env?: Record; + [key: string]: unknown; +} + +/** Shape of a `.mcp.json` / `.copilot/mcp-config.json` file. */ +export interface McpConfigShape { + mcpServers?: Record; + [key: string]: unknown; +} + +export interface MigrateMcpConfigOptions { + /** + * If `true`, overwrite a malformed `.mcp.json` (the target). Defaults to + * `false` — by default the helper refuses to clobber a file it can't + * understand and returns a `malformed-target` status instead. + */ + force?: boolean; +} + +export type MigrateMcpConfigStatus = + | 'no-legacy' // legacy file absent — nothing to do + | 'empty-legacy' // legacy file present but has no servers + | 'no-op' // legacy servers all match target + | 'migrated' // wrote merged config + | 'malformed-legacy' // could not parse legacy file (no write performed) + | 'malformed-target'; // could not parse target `.mcp.json` (no write performed) + +export interface MigrateMcpConfigResult { + status: MigrateMcpConfigStatus; + /** Number of server names copied from legacy into `.mcp.json`. */ + migrated: number; + /** Server names that were copied. */ + migratedKeys: string[]; + /** Server names that already existed in `.mcp.json` with the same definition. */ + skippedKeys: string[]; + /** Server names that exist in both files with different definitions (target kept). */ + conflicts: string[]; + /** Human-readable warning lines (one per conflict, plus any structural notes). */ + warnings: string[]; + /** Absolute path to the legacy file we read (for diagnostics). */ + legacyPath: string; + /** Absolute path to the workspace `.mcp.json` we wrote (for diagnostics). */ + mcpJsonPath: string; + /** Error message if `status` is `malformed-*`. */ + error?: string; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Migrate legacy `.copilot/mcp-config.json` entries into `.mcp.json`. + * + * Idempotent — re-running after a successful migration returns + * `status: 'no-op'` with `migrated: 0`. + * + * @param repoRoot Absolute path to the repository root (the directory that + * contains `.mcp.json` and/or `.copilot/mcp-config.json`). + * @param options See {@link MigrateMcpConfigOptions}. + */ +export function migrateMcpConfig( + repoRoot: string, + options: MigrateMcpConfigOptions = {}, +): MigrateMcpConfigResult { + const legacyPath = join(repoRoot, '.copilot', 'mcp-config.json'); + const mcpJsonPath = join(repoRoot, '.mcp.json'); + + const baseResult: MigrateMcpConfigResult = { + status: 'no-legacy', + migrated: 0, + migratedKeys: [], + skippedKeys: [], + conflicts: [], + warnings: [], + legacyPath, + mcpJsonPath, + }; + + if (!existsSync(legacyPath)) { + return baseResult; + } + + // -- Read legacy --------------------------------------------------------- + let legacy: McpConfigShape; + try { + legacy = parseJsonFile(legacyPath); + } catch (err) { + return { + ...baseResult, + status: 'malformed-legacy', + error: `Failed to parse legacy MCP config at ${legacyPath}: ${(err as Error).message}`, + }; + } + + const legacyServers = legacy.mcpServers ?? {}; + const legacyKeys = Object.keys(legacyServers); + if (legacyKeys.length === 0) { + return { ...baseResult, status: 'empty-legacy' }; + } + + // -- Read target --------------------------------------------------------- + let target: McpConfigShape = {}; + if (existsSync(mcpJsonPath)) { + try { + target = parseJsonFile(mcpJsonPath); + } catch (err) { + if (!options.force) { + return { + ...baseResult, + status: 'malformed-target', + error: + `Refusing to overwrite malformed .mcp.json at ${mcpJsonPath}: ` + + `${(err as Error).message}. Fix the file by hand or re-run with --force.`, + }; + } + // force: start from empty target (legacy + new merge wins) + target = {}; + } + } + + // -- Merge --------------------------------------------------------------- + const mergedServers: Record = { + ...(target.mcpServers ?? {}), + }; + const migratedKeys: string[] = []; + const skippedKeys: string[] = []; + const conflicts: string[] = []; + const warnings: string[] = []; + + for (const key of legacyKeys) { + const legacyEntry = legacyServers[key]; + if (legacyEntry === undefined) continue; + const existing = mergedServers[key]; + if (existing === undefined) { + mergedServers[key] = legacyEntry; + migratedKeys.push(key); + continue; + } + if (mcpEntriesEquivalent(existing, legacyEntry)) { + skippedKeys.push(key); + continue; + } + conflicts.push(key); + warnings.push( + `MCP server "${key}" exists in both .mcp.json and .copilot/mcp-config.json ` + + `with different definitions; keeping the .mcp.json version. ` + + `Reconcile by hand if the legacy definition was intentional.`, + ); + } + + if (migratedKeys.length === 0) { + return { + ...baseResult, + status: 'no-op', + skippedKeys, + conflicts, + warnings, + }; + } + + const merged: McpConfigShape = { + ...target, + mcpServers: mergedServers, + }; + + atomicWriteJson(mcpJsonPath, merged); + + return { + status: 'migrated', + migrated: migratedKeys.length, + migratedKeys, + skippedKeys, + conflicts, + warnings, + legacyPath, + mcpJsonPath, + }; +} + +/** + * Ensure a single MCP server entry is pinned in an MCP config file. + * + * Used by `squad init` to keep `squad_state` in sync across both + * `.mcp.json` and the legacy `.copilot/mcp-config.json` during the + * additive deprecation window. + * + * - If `filePath` does not exist and `createIfMissing` is false → no-op. + * - If the file is malformed → no-op (returns `status: 'malformed'`), + * never clobbers a file we can't parse. + * - If the server is already present with an equivalent definition → + * no-op (`status: 'no-op'`). + * - If the server is missing or different → write it (atomic). + * + * @returns result object describing what happened. + */ +export interface EnsureMcpServerOptions { + /** Create the file (and `mcpServers` key) if it does not exist. Default: `false`. */ + createIfMissing?: boolean; + /** If true, overwrite a conflicting entry. Default: `false` (keep existing, warn). */ + overwriteOnConflict?: boolean; +} + +export type EnsureMcpServerStatus = + | 'no-op' + | 'created' // file did not exist; we created it (only if createIfMissing) + | 'added' // file existed, server key was missing; we added it + | 'updated' // overwriteOnConflict resolved a conflict + | 'conflict' // entry differs but overwriteOnConflict=false; left as-is + | 'malformed' // file exists but won't parse; not touched + | 'absent'; // file does not exist and createIfMissing=false + +export interface EnsureMcpServerResult { + status: EnsureMcpServerStatus; + filePath: string; + warning?: string; +} + +export function ensureMcpServerPinned( + filePath: string, + serverName: string, + serverEntry: McpServerEntry, + options: EnsureMcpServerOptions = {}, +): EnsureMcpServerResult { + const { createIfMissing = false, overwriteOnConflict = false } = options; + + if (!existsSync(filePath)) { + if (!createIfMissing) { + return { status: 'absent', filePath }; + } + const fresh: McpConfigShape = { + mcpServers: { [serverName]: serverEntry }, + }; + atomicWriteJson(filePath, fresh); + return { status: 'created', filePath }; + } + + let parsed: McpConfigShape; + try { + parsed = parseJsonFile(filePath); + } catch (err) { + return { + status: 'malformed', + filePath, + warning: `Skipped MCP server pin in ${filePath}: ${(err as Error).message}`, + }; + } + + const servers = { ...(parsed.mcpServers ?? {}) }; + const existing = servers[serverName]; + if (existing === undefined) { + servers[serverName] = serverEntry; + atomicWriteJson(filePath, { ...parsed, mcpServers: servers }); + return { status: 'added', filePath }; + } + if (mcpEntriesEquivalent(existing, serverEntry)) { + return { status: 'no-op', filePath }; + } + if (overwriteOnConflict) { + servers[serverName] = serverEntry; + atomicWriteJson(filePath, { ...parsed, mcpServers: servers }); + return { status: 'updated', filePath }; + } + return { + status: 'conflict', + filePath, + warning: + `MCP server "${serverName}" in ${filePath} differs from the Squad pin; ` + + `leaving the existing entry in place.`, + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function parseJsonFile(filePath: string): McpConfigShape { + const raw = readFileSync(filePath, 'utf-8'); + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return {}; + } + const parsed = JSON.parse(trimmed); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('expected a JSON object at the file root'); + } + return parsed as McpConfigShape; +} + +/** + * Atomic write: serialize to a temp file in the same directory, fsync via + * `writeFileSync`'s flush, then rename over the target. The same-directory + * rule keeps the rename within a single filesystem so the swap is atomic + * on all supported platforms (POSIX rename(2), Windows MoveFileEx). + * + * Exported for re-use by `ensureMcpServerPinned` and for tests that need + * to assert no temp file is left behind on success. + */ +export function atomicWriteJson(filePath: string, value: unknown): void { + const dir = dirname(filePath); + mkdirSync(dir, { recursive: true }); + // `process.pid` + timestamp is sufficient — this is single-process CLI code, + // not concurrent server traffic. + const tempPath = join( + dir, + `.${pathBasename(filePath)}.${process.pid}.${Date.now()}.tmp`, + ); + const serialized = JSON.stringify(value, null, 2) + '\n'; + // Worf Condition B (defense-in-depth): round-trip the payload through + // JSON.parse before touching disk. Closes the silent-fallback hazard + // documented in Seven's precedence research — Copilot CLI 1.0.58 silently + // drops malformed `.mcp.json` with no warning, so we must guarantee we + // never write invalid JSON in the first place. Cheap (<1ms) insurance + // against future refactors that introduce custom `toJSON` methods or + // non-stringify-safe values. + JSON.parse(serialized); + try { + writeFileSync(tempPath, serialized, 'utf-8'); + renameSync(tempPath, filePath); + } catch (err) { + // Best-effort cleanup; the temp file may not exist if writeFileSync threw. + try { unlinkSync(tempPath); } catch { /* ignore */ } + throw err; + } +} + +function pathBasename(p: string): string { + // Avoid importing `basename` from `path` separately — keep imports tight. + const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); + return idx >= 0 ? p.slice(idx + 1) : p; +} + +/** + * Two MCP server entries are equivalent iff their runtime-meaningful fields + * (`command`, `args`, `env`) match. Unknown sibling fields are ignored — + * those are typically Copilot-CLI-managed metadata we don't want to fight + * over. + */ +function mcpEntriesEquivalent(a: McpServerEntry, b: McpServerEntry): boolean { + if ((a.command ?? '') !== (b.command ?? '')) return false; + if (!arraysEqual(a.args ?? [], b.args ?? [])) return false; + if (!envEqual(a.env, b.env)) return false; + return true; +} + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function envEqual(a?: Record, b?: Record): boolean { + const aKeys = a ? Object.keys(a).sort() : []; + const bKeys = b ? Object.keys(b).sort() : []; + if (!arraysEqual(aKeys, bKeys)) return false; + for (const k of aKeys) { + if ((a as Record)[k] !== (b as Record)[k]) return false; + } + return true; +} diff --git a/packages/squad-sdk/templates/mcp-config.md b/packages/squad-sdk/templates/mcp-config.md index f38425e4c..e9f739f54 100644 --- a/packages/squad-sdk/templates/mcp-config.md +++ b/packages/squad-sdk/templates/mcp-config.md @@ -5,10 +5,12 @@ MCP (Model Context Protocol) servers extend Squad with tools for external servic ## Config File Locations Users configure MCP servers at these locations (checked in priority order): -1. **Repository-level:** `.copilot/mcp-config.json` (team-shared, committed to repo) -2. **Workspace-level:** `.vscode/mcp.json` (VS Code workspaces) -3. **User-level:** `~/.copilot/mcp-config.json` (personal) -4. **CLI override:** `--additional-mcp-config` flag (session-specific) +1. **Repository-level (auto-loaded):** `.mcp.json` at the repo root — team-shared, committed; this is the workspace path Copilot CLI auto-loads (see `copilot mcp --help`). +2. **Workspace-level:** `.vscode/mcp.json` (VS Code workspaces). +3. **User-level:** `~/.copilot/mcp-config.json` (personal, user-wide). +4. **CLI override:** `--additional-mcp-config ` (session-specific). + +> **Migration note:** Older Squad versions wrote the per-repo config to `.copilot/mcp-config.json`. That path was not auto-loaded by Copilot CLI and required `--additional-mcp-config`. New `squad init` writes `.mcp.json` instead. If your repo still has a `.copilot/mcp-config.json`, it is preserved as-is — see `docs/features/mcp.md` for a manual merge recipe until `squad upgrade` ships a guided merge. ## Sample Config — Trello diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index aeade9aaf..c2af60cb2 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -449,7 +449,7 @@ When spawning agents, include an `MCP TOOLS AVAILABLE` block in the prompt (see Never crash or halt because an MCP tool is missing. MCP tools are enhancements, not dependencies. 1. **CLI fallback** — GitHub MCP missing → use `gh` CLI. Azure MCP missing → use `az` CLI. -2. **Inform the user** — "Trello integration requires the Trello MCP server. Add it to `.copilot/mcp-config.json`." +2. **Inform the user** — "Trello integration requires the Trello MCP server. Add it to `.mcp.json` at the repo root (the path Copilot CLI auto-loads)." 3. **Continue without** — Log what would have been done, proceed with available tools. ### Eager Execution Philosophy diff --git a/templates/mcp-config.md b/templates/mcp-config.md index f38425e4c..e9f739f54 100644 --- a/templates/mcp-config.md +++ b/templates/mcp-config.md @@ -5,10 +5,12 @@ MCP (Model Context Protocol) servers extend Squad with tools for external servic ## Config File Locations Users configure MCP servers at these locations (checked in priority order): -1. **Repository-level:** `.copilot/mcp-config.json` (team-shared, committed to repo) -2. **Workspace-level:** `.vscode/mcp.json` (VS Code workspaces) -3. **User-level:** `~/.copilot/mcp-config.json` (personal) -4. **CLI override:** `--additional-mcp-config` flag (session-specific) +1. **Repository-level (auto-loaded):** `.mcp.json` at the repo root — team-shared, committed; this is the workspace path Copilot CLI auto-loads (see `copilot mcp --help`). +2. **Workspace-level:** `.vscode/mcp.json` (VS Code workspaces). +3. **User-level:** `~/.copilot/mcp-config.json` (personal, user-wide). +4. **CLI override:** `--additional-mcp-config ` (session-specific). + +> **Migration note:** Older Squad versions wrote the per-repo config to `.copilot/mcp-config.json`. That path was not auto-loaded by Copilot CLI and required `--additional-mcp-config`. New `squad init` writes `.mcp.json` instead. If your repo still has a `.copilot/mcp-config.json`, it is preserved as-is — see `docs/features/mcp.md` for a manual merge recipe until `squad upgrade` ships a guided merge. ## Sample Config — Trello diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index aeade9aaf..c2af60cb2 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -449,7 +449,7 @@ When spawning agents, include an `MCP TOOLS AVAILABLE` block in the prompt (see Never crash or halt because an MCP tool is missing. MCP tools are enhancements, not dependencies. 1. **CLI fallback** — GitHub MCP missing → use `gh` CLI. Azure MCP missing → use `az` CLI. -2. **Inform the user** — "Trello integration requires the Trello MCP server. Add it to `.copilot/mcp-config.json`." +2. **Inform the user** — "Trello integration requires the Trello MCP server. Add it to `.mcp.json` at the repo root (the path Copilot CLI auto-loads)." 3. **Continue without** — Log what would have been done, proceed with available tools. ### Eager Execution Philosophy diff --git a/test/cli/init.test.ts b/test/cli/init.test.ts index 6a8ad1797..f015031d2 100644 --- a/test/cli/init.test.ts +++ b/test/cli/init.test.ts @@ -4,9 +4,9 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdir, rm, readdir, readFile } from 'fs/promises'; +import { mkdir, rm, readdir, readFile, writeFile } from 'fs/promises'; import { join } from 'path'; -import { existsSync } from 'fs'; +import { existsSync, chmodSync } from 'fs'; import { tmpdir } from 'os'; import { randomBytes } from 'crypto'; import { runInit } from '@bradygaster/squad-cli/core/init'; @@ -84,12 +84,12 @@ describe('CLI: init command', () => { expect(wisdomContent).toContain('Team Wisdom'); }); - it('should create .copilot/mcp-config.json', async () => { + it('should create .mcp.json at repo root (Copilot CLI auto-loaded workspace path)', async () => { await runInit(TEST_ROOT); - - const mcpPath = join(TEST_ROOT, '.copilot', 'mcp-config.json'); + + const mcpPath = join(TEST_ROOT, '.mcp.json'); expect(existsSync(mcpPath)).toBe(true); - + const content = await readFile(mcpPath, 'utf-8'); const config = JSON.parse(content); expect(config).toHaveProperty('mcpServers'); @@ -97,12 +97,17 @@ describe('CLI: init command', () => { expect(config.mcpServers.squad_state).not.toHaveProperty('env'); expect(content).not.toContain('SQUAD_TEAM_ROOT'); expect(content).not.toContain(TEST_ROOT); + + // Legacy .copilot/mcp-config.json should NOT be created by new init + // (preservation of existing legacy file is handled in test/mcp-config.test.cjs) + const legacyPath = join(TEST_ROOT, '.copilot', 'mcp-config.json'); + expect(existsSync(legacyPath)).toBe(false); }); it('should write MCP config into agent frontmatter when requested', async () => { await runInit(TEST_ROOT, { mcpFrontmatter: true }); - const mcpPath = join(TEST_ROOT, '.copilot', 'mcp-config.json'); + const mcpPath = join(TEST_ROOT, '.mcp.json'); expect(existsSync(mcpPath)).toBe(false); const agentPath = join(TEST_ROOT, '.github', 'agents', 'squad.agent.md'); @@ -244,4 +249,31 @@ describe('CLI: init command', () => { const secondContent = await readFile(agentPath, 'utf-8'); expect(secondContent).toContain(''); }); + + // Worf Condition A: if the legacy .copilot/mcp-config.json can't be written + // (read-only dir, EACCES, EISDIR, etc.), the legacy dual-write mirror must + // degrade to a warning — `squad init` must not crash. Skipped on Windows + // where chmod-based read-only directory simulation is unreliable on NTFS. + it.skipIf(process.platform === 'win32')( + 'does not crash when legacy .copilot/mcp-config.json cannot be reconciled (EACCES)', + async () => { + const copilotDir = join(TEST_ROOT, '.copilot'); + await mkdir(copilotDir, { recursive: true }); + const legacyPath = join(copilotDir, 'mcp-config.json'); + // Empty mcpServers object → ensureMcpServerPinned will take the 'added' + // branch and call atomicWriteJson, which fails on the read-only dir. + await writeFile(legacyPath, '{"mcpServers":{}}\n', 'utf-8'); + chmodSync(copilotDir, 0o555); + + try { + await expect(runInit(TEST_ROOT)).resolves.not.toThrow(); + // Canonical .mcp.json still gets created — primary write happens + // BEFORE the legacy mirror, so the failure doesn't poison it. + expect(existsSync(join(TEST_ROOT, '.mcp.json'))).toBe(true); + } finally { + // Restore perms so afterEach can clean up the tempdir. + chmodSync(copilotDir, 0o755); + } + }, + ); }); diff --git a/test/upgrade-mcp-merge.test.ts b/test/upgrade-mcp-merge.test.ts new file mode 100644 index 000000000..ef347e6f1 --- /dev/null +++ b/test/upgrade-mcp-merge.test.ts @@ -0,0 +1,261 @@ +/** + * Phase 2 coverage for the .copilot/mcp-config.json → .mcp.json migration + * helper. Exercises the SDK helper directly against real tempdirs (it uses + * raw node:fs, so an in-memory storage provider would not exercise the + * atomic temp+rename path). + * + * Locks in: + * 1. No-legacy → no-op + no .mcp.json created + * 2. Legacy present, no .mcp.json → migrated + file written + * 3. Both present, disjoint servers → both kept + * 4. Both present, equivalent entries → skipped (no conflict) + * 5. Both present, conflicting entries → .mcp.json wins, warning emitted + * 6. Malformed .mcp.json → refuses to overwrite, returns malformed-target + * 7. Idempotence: a second run is a no-op + * 8. ensureMcpServerPinned with createIfMissing:false on absent file → absent + * 9. ensureMcpServerPinned updates a stale pin in an existing file + * 10. atomicWriteJson leaves no .tmp leftovers on success + * + * @module test/upgrade-mcp-merge + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + migrateMcpConfig, + ensureMcpServerPinned, + atomicWriteJson, +} from '@bradygaster/squad-sdk'; + +function makeRepo(): string { + return mkdtempSync(join(tmpdir(), 'squad-mcp-merge-')); +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, JSON.stringify(value, null, 2) + '\n', 'utf8'); +} + +function readJson(path: string): any { + return JSON.parse(readFileSync(path, 'utf8')); +} + +describe('migrateMcpConfig', () => { + let repo: string; + + beforeEach(() => { + repo = makeRepo(); + }); + + afterEach(() => { + rmSync(repo, { recursive: true, force: true }); + }); + + it('no-op when neither file exists', () => { + const result = migrateMcpConfig(repo); + expect(result.status).toBe('no-legacy'); + expect(result.migrated).toBe(0); + expect(existsSync(join(repo, '.mcp.json'))).toBe(false); + }); + + it('migrates legacy servers into a new .mcp.json', () => { + writeJson(join(repo, '.copilot', 'mcp-config.json'), { + mcpServers: { + squad_state: { command: 'npx', args: ['-y', '@bradygaster/squad-cli', 'state-mcp'] }, + custom_thing: { command: 'node', args: ['./tool.js'] }, + }, + }); + + const result = migrateMcpConfig(repo); + expect(result.status).toBe('migrated'); + expect(result.migrated).toBe(2); + expect(result.migratedKeys.sort()).toEqual(['custom_thing', 'squad_state']); + + const merged = readJson(join(repo, '.mcp.json')); + expect(Object.keys(merged.mcpServers).sort()).toEqual(['custom_thing', 'squad_state']); + }); + + it('merges disjoint servers from legacy into existing .mcp.json', () => { + writeJson(join(repo, '.mcp.json'), { + mcpServers: { kept: { command: 'echo', args: ['hi'] } }, + }); + writeJson(join(repo, '.copilot', 'mcp-config.json'), { + mcpServers: { added: { command: 'echo', args: ['bye'] } }, + }); + + const result = migrateMcpConfig(repo); + expect(result.status).toBe('migrated'); + expect(result.migratedKeys).toEqual(['added']); + + const merged = readJson(join(repo, '.mcp.json')); + expect(Object.keys(merged.mcpServers).sort()).toEqual(['added', 'kept']); + }); + + it('skips entries that are equivalent between legacy and target', () => { + const entry = { command: 'npx', args: ['-y', '@bradygaster/squad-cli', 'state-mcp'] }; + writeJson(join(repo, '.mcp.json'), { mcpServers: { squad_state: entry } }); + writeJson(join(repo, '.copilot', 'mcp-config.json'), { mcpServers: { squad_state: entry } }); + + const result = migrateMcpConfig(repo); + expect(result.status).toBe('no-op'); + expect(result.migrated).toBe(0); + expect(result.skippedKeys).toEqual(['squad_state']); + expect(result.conflicts).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it('keeps .mcp.json and warns on a conflict', () => { + writeJson(join(repo, '.mcp.json'), { + mcpServers: { squad_state: { command: 'npx', args: ['NEW'] } }, + }); + writeJson(join(repo, '.copilot', 'mcp-config.json'), { + mcpServers: { squad_state: { command: 'npx', args: ['OLD'] } }, + }); + + const result = migrateMcpConfig(repo); + expect(result.status).toBe('no-op'); + expect(result.conflicts).toEqual(['squad_state']); + expect(result.warnings.length).toBe(1); + expect(result.warnings[0]).toMatch(/squad_state/); + + const merged = readJson(join(repo, '.mcp.json')); + expect(merged.mcpServers.squad_state.args).toEqual(['NEW']); + }); + + it('refuses to overwrite a malformed .mcp.json', () => { + writeFileSync(join(repo, '.mcp.json'), '{ this is not json', 'utf8'); + writeJson(join(repo, '.copilot', 'mcp-config.json'), { + mcpServers: { squad_state: { command: 'npx', args: [] } }, + }); + + const result = migrateMcpConfig(repo); + expect(result.status).toBe('malformed-target'); + expect(result.error).toBeTruthy(); + expect(readFileSync(join(repo, '.mcp.json'), 'utf8')).toBe('{ this is not json'); + }); + + it('is idempotent — a second run is a no-op', () => { + writeJson(join(repo, '.copilot', 'mcp-config.json'), { + mcpServers: { squad_state: { command: 'npx', args: ['-y', 'x'] } }, + }); + + const first = migrateMcpConfig(repo); + expect(first.status).toBe('migrated'); + + const second = migrateMcpConfig(repo); + expect(second.status).toBe('no-op'); + expect(second.migrated).toBe(0); + }); + + it('treats an empty legacy mcpServers object as empty-legacy', () => { + writeJson(join(repo, '.copilot', 'mcp-config.json'), { mcpServers: {} }); + const result = migrateMcpConfig(repo); + expect(result.status).toBe('empty-legacy'); + expect(result.migrated).toBe(0); + }); +}); + +describe('ensureMcpServerPinned', () => { + let repo: string; + + beforeEach(() => { + repo = makeRepo(); + }); + + afterEach(() => { + rmSync(repo, { recursive: true, force: true }); + }); + + it('returns absent when file is missing and createIfMissing is false', () => { + const result = ensureMcpServerPinned( + join(repo, '.copilot', 'mcp-config.json'), + 'squad_state', + { command: 'npx', args: [] }, + { createIfMissing: false }, + ); + expect(result.status).toBe('absent'); + expect(existsSync(join(repo, '.copilot', 'mcp-config.json'))).toBe(false); + }); + + it('updates a stale pin in an existing file when overwriteOnConflict is set', () => { + const filePath = join(repo, '.copilot', 'mcp-config.json'); + writeJson(filePath, { + mcpServers: { squad_state: { command: 'npx', args: ['OLD'] } }, + }); + + const result = ensureMcpServerPinned( + filePath, + 'squad_state', + { command: 'npx', args: ['NEW'] }, + { overwriteOnConflict: true }, + ); + expect(result.status).toBe('updated'); + + const after = readJson(filePath); + expect(after.mcpServers.squad_state.args).toEqual(['NEW']); + }); + + it('returns conflict (no write) when overwriteOnConflict is not set', () => { + const filePath = join(repo, '.copilot', 'mcp-config.json'); + writeJson(filePath, { + mcpServers: { squad_state: { command: 'npx', args: ['OLD'] } }, + }); + + const result = ensureMcpServerPinned( + filePath, + 'squad_state', + { command: 'npx', args: ['NEW'] }, + ); + expect(result.status).toBe('conflict'); + const after = readJson(filePath); + expect(after.mcpServers.squad_state.args).toEqual(['OLD']); + }); + + it('is a no-op when the existing entry already matches', () => { + const filePath = join(repo, '.copilot', 'mcp-config.json'); + const entry = { command: 'npx', args: ['-y', 'x'] }; + writeJson(filePath, { mcpServers: { squad_state: entry } }); + + const result = ensureMcpServerPinned(filePath, 'squad_state', entry); + expect(result.status).toBe('no-op'); + }); +}); + +describe('atomicWriteJson', () => { + let repo: string; + + beforeEach(() => { + repo = makeRepo(); + }); + + afterEach(() => { + rmSync(repo, { recursive: true, force: true }); + }); + + it('leaves no .tmp leftovers on success', () => { + const target = join(repo, '.mcp.json'); + atomicWriteJson(target, { mcpServers: { a: { command: 'echo', args: [] } } }); + + expect(existsSync(target)).toBe(true); + const leftovers = readdirSync(repo).filter(name => name.endsWith('.tmp') || name.includes('.tmp.')); + expect(leftovers).toEqual([]); + }); + + it('throws before touching disk when the serialized payload fails JSON.parse (Worf Condition B)', () => { + // Simulate any future regression where stringify produces non-JSON output + // (e.g. a custom toJSON returning a partial-object fragment). The round- + // trip parse must reject BEFORE any tempfile is created. + const target = join(repo, '.mcp.json'); + const spy = vi.spyOn(JSON, 'stringify').mockReturnValueOnce('not valid json {{'); + try { + expect(() => atomicWriteJson(target, { mcpServers: {} })).toThrow(SyntaxError); + expect(existsSync(target)).toBe(false); + const leftovers = readdirSync(repo).filter(name => name.endsWith('.tmp') || name.includes('.tmp.')); + expect(leftovers).toEqual([]); + } finally { + spy.mockRestore(); + } + }); +});