From eee942a5289cd29fdccd501bf97a8af7d393f2e1 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 21 Sep 2026 17:57:46 -0300 Subject: [PATCH 1/2] fix(emitters): emit an MCP variant under its original server name Both emitters keyed the MCP file by the ingredient name, so an MCP variant, which import creates since 0.2.1, was written as "srv--b" and the workspace's own file read as a collision. Two ingredients writing one server name now warn. --- src/emitters/claude-code.ts | 7 +++---- src/emitters/kiro.ts | 8 +++----- src/emitters/shared.ts | 21 +++++++++++++++++++++ test/cli.test.ts | 25 +++++++++++++++++++++++++ test/emitters/claude-code.test.ts | 14 ++++++++++++++ test/emitters/kiro.test.ts | 14 ++++++++++++++ 6 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/emitters/claude-code.ts b/src/emitters/claude-code.ts index 7b8677e..fc6a939 100644 --- a/src/emitters/claude-code.ts +++ b/src/emitters/claude-code.ts @@ -1,6 +1,6 @@ import { serializeFrontmatter } from "../core/frontmatter.js"; import { listFiles } from "../core/forge.js"; -import { appliesTo, outName, textFile } from "./shared.js"; +import { appliesTo, mcpServers, outName, textFile } from "./shared.js"; import type { Emitter, EmitContext, PlannedFile } from "./types.js"; /** @@ -11,7 +11,6 @@ export const claudeCode: Emitter = { target: "claude-code", async emit(ctx) { const out: PlannedFile[] = []; - const mcp: Record = {}; const t = "claude-code"; for (const ing of ctx.resolution.ingredients) { @@ -56,13 +55,13 @@ export const claudeCode: Emitter = { for (const f of m.files) out.push(await anyFile(ctx, ing, f, `.claude/hooks/${f}`, t)); break; case "mcp": - mcp[m.name] = m.server; - break; + break; // collected into .mcp.json below case "steering": break; // Kiro-only by nature } } + const mcp = mcpServers(ctx, t, ".mcp.json"); if (Object.keys(mcp).length) { const json = JSON.stringify({ mcpServers: mcp }, null, 2) + "\n"; out.push(await textFile(ctx, ".mcp.json", json, t, "mcp/*")); diff --git a/src/emitters/kiro.ts b/src/emitters/kiro.ts index 0013988..db10cb0 100644 --- a/src/emitters/kiro.ts +++ b/src/emitters/kiro.ts @@ -1,7 +1,7 @@ import { toCrlf } from "../core/text.js"; import { serializeFrontmatter } from "../core/frontmatter.js"; import { listFiles } from "../core/forge.js"; -import { appliesTo, outName } from "./shared.js"; +import { appliesTo, mcpServers, outName } from "./shared.js"; import type { Emitter, EmitContext, PlannedFile } from "./types.js"; import type { ResolvedIngredient } from "../core/resolve.js"; @@ -79,10 +79,8 @@ export const kiro: Emitter = { } } - const mcp = ctx.resolution.ingredients.filter((i) => i.meta.type === "mcp" && appliesTo(i.meta.targets, t)); - if (mcp.length) { - const servers: Record = {}; - for (const i of mcp) if (i.meta.type === "mcp") servers[i.meta.name] = i.meta.server; + const servers = mcpServers(ctx, t, ".kiro/settings/mcp.json"); + if (Object.keys(servers).length) { out.push(crlf(".kiro/settings/mcp.json", JSON.stringify({ mcpServers: servers }, null, 2) + "\n", "mcp/*")); } return out; diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 5000753..2978c0e 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -20,3 +20,24 @@ export async function textFile(ctx: EmitContext, relPath: string, text: string, export function outName(m: { name: string; as?: string }): string { return m.as ?? m.name; } + +/** + * The MCP servers a target writes into its one JSON file, keyed by `outName` so a variant keeps the + * server name its workspace uses. The file holds one entry per name, so when two ingredients write + * the same name the earlier one is dropped — said out loud, never silently. + */ +export function mcpServers(ctx: EmitContext, target: string, file: string): Record { + // A null prototype, so a server named `__proto__` is an entry and not the object's prototype. + const servers: Record = Object.create(null); + // A Map, so a server named `constructor` or `toString` is not mistaken for one already written. + const writtenBy = new Map(); + for (const ing of ctx.resolution.ingredients) { + if (ing.meta.type !== "mcp" || !appliesTo(ing.meta.targets, target)) continue; + const key = outName(ing.meta); + const prev = writtenBy.get(key); + if (prev) ctx.warn(`${target}: two ingredients write the MCP server "${key}" into ${file}: ${prev} and ${ing.ref} (last wins)`); + servers[key] = ing.meta.server; + writtenBy.set(key, ing.ref); + } + return servers; +} diff --git a/test/cli.test.ts b/test/cli.test.ts index c7b4767..0ab5054 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -86,6 +86,31 @@ describe("cli", () => { expect(r.stdout + r.stderr).not.toContain("ghp_"); }); + it("a second workspace whose MCP server differs imports as a variant and syncs back to its own .mcp.json", async () => { + // Adopt, don't collide: since 0.2.1 the differing server becomes mcp/srv--b; it must be + // emitted under its original name, or the workspace's own .mcp.json reads as a collision. + const root = await tmpDir("craftar-cli-import-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const forge = path.join(root, "forge"); + const ws = (name: string, args: string[]) => + writeFiles(path.join(root, name), { + ".claude/rules/workflow.md": "# Workflow\n", + ".mcp.json": JSON.stringify({ mcpServers: { srv: { command: "npx", args } } }, null, 2) + "\n", + }); + await ws("a", ["public-server"]); + await ws("b", ["acme-server"]); + expect(runCli(["import", "--from", "claude-code", "--workspace", path.join(root, "a"), "--forge", forge, "--profile", "a"]).code).toBe(0); + const imp = runCli(["import", "--from", "claude-code", "--workspace", path.join(root, "b"), "--forge", forge, "--profile", "b", "--write-config"]); + expect(imp.code).toBe(0); + expect(await exists(path.join(forge, "ingredients/mcp/srv--b"))).toBe(true); + + const st = runCli(["status", "--workspace", path.join(root, "b"), "--json"]); + expect(st.code).toBe(0); + const mcp = JSON.parse(st.stdout).statuses.find((s: { path: string }) => s.path === ".mcp.json"); + expect(mcp.state).not.toBe("collision"); + expect(["adopt", "unchanged"]).toContain(mcp.state); + }); + it("forge variants lists variants as JSON and leaves the Forge untouched", async () => { const root = await tmpDir("craftar-cli-forge-"); cleanups.push(() => fs.rm(root, { recursive: true, force: true })); diff --git a/test/emitters/claude-code.test.ts b/test/emitters/claude-code.test.ts index 7d88df8..b92f069 100644 --- a/test/emitters/claude-code.test.ts +++ b/test/emitters/claude-code.test.ts @@ -52,6 +52,20 @@ describe("claude-code emitter", () => { expect(text(p, ".mcp.json")).toBe(JSON.stringify({ mcpServers: { pw: { command: "npx", args: ["-y", "pw"] }, docs: { url: "https://mcp.example.com/sse" } } }, null, 2) + "\n"); }); + it("emits an MCP variant under its original server name", async () => { + const p = await planFor([{ meta: { type: "mcp", name: "srv--acme", as: "srv", server: { command: "npx", args: ["acme-server"] } } }]); + expect(text(p, ".mcp.json")).toBe(JSON.stringify({ mcpServers: { srv: { command: "npx", args: ["acme-server"] } } }, null, 2) + "\n"); + }); + + it("warns when two MCP ingredients emit the same server name, instead of dropping one silently", async () => { + const p = await planFor([ + { meta: { type: "mcp", name: "srv", server: { command: "npx", args: ["public-server"] } } }, + { meta: { type: "mcp", name: "srv--acme", as: "srv", server: { command: "npx", args: ["acme-server"] } } }, + ]); + expect(p.warnings).toContain('claude-code: two ingredients write the MCP server "srv" into .mcp.json: mcp/srv and mcp/srv--acme (last wins)'); + expect(text(p, ".mcp.json")).toBe(JSON.stringify({ mcpServers: { srv: { command: "npx", args: ["acme-server"] } } }, null, 2) + "\n"); + }); + it("keeps the BOM of the file it replaces", async () => { const p = await planFor([rule("a", "# A\n")], { ".claude/rules/a.md": "\uFEFF# old\n" }); const f = p.files.find((x) => x.path === ".claude/rules/a.md")!; diff --git a/test/emitters/kiro.test.ts b/test/emitters/kiro.test.ts index dba40a4..636f14f 100644 --- a/test/emitters/kiro.test.ts +++ b/test/emitters/kiro.test.ts @@ -82,4 +82,18 @@ describe("kiro emitter", () => { const p = await planFor([{ meta: { type: "script", name: "hello", files: ["hello.ps1"], targets: ["claude-code"] }, files: { "hello.ps1": "x\n" } }]); expect(p.warnings.filter((w) => w.startsWith("kiro: script"))).toEqual([]); }); + + it("emits an MCP variant under its original server name", async () => { + const p = await planFor([{ meta: { type: "mcp", name: "srv--acme", as: "srv", server: { command: "npx", args: ["acme-server"] } } }]); + const json = JSON.parse(file(p, ".kiro/settings/mcp.json")!.content.toString("utf8")); + expect(Object.keys(json.mcpServers)).toEqual(["srv"]); + }); + + it("warns when two MCP ingredients emit the same server name, instead of dropping one silently", async () => { + const p = await planFor([ + { meta: { type: "mcp", name: "srv", server: { command: "npx", args: ["public-server"] } } }, + { meta: { type: "mcp", name: "srv--acme", as: "srv", server: { command: "npx", args: ["acme-server"] } } }, + ]); + expect(p.warnings).toContain('kiro: two ingredients write the MCP server "srv" into .kiro/settings/mcp.json: mcp/srv and mcp/srv--acme (last wins)'); + }); }); From 99427fcb8cea899b63449cde0b8c0361b2861670 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 21 Sep 2026 18:23:11 -0300 Subject: [PATCH 2/2] chore: update version 0.2.2: patch, for the MCP emitter fix. --- package-lock.json | 4 ++-- package.json | 2 +- src/cli.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index a20f68a..66db67d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 48a7874..6e989c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.2.1", + "version": "0.2.2", "description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.", "license": "MIT", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index 1177134..8655401 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,7 +25,7 @@ import { UnifyPlanSchema, type IngredientRef, type Take, type UnifyPlan } from " process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE") process.exit(0); }); const program = new Command(); -program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.2.1"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.2.2"); /* ---------------------------------------------------------------- import */ program