From ab27d89311470048fdadf8791f8bcacc8c6afdc2 Mon Sep 17 00:00:00 2001 From: Dominic Frei Date: Fri, 4 Sep 2026 13:56:48 +0800 Subject: [PATCH] Register the docs MCP server on install install now registers the gaffa docs MCP server in each selected tool's own MCP config, and uninstall removes it again. Four tools keep it as JSON under mcpServers, Codex uses TOML, and the URL field differs per tool (serverUrl for Antigravity), each per the tool's own docs. Merges into an existing config without touching the user's other servers, backs up and refuses a config it cannot parse, and only removes an entry that still points at our URL. Adds --no-mcp to skip it. GAF-669 --- README.md | 13 +- src/cli.ts | 55 ++++++- src/mcp.ts | 251 ++++++++++++++++++++++++++++++++ src/tools.ts | 118 ++++++++++++++- test/mcp.test.js | 362 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 790 insertions(+), 9 deletions(-) create mode 100644 src/mcp.ts create mode 100644 test/mcp.test.js diff --git a/README.md b/README.md index a1d4dce..ef872e5 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,18 @@ longer exist. Until the skills are published to npm, point install at a local checkout with `--skills-dir` or `GAFFA_SKILLS_DIR`. +The same run also registers the Gaffa docs MCP server (`https://gaffa.dev/docs/~gitbook/mcp`) +in each tool's own MCP config, merging into an existing config without touching +your other servers. A config it cannot parse is backed up and left alone. Pass +`--no-mcp` to skip this. At project scope Claude Code will ask you to approve the +server the first time you use it. + ### uninstall -`uninstall` removes the skills a previous install wrote, for a scope. A skill you -have edited since is left in place and reported, so your own changes are never -lost. +`uninstall` removes the skills a previous install wrote, for a scope, and the +docs MCP server it registered. A skill you have edited since is left in place and +reported, so your own changes are never lost. An MCP entry you have re-pointed +elsewhere is left alone. Pass `--no-mcp` to keep the server registered. ``` npx @gaffa-dev/cli uninstall --scope=project diff --git a/src/cli.ts b/src/cli.ts index 73a61b5..e9128c5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { runDoctor, processContext } from "./doctor.js"; import { inspectTools, TOOLS, type DoctorContext, type Scope } from "./tools.js"; import { fetchNpmSource, readLocalSource } from "./skills-source.js"; import { install, uninstall, type InstallResult, type UninstallResult } from "./install.js"; +import { registerMcp, unregisterMcp, type McpResult } from "./mcp.js"; const pkg = JSON.parse( readFileSync(new URL("../package.json", import.meta.url), "utf8"), @@ -22,15 +23,18 @@ Commands doctor Report which AI coding tools are installed and whether the gaffa skills are set up in them. Add --json for machine output. install Fetch the latest gaffa skills from npm and copy them into the - tools you pick. + tools you pick, and register the gaffa docs MCP server in each. --tools=a,b tool ids, default the installed ones --scope=project or personal, default project --skills-dir=PATH read the skills from a local checkout instead of npm, or set GAFFA_SKILLS_DIR + --no-mcp skip registering the docs MCP server -y, --yes take the defaults, do not prompt - uninstall Remove skills a previous install wrote, for a scope. A skill you - edited since is left in place and reported. + uninstall Remove skills a previous install wrote, for a scope, and the + docs MCP server. A skill you edited since is left in place and + reported. --scope=project or personal, default project + --no-mcp leave the docs MCP server in place -y, --yes take the defaults, do not prompt Options @@ -123,6 +127,45 @@ function formatUninstall(results: UninstallResult[], ctx: DoctorContext): string return lines.join("\n") + "\n"; } +// The human words for each outcome, kept short. +const MCP_WORDS: Record = { + added: "added", + updated: "updated", + unchanged: "already set", + removed: "removed", + refused: "could not parse, skipped", + skipped: "skipped", +}; + +function formatMcpRegister(results: McpResult[], ctx: DoctorContext): string { + if (results.length === 0) return ""; + const lines = ["", "Registered the gaffa-docs MCP server:"]; + for (const r of results) { + const detail = r.detail ? ` (${r.detail})` : ""; + lines.push(` ${r.label} ${shortPath(r.path, ctx)} ${MCP_WORDS[r.outcome]}${detail}`); + } + // Claude Code prompts for approval of a project-scoped server on first use. + const claudeProject = results.some( + (r) => r.toolId === "claude-code" && r.scope === "project" && (r.outcome === "added" || r.outcome === "updated"), + ); + if (claudeProject) { + lines.push(""); + lines.push("Claude Code will ask you to approve the project MCP server the first time you use it."); + } + return lines.join("\n") + "\n"; +} + +function formatMcpUnregister(results: McpResult[], ctx: DoctorContext): string { + const touched = results.filter((r) => r.outcome !== "skipped" || r.detail); + if (touched.length === 0) return ""; + const lines = ["", "gaffa-docs MCP server:"]; + for (const r of touched) { + const detail = r.detail ? ` (${r.detail})` : ""; + lines.push(` ${r.label} ${shortPath(r.path, ctx)} ${MCP_WORDS[r.outcome]}${detail}`); + } + return lines.join("\n") + "\n"; +} + async function runInstall(flags: Flags): Promise { const ctx = processContext(); const interactive = Boolean(process.stdin.isTTY) && !flags.bools.has("yes"); @@ -169,6 +212,9 @@ async function runInstall(flags: Flags): Promise { } process.stdout.write(formatInstall(install(ctx, { tools, scope, source }), source.version, ctx)); + if (!flags.bools.has("no-mcp")) { + process.stdout.write(formatMcpRegister(registerMcp(ctx, { tools, scope }), ctx)); + } return 0; } @@ -185,6 +231,9 @@ async function runUninstall(flags: Flags): Promise { } process.stdout.write(formatUninstall(uninstall(ctx, scope), ctx)); + if (!flags.bools.has("no-mcp")) { + process.stdout.write(formatMcpUnregister(unregisterMcp(ctx, scope), ctx)); + } return 0; } diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..deeae04 --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,251 @@ +// Register the Gaffa docs MCP server in each tool's own MCP config, and remove +// it again on uninstall. +// +// The same install that writes the skills registers the docs MCP at every +// selected tool that has a known MCP config location for the chosen scope. Each +// tool keeps its servers differently (see tools.ts): four use JSON under a +// `mcpServers` key, Codex uses TOML under `[mcp_servers.NAME]`, and the field +// carrying the URL is `url` for most but `serverUrl` for Antigravity. +// +// We own the name `gaffa-docs`, so a register overwrites our own entry (an +// idempotent refresh) and an uninstall removes it only when it still points at +// our URL, so a server the user re-pointed is left alone. A JSON file we cannot +// parse is backed up and left untouched rather than clobbered. Codex TOML has no +// parser here, so those edits are surgical: append our table, or replace the one +// we recognise by its header. + +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { TOOLS, mcpTarget, type DoctorContext, type McpTarget, type Scope } from "./tools.js"; + +export const MCP_NAME = "gaffa-docs"; +export const MCP_URL = "https://gaffa.dev/docs/~gitbook/mcp"; + +export type McpOutcome = "added" | "updated" | "unchanged" | "removed" | "refused" | "skipped"; + +export interface McpResult { + toolId: string; + label: string; + scope: Scope; + path: string; + outcome: McpOutcome; + // Extra context: the backup path on a refusal, or why we skipped or noted. + detail?: string; +} + +// The server entry we write, in the shape the tool expects. +function buildEntry(target: McpTarget): Record { + const entry: Record = {}; + if (target.type) entry.type = target.type; + entry[target.urlKey] = MCP_URL; + if (target.extra) Object.assign(entry, target.extra); + return entry; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readTextOrNull(path: string): string | null { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +function writeText(path: string, text: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +} + +// A parsed JSON config object, or "malformed" when the text is not a JSON object +// we can safely edit (invalid JSON, or an mcpServers that is not an object). +type ParsedJson = { obj: Record; servers: Record } | "malformed"; + +function parseJson(text: string | null): ParsedJson { + if (text === null || text.trim() === "") return { obj: {}, servers: {} }; + let obj: unknown; + try { + obj = JSON.parse(text); + } catch { + return "malformed"; + } + if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return "malformed"; + const record = obj as Record; + const existing = record.mcpServers; + if (existing === undefined) return { obj: record, servers: {} }; + if (typeof existing !== "object" || existing === null || Array.isArray(existing)) return "malformed"; + return { obj: record, servers: existing as Record }; +} + +function backup(path: string, text: string): string { + const bak = `${path}.gaffa.bak`; + writeFileSync(bak, text); + return bak; +} + +function registerJson(target: McpTarget): Omit { + const text = readTextOrNull(target.path); + const parsed = parseJson(text); + if (parsed === "malformed") { + const bak = backup(target.path, text ?? ""); + return { path: target.path, outcome: "refused", detail: `left it, backed up to ${bak}` }; + } + const { obj, servers } = parsed; + const entry = buildEntry(target); + const existing = servers[MCP_NAME]; + const existingObj = isObject(existing) ? existing : undefined; + const collision = existingObj !== undefined && existingObj[target.urlKey] !== MCP_URL; + if (existing !== undefined && JSON.stringify(existing) === JSON.stringify(entry)) { + return { path: target.path, outcome: "unchanged" }; + } + servers[MCP_NAME] = entry; + obj.mcpServers = servers; + writeText(target.path, JSON.stringify(obj, null, 2) + "\n"); + return { + path: target.path, + outcome: existing === undefined ? "added" : "updated", + detail: collision ? "replaced a different gaffa-docs entry" : undefined, + }; +} + +// Codex TOML. We only ever write our own table, and only touch our own on edits. +const TOML_HEADER = `[mcp_servers.${MCP_NAME}]`; + +function tomlBlock(): string { + return `${TOML_HEADER}\nurl = "${MCP_URL}"\n`; +} + +// A table header line with any trailing comment and surrounding whitespace +// stripped, or null if the line is not a table header. +function tableHeader(line: string): string | null { + if (!line.trimStart().startsWith("[")) return null; + const hash = line.indexOf("#"); + return (hash === -1 ? line : line.slice(0, hash)).trim(); +} + +// Our table, tolerating the equivalent quoted-key spelling of the same name. +function isOurHeader(line: string): boolean { + const h = tableHeader(line); + return h === TOML_HEADER || h === `[mcp_servers."${MCP_NAME}"]`; +} + +// A sub-table of ours, e.g. [mcp_servers.gaffa-docs.http_headers]. +function isOurSubHeader(line: string): boolean { + const h = tableHeader(line); + return h !== null && (h.startsWith(`[mcp_servers.${MCP_NAME}.`) || h.startsWith(`[mcp_servers."${MCP_NAME}".`)); +} + +// The line range [start, end) covering our table and any sub-tables of it. end +// stops before the next unrelated section and does not swallow trailing blank or +// comment lines, which belong to whatever follows. +function findTomlTable(lines: string[]): { start: number; end: number } | null { + const start = lines.findIndex(isOurHeader); + if (start === -1) return null; + let end = start + 1; + while (end < lines.length) { + if (tableHeader(lines[end]) !== null && !isOurHeader(lines[end]) && !isOurSubHeader(lines[end])) break; + end++; + } + while (end > start + 1 && (lines[end - 1].trim() === "" || lines[end - 1].trim().startsWith("#"))) end--; + return { start, end }; +} + +function registerToml(target: McpTarget): Omit { + const text = readTextOrNull(target.path); + const block = tomlBlock(); + if (text === null || text.trim() === "") { + writeText(target.path, block); + return { path: target.path, outcome: "added" }; + } + const lines = text.split("\n"); + const range = findTomlTable(lines); + if (!range) { + const trimmed = text.replace(/\s*$/, ""); + writeText(target.path, `${trimmed}\n\n${block}`); + return { path: target.path, outcome: "added" }; + } + const current = lines.slice(range.start, range.end).join("\n").trimEnd(); + if (current === block.trimEnd()) return { path: target.path, outcome: "unchanged" }; + const tail = lines.slice(range.end); + // Keep a blank line between our table and a following section. + const sep = tail.length && tail[0].trimStart().startsWith("[") ? [""] : []; + const next = [...lines.slice(0, range.start), ...block.trimEnd().split("\n"), ...sep, ...tail]; + const out = next.join("\n"); + writeText(target.path, out.endsWith("\n") ? out : out + "\n"); + return { path: target.path, outcome: "updated" }; +} + +function unregisterJson(target: McpTarget): Omit { + const text = readTextOrNull(target.path); + if (text === null) return { path: target.path, outcome: "skipped" }; + const parsed = parseJson(text); + if (parsed === "malformed") return { path: target.path, outcome: "skipped", detail: "could not parse it, left it" }; + const { obj, servers } = parsed; + const existing = servers[MCP_NAME]; + if (existing === undefined) return { path: target.path, outcome: "skipped" }; + if (!isObject(existing) || existing[target.urlKey] !== MCP_URL) { + return { path: target.path, outcome: "skipped", detail: "left a gaffa-docs that points elsewhere" }; + } + delete servers[MCP_NAME]; + if (Object.keys(servers).length === 0) delete obj.mcpServers; + if (Object.keys(obj).length === 0) rmSync(target.path, { force: true }); + else writeText(target.path, JSON.stringify(obj, null, 2) + "\n"); + return { path: target.path, outcome: "removed" }; +} + +function unregisterToml(target: McpTarget): Omit { + const text = readTextOrNull(target.path); + if (text === null) return { path: target.path, outcome: "skipped" }; + const lines = text.split("\n"); + const range = findTomlTable(lines); + if (!range) return { path: target.path, outcome: "skipped" }; + const block = lines.slice(range.start, range.end).join("\n"); + if (!block.includes(`"${MCP_URL}"`)) { + return { path: target.path, outcome: "skipped", detail: "left a gaffa-docs that points elsewhere" }; + } + // Remove only our table, plus the single blank separator line we added before + // it on append. The rest of the user's config is left untouched, apart from + // normalising the file to a single trailing newline. + let start = range.start; + if (start > 0 && lines[start - 1].trim() === "") start--; + const rest = [...lines.slice(0, start), ...lines.slice(range.end)].join("\n"); + if (rest.trim() === "") rmSync(target.path, { force: true }); + else writeText(target.path, rest.endsWith("\n") ? rest : rest + "\n"); + return { path: target.path, outcome: "removed" }; +} + +export interface RegisterOptions { + tools: string[]; + scope: Scope; +} + +// Register the docs MCP for the selected tools at a scope. A tool with no MCP +// location for that scope is left out of the results, not reported as skipped. +export function registerMcp(ctx: DoctorContext, opts: RegisterOptions): McpResult[] { + const results: McpResult[] = []; + for (const id of opts.tools) { + const tool = TOOLS.find((t) => t.id === id); + if (!tool) continue; + const target = mcpTarget(tool, opts.scope, ctx); + if (!target) continue; + const base = target.format === "toml" ? registerToml(target) : registerJson(target); + results.push({ toolId: tool.id, label: tool.label, scope: opts.scope, ...base }); + } + return results; +} + +// Remove the docs MCP for a scope from every tool that could hold it. Mirrors +// uninstall, which reverses across all tools rather than a chosen set. +export function unregisterMcp(ctx: DoctorContext, scope: Scope): McpResult[] { + const results: McpResult[] = []; + for (const tool of TOOLS) { + const target = mcpTarget(tool, scope, ctx); + if (!target) continue; + const base = target.format === "toml" ? unregisterToml(target) : unregisterJson(target); + if (base.outcome === "skipped" && !base.detail) continue; + results.push({ toolId: tool.id, label: tool.label, scope, ...base }); + } + return results; +} diff --git a/src/tools.ts b/src/tools.ts index 8a2cb07..70d7927 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -23,6 +23,35 @@ interface SkillDir { segments: string[]; } +// A tool's MCP config file at one scope. `project` is relative to the working +// directory, `personal` to the home directory unless `fromConfig` is set, in +// which case it is relative to the tool's resolved config directory. +export interface McpFile { + scope: Scope; + fromConfig?: boolean; + // Base the personal path on the config-env override directory if it is set, + // else the home directory. Used by a file that sits beside the config dir + // rather than inside it, like Claude Code's ~/.claude.json which moves to + // $CLAUDE_CONFIG_DIR/.claude.json when that variable is set. + fromConfigEnv?: boolean; + segments: string[]; +} + +// How to register the gaffa docs MCP server in a tool. Each tool keeps the +// server list under `mcpServers` (JSON) or `[mcp_servers.NAME]` (TOML), but the +// entry shape differs: the field carrying the URL, whether a `type` is required, +// and any fixed extras. All verified against each tool's own docs (GAF-669). +export interface McpConfig { + format: "json" | "toml"; + files: McpFile[]; + // The field name that carries the server URL in an entry. + urlKey: "url" | "serverUrl"; + // A `type` the entry needs, if any (Claude Code and Copilot want "http"). + type?: string; + // Fixed extra fields on the entry (Copilot wants a tools allowlist). + extra?: Record; +} + export interface Tool { id: string; label: string; @@ -31,6 +60,8 @@ export interface Tool { // Config directory under the home directory, the marker that the tool is installed. configSegments: string[]; skillDirs: SkillDir[]; + // How to register the docs MCP server, if we register it for this tool. + mcp?: McpConfig; } // The gaffa skills we look for. A skill copy is a directory named `gaffa-*` that @@ -47,6 +78,17 @@ export const TOOLS: Tool[] = [ { scope: "project", segments: [".claude", "skills"] }, { scope: "personal", fromConfig: true, segments: ["skills"] }, ], + // user scope writes ~/.claude.json (or $CLAUDE_CONFIG_DIR/.claude.json when + // that is set), project scope writes .mcp.json. + mcp: { + format: "json", + files: [ + { scope: "personal", fromConfigEnv: true, segments: [".claude.json"] }, + { scope: "project", segments: [".mcp.json"] }, + ], + urlKey: "url", + type: "http", + }, }, { id: "codex", @@ -58,6 +100,16 @@ export const TOOLS: Tool[] = [ { scope: "project", segments: [".agents", "skills"] }, { scope: "personal", segments: [".agents", "skills"] }, ], + // TOML, not JSON. HTTP transport is supported on current Codex, older + // versions were stdio only, so an old install ignores this entry. + mcp: { + format: "toml", + files: [ + { scope: "personal", fromConfig: true, segments: ["config.toml"] }, + { scope: "project", segments: [".codex", "config.toml"] }, + ], + urlKey: "url", + }, }, { id: "copilot", @@ -70,6 +122,15 @@ export const TOOLS: Tool[] = [ { scope: "personal", fromConfig: true, segments: ["skills"] }, { scope: "personal", segments: [".agents", "skills"] }, ], + // Only the personal file (~/.copilot/mcp-config.json) is documented, so a + // project install writes no Copilot MCP entry. + mcp: { + format: "json", + files: [{ scope: "personal", fromConfig: true, segments: ["mcp-config.json"] }], + urlKey: "url", + type: "http", + extra: { tools: ["*"] }, + }, }, { id: "cursor", @@ -79,6 +140,14 @@ export const TOOLS: Tool[] = [ { scope: "project", segments: [".agents", "skills"] }, { scope: "project", segments: [".claude", "skills"] }, ], + mcp: { + format: "json", + files: [ + { scope: "personal", segments: [".cursor", "mcp.json"] }, + { scope: "project", segments: [".cursor", "mcp.json"] }, + ], + urlKey: "url", + }, }, { id: "antigravity", @@ -92,6 +161,15 @@ export const TOOLS: Tool[] = [ { scope: "project", segments: [".agent", "skills"] }, { scope: "personal", segments: [".gemini", "config", "skills"] }, ], + // Antigravity uses serverUrl for remote servers, not url. + mcp: { + format: "json", + files: [ + { scope: "personal", segments: [".gemini", "config", "mcp_config.json"] }, + { scope: "project", segments: [".agents", "mcp_config.json"] }, + ], + urlKey: "serverUrl", + }, }, ]; @@ -143,10 +221,13 @@ function gaffaSkillsIn(dir: string): string[] { .sort(); } -function resolveConfigPath(tool: Tool, ctx: DoctorContext): string { +function configEnvOverride(tool: Tool, ctx: DoctorContext): string | undefined { const override = tool.configEnv ? ctx.env[tool.configEnv] : undefined; - if (override && override.length > 0) return override; - return join(ctx.home, ...tool.configSegments); + return override && override.length > 0 ? override : undefined; +} + +function resolveConfigPath(tool: Tool, ctx: DoctorContext): string { + return configEnvOverride(tool, ctx) ?? join(ctx.home, ...tool.configSegments); } export interface SkillTarget { @@ -169,6 +250,37 @@ export function skillTargets(tool: Tool, ctx: DoctorContext): SkillTarget[] { }); } +export interface McpTarget { + path: string; + format: "json" | "toml"; + urlKey: "url" | "serverUrl"; + type?: string; + extra?: Record; +} + +// The MCP config file to write for a tool at a scope, or undefined if the tool +// has no known MCP location there. Resolves the path the same way skillTargets +// does: project under the working directory, personal under home unless the +// file follows the tool's config directory. +export function mcpTarget(tool: Tool, scope: Scope, ctx: DoctorContext): McpTarget | undefined { + const mcp = tool.mcp; + if (!mcp) return undefined; + const file = mcp.files.find((f) => f.scope === scope); + if (!file) return undefined; + let base: string; + if (file.scope === "project") base = ctx.cwd; + else if (file.fromConfig) base = resolveConfigPath(tool, ctx); + else if (file.fromConfigEnv) base = configEnvOverride(tool, ctx) ?? ctx.home; + else base = ctx.home; + return { + path: join(base, ...file.segments), + format: mcp.format, + urlKey: mcp.urlKey, + type: mcp.type, + extra: mcp.extra, + }; +} + // Inspect every target tool against the given home, working directory and // environment. Reads the filesystem, writes nothing. export function inspectTools(ctx: DoctorContext): ToolReport[] { diff --git a/test/mcp.test.js b/test/mcp.test.js new file mode 100644 index 0000000..c56df4c --- /dev/null +++ b/test/mcp.test.js @@ -0,0 +1,362 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { registerMcp, unregisterMcp, MCP_NAME, MCP_URL } from "../dist/mcp.js"; + +const cli = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); + +function tmp() { + return mkdtempSync(join(tmpdir(), "gaffa-mcp-")); +} + +function cleanup(...dirs) { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +test("register writes the right entry shape per tool at personal scope", () => { + const home = tmp(), cwd = tmp(); + try { + const ctx = { home, cwd, env: {} }; + registerMcp(ctx, { tools: ["claude-code", "cursor", "antigravity", "copilot"], scope: "personal" }); + + // Claude Code: ~/.claude.json, type http + url. + assert.deepEqual(readJson(join(home, ".claude.json")).mcpServers[MCP_NAME], { + type: "http", + url: MCP_URL, + }); + // Cursor: url only, no type. + assert.deepEqual(readJson(join(home, ".cursor", "mcp.json")).mcpServers[MCP_NAME], { + url: MCP_URL, + }); + // Antigravity: serverUrl, not url. + assert.deepEqual(readJson(join(home, ".gemini", "config", "mcp_config.json")).mcpServers[MCP_NAME], { + serverUrl: MCP_URL, + }); + // Copilot: type http, url, tools allowlist. + assert.deepEqual(readJson(join(home, ".copilot", "mcp-config.json")).mcpServers[MCP_NAME], { + type: "http", + url: MCP_URL, + tools: ["*"], + }); + } finally { + cleanup(home, cwd); + } +}); + +test("register merges into an existing config without dropping the user's keys", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + writeFileSync( + file, + JSON.stringify({ mcpServers: { other: { command: "x" } }, someTopKey: 1 }, null, 2), + ); + + const results = registerMcp({ home, cwd, env: {} }, { tools: ["cursor"], scope: "personal" }); + assert.equal(results[0].outcome, "added"); + + const doc = readJson(file); + assert.equal(doc.someTopKey, 1); // untouched + assert.deepEqual(doc.mcpServers.other, { command: "x" }); // untouched + assert.deepEqual(doc.mcpServers[MCP_NAME], { url: MCP_URL }); // added + } finally { + cleanup(home, cwd); + } +}); + +test("a second register is a no-op reported as unchanged", () => { + const home = tmp(), cwd = tmp(); + try { + const ctx = { home, cwd, env: {} }; + registerMcp(ctx, { tools: ["cursor"], scope: "personal" }); + const again = registerMcp(ctx, { tools: ["cursor"], scope: "personal" }); + assert.equal(again[0].outcome, "unchanged"); + } finally { + cleanup(home, cwd); + } +}); + +test("register backs up and refuses a malformed JSON config", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + writeFileSync(file, "{ not valid json "); + + const results = registerMcp({ home, cwd, env: {} }, { tools: ["cursor"], scope: "personal" }); + assert.equal(results[0].outcome, "refused"); + assert.equal(readFileSync(file, "utf8"), "{ not valid json "); // left as-is + assert.ok(existsSync(`${file}.gaffa.bak`)); // backed up + } finally { + cleanup(home, cwd); + } +}); + +test("register overwrites a gaffa-docs pointing elsewhere and notes the collision", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + writeFileSync(file, JSON.stringify({ mcpServers: { [MCP_NAME]: { url: "https://old.example" } } })); + + const results = registerMcp({ home, cwd, env: {} }, { tools: ["cursor"], scope: "personal" }); + assert.equal(results[0].outcome, "updated"); + assert.match(results[0].detail, /replaced/); + assert.equal(readJson(file).mcpServers[MCP_NAME].url, MCP_URL); + } finally { + cleanup(home, cwd); + } +}); + +test("register adds a TOML table for Codex and preserves existing config", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".codex", "config.toml"); + mkdirSync(join(home, ".codex"), { recursive: true }); + writeFileSync(file, 'model = "gpt-5"\n\n[mcp_servers.other]\ncommand = "x"\n'); + + const results = registerMcp({ home, cwd, env: {} }, { tools: ["codex"], scope: "personal" }); + assert.equal(results[0].outcome, "added"); + + const text = readFileSync(file, "utf8"); + assert.match(text, /model = "gpt-5"/); // preserved + assert.match(text, /\[mcp_servers\.other\]/); // preserved + assert.match(text, new RegExp(`\\[mcp_servers\\.${MCP_NAME}\\]`)); + assert.match(text, new RegExp(`url = "${MCP_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`)); + + // Idempotent. + const again = registerMcp({ home, cwd, env: {} }, { tools: ["codex"], scope: "personal" }); + assert.equal(again[0].outcome, "unchanged"); + } finally { + cleanup(home, cwd); + } +}); + +test("copilot has no project MCP file, so a project register skips it", () => { + const home = tmp(), cwd = tmp(); + try { + const results = registerMcp({ home, cwd, env: {} }, { tools: ["copilot"], scope: "project" }); + assert.equal(results.length, 0); + } finally { + cleanup(home, cwd); + } +}); + +test("unregister removes our entry and leaves the user's other servers", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + writeFileSync(file, JSON.stringify({ mcpServers: { other: { command: "x" } } })); + const ctx = { home, cwd, env: {} }; + registerMcp(ctx, { tools: ["cursor"], scope: "personal" }); + + const results = unregisterMcp(ctx, "personal").filter((r) => r.toolId === "cursor"); + assert.equal(results[0].outcome, "removed"); + const doc = readJson(file); + assert.equal(doc.mcpServers[MCP_NAME], undefined); // gone + assert.deepEqual(doc.mcpServers.other, { command: "x" }); // kept + } finally { + cleanup(home, cwd); + } +}); + +test("unregister leaves a gaffa-docs that points elsewhere", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + writeFileSync(file, JSON.stringify({ mcpServers: { [MCP_NAME]: { url: "https://mine.example" } } })); + + const results = unregisterMcp({ home, cwd, env: {} }, "personal").filter((r) => r.toolId === "cursor"); + assert.equal(results[0].outcome, "skipped"); + assert.equal(readJson(file).mcpServers[MCP_NAME].url, "https://mine.example"); // untouched + } finally { + cleanup(home, cwd); + } +}); + +test("register then unregister restores the Codex TOML file exactly", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".codex", "config.toml"); + mkdirSync(join(home, ".codex"), { recursive: true }); + const original = 'model = "gpt-5"\n\n[mcp_servers.other]\ncommand = "x"\n'; + writeFileSync(file, original); + const ctx = { home, cwd, env: {} }; + + registerMcp(ctx, { tools: ["codex"], scope: "personal" }); + const results = unregisterMcp(ctx, "personal").filter((r) => r.toolId === "codex"); + assert.equal(results[0].outcome, "removed"); + // Surgical: for a normally terminated config, adding then removing our table + // leaves the rest exactly as it was. + assert.equal(readFileSync(file, "utf8"), original); + } finally { + cleanup(home, cwd); + } +}); + +test("claude-code personal MCP follows CLAUDE_CONFIG_DIR, else home", () => { + const home = tmp(), cwd = tmp(), cfg = tmp(), home2 = tmp(); + try { + registerMcp({ home, cwd, env: { CLAUDE_CONFIG_DIR: cfg } }, { tools: ["claude-code"], scope: "personal" }); + assert.ok(existsSync(join(cfg, ".claude.json"))); // the override dir + assert.ok(!existsSync(join(home, ".claude.json"))); // not home + + registerMcp({ home: home2, cwd, env: {} }, { tools: ["claude-code"], scope: "personal" }); + assert.ok(existsSync(join(home2, ".claude.json"))); // falls back to home + } finally { + cleanup(home, cwd, cfg, home2); + } +}); + +test("claude-code project MCP writes .mcp.json in the working dir", () => { + const home = tmp(), cwd = tmp(); + try { + registerMcp({ home, cwd, env: {} }, { tools: ["claude-code"], scope: "project" }); + assert.deepEqual(readJson(join(cwd, ".mcp.json")).mcpServers[MCP_NAME], { type: "http", url: MCP_URL }); + } finally { + cleanup(home, cwd); + } +}); + +test("a null gaffa-docs entry does not crash register or unregister", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + const ctx = { home, cwd, env: {} }; + + writeFileSync(file, JSON.stringify({ mcpServers: { [MCP_NAME]: null } })); + const r = registerMcp(ctx, { tools: ["cursor"], scope: "personal" }); + assert.equal(r[0].outcome, "updated"); // replaced the null with our entry + assert.deepEqual(readJson(file).mcpServers[MCP_NAME], { url: MCP_URL }); + + writeFileSync(file, JSON.stringify({ mcpServers: { [MCP_NAME]: null, other: { url: "x" } } })); + const u = unregisterMcp(ctx, "personal").filter((x) => x.toolId === "cursor"); + assert.equal(u[0].outcome, "skipped"); // not recognisably ours, left alone, no throw + } finally { + cleanup(home, cwd); + } +}); + +test("unregister deletes a config file it emptied", () => { + const home = tmp(), cwd = tmp(); + try { + const ctx = { home, cwd, env: {} }; + registerMcp(ctx, { tools: ["cursor"], scope: "personal" }); + const file = join(home, ".cursor", "mcp.json"); + assert.ok(existsSync(file)); + unregisterMcp(ctx, "personal"); + assert.ok(!existsSync(file)); + } finally { + cleanup(home, cwd); + } +}); + +test("TOML edits preserve a comment and section that follow our table", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".codex", "config.toml"); + mkdirSync(join(home, ".codex"), { recursive: true }); + const original = `[mcp_servers.${MCP_NAME}]\nurl = "${MCP_URL}"\n\n# my own servers\n[mcp_servers.other]\ncommand = "x"\n`; + writeFileSync(file, original); + const ctx = { home, cwd, env: {} }; + + assert.equal(registerMcp(ctx, { tools: ["codex"], scope: "personal" })[0].outcome, "unchanged"); + unregisterMcp(ctx, "personal"); + const text = readFileSync(file, "utf8"); + assert.match(text, /# my own servers/); // comment kept + assert.match(text, /\[mcp_servers\.other\]/); // section kept + assert.doesNotMatch(text, new RegExp(`mcp_servers\\.${MCP_NAME}`)); // ours gone + } finally { + cleanup(home, cwd); + } +}); + +test("TOML register recognises the quoted-key spelling and does not duplicate", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".codex", "config.toml"); + mkdirSync(join(home, ".codex"), { recursive: true }); + writeFileSync(file, `[mcp_servers."${MCP_NAME}"]\nurl = "https://old.example"\n`); + + registerMcp({ home, cwd, env: {} }, { tools: ["codex"], scope: "personal" }); + const text = readFileSync(file, "utf8"); + assert.equal((text.match(/mcp_servers/g) || []).length, 1); // one table, not a duplicate + assert.match(text, new RegExp(`url = "${MCP_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`)); + } finally { + cleanup(home, cwd); + } +}); + +test("TOML unregister removes our sub-table too", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".codex", "config.toml"); + mkdirSync(join(home, ".codex"), { recursive: true }); + writeFileSync( + file, + `[mcp_servers.${MCP_NAME}]\nurl = "${MCP_URL}"\n\n[mcp_servers.${MCP_NAME}.http_headers]\nX = "y"\n\n[mcp_servers.other]\ncommand = "x"\n`, + ); + const ctx = { home, cwd, env: {} }; + + assert.equal(unregisterMcp(ctx, "personal").filter((x) => x.toolId === "codex")[0].outcome, "removed"); + const text = readFileSync(file, "utf8"); + assert.doesNotMatch(text, new RegExp(`mcp_servers\\.${MCP_NAME}`)); // parent and sub-table gone + assert.match(text, /\[mcp_servers\.other\]/); // unrelated section kept + } finally { + cleanup(home, cwd); + } +}); + +// End to end through the built binary, project scope so files land in the temp cwd. +function run(args, cwd) { + try { + const stdout = execFileSync(process.execPath, [cli, ...args], { encoding: "utf8", cwd }); + return { stdout, code: 0 }; + } catch (err) { + return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", code: err.status }; + } +} + +function sourceSkill(srcDir, name) { + mkdirSync(join(srcDir, name), { recursive: true }); + writeFileSync(join(srcDir, name, "SKILL.md"), "skill\n"); +} + +test("cli install registers the docs MCP, and --no-mcp skips it", () => { + const cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + const base = ["install", "--tools=cursor", "--scope=project", `--skills-dir=${src}`, "--yes"]; + + const withMcp = run(base, cwd); + assert.equal(withMcp.code, 0); + assert.match(withMcp.stdout, /Registered the gaffa-docs MCP server/); + assert.equal(readJson(join(cwd, ".cursor", "mcp.json")).mcpServers[MCP_NAME].url, MCP_URL); + + // A fresh cwd with --no-mcp writes no MCP config. + const cwd2 = tmp(); + try { + const noMcp = run([...base, "--no-mcp"], cwd2); + assert.equal(noMcp.code, 0); + assert.doesNotMatch(noMcp.stdout, /MCP server/); + assert.ok(!existsSync(join(cwd2, ".cursor", "mcp.json"))); + } finally { + cleanup(cwd2); + } + } finally { + cleanup(cwd, src); + } +});