Skip to content

Commit ae4a143

Browse files
feat(agents): inject workspace root for VS Code MCP init (#156)
* feat(agents): inject workspace root for VS Code MCP init VS Code / Copilot now get `--root ${workspaceFolder}` via agents init --mcp, matching Cursor so Copilot indexes the open workspace when spawn cwd differs. * test(agents): deepen VS Code MCP workspace-root coverage Address PR review: registry assertion, vscode-only/merge/upgrade tests, stale plan doc refresh, changeset migration note, CLI subprocess check. * docs(plans): mark vscode MCP root fix as shipped (Option A) Resolve plan doc contradiction post-implementation; rename CLI test title. * fix(docs): drop invalid --target copilot from migration text Copilot-only MCP wiring uses --interactive, not a --target flag. * docs(plans): retire vscode MCP workspace-root plan Lift migration + --root rationale into agents.md; delete shipped plan per docs-governance (delete + lift, no slim-and-keep in plans/).
1 parent 946816d commit ae4a143

7 files changed

Lines changed: 161 additions & 11 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stainless-code/codemap": patch
3+
---
4+
5+
`codemap agents init --mcp` now includes `--root ${workspaceFolder}` in the VS Code / Copilot MCP config (`.vscode/mcp.json`), same as Cursor. Re-run `codemap agents init --mcp` to upgrade an existing `.vscode/mcp.json` from older init output (or `--interactive` and select Copilot only).

docs/agents.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Example: `CODEMAP_MCP_TOOLS=query,context,show codemap mcp --no-watch`
142142
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
143143
| Cursor | `.cursor/mcp.json` — PM-resolved spawn + `mcp --watch --root ${workspaceFolder}` (e.g. `npx codemap`, `pnpm exec codemap`, `yarn exec codemap`, `bunx codemap`) |
144144
| Claude Code | `.mcp.json` + `.claude/settings.json``permissions.allow` includes `mcp__codemap__*` |
145-
| VS Code / Copilot | `.vscode/mcp.json``servers.codemap` with `type: stdio` |
145+
| VS Code / Copilot | `.vscode/mcp.json``servers.codemap` with `type: stdio` + `mcp --watch --root ${workspaceFolder}` (PM-resolved spawn, same tail as Cursor) |
146146
| Continue | `.continue/mcpServers/codemap-mcp.json` (JSON `mcpServers`; also accepted from Cursor/Cline exports) |
147147
| Amazon Q Developer | **`.amazonq/default.json`** (IDE canonical) + **`.amazonq/mcp.json`** (legacy workspace; still read when global `useLegacyMcpJson` is true — AWS default). [AWS MCP IDE docs](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/mcp-ide.html) |
148148
| Gemini CLI | `.gemini/settings.json` — top-level `mcpServers.codemap` |
@@ -153,7 +153,7 @@ With **`--mcp`** and no `--target` filter, all **project-local** rows above are
153153

154154
Merge is idempotent: foreign MCP servers and existing settings keys are preserved; only the `codemap` server entry and permission pattern are upserted. **`command` / spawn args are resolved from the project** (when `@stainless-code/codemap` is listed in `package.json`, the local PM runner is used — e.g. `pnpm exec codemap`, `yarn exec codemap`, `bunx codemap`; otherwise PM dlx of `@stainless-code/codemap@latest` — e.g. `npx @stainless-code/codemap@latest`, `pnpm dlx @stainless-code/codemap@latest`, `yarn dlx @stainless-code/codemap@latest`; yarn classic may fall back to `npx` per `package-manager-detector`; Bun uses **`bunx`**, not `bun x`). Init logs the chosen invocation (`MCP CLI: …`).
155155

156-
**Side-effect-only re-runs:** When `.agents/` already exists, `codemap agents init --mcp`, **`--interactive`** target wiring (or any explicit integration targets), or `--git-hooks` still apply without `--force`. `codemap agents init --no-git-hooks --mcp` uninstalls hook blocks and writes MCP even when `.agents/` is absent. Template refresh still requires `--force`. Unparseable MCP JSON and invalid `mcpServers` / `servers` **shape** are **rejected** (fix the file manually — init never wipes or resets those maps, even with `--force`). Foreign MCP servers in a valid map are always preserved on merge.
156+
**Side-effect-only re-runs:** When `.agents/` already exists, `codemap agents init --mcp`, **`--interactive`** target wiring (or any explicit integration targets), or `--git-hooks` still apply without `--force`. `codemap agents init --no-git-hooks --mcp` uninstalls hook blocks and writes MCP even when `.agents/` is absent. Template refresh still requires `--force`. Unparseable MCP JSON and invalid `mcpServers` / `servers` **shape** are **rejected** (fix the file manually — init never wipes or resets those maps, even with `--force`). Foreign MCP servers in a valid map are always preserved on merge. Re-runs upsert the codemap server entry in place (e.g. add `--root ${workspaceFolder}` on VS Code when an older `.vscode/mcp.json` omitted it). Cursor and VS Code get explicit `--root` because host docs do not guarantee stdio spawn `cwd` equals the workspace folder.
157157

158158
## Section assembler and `*.gen.md`
159159

src/agents-init-mcp-registry.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ import {
1010
} from "./agents-init-mcp-registry";
1111

1212
describe("AGENTS_INIT_MCP_REGISTRY", () => {
13+
it("cursor and vscode inject workspace root via registry flag", () => {
14+
expect(getAgentsInitMcpTargetDef("cursor").workspaceRootArg).toBe(true);
15+
expect(getAgentsInitMcpTargetDef("vscode").workspaceRootArg).toBe(true);
16+
expect(
17+
getAgentsInitMcpTargetDef("claude-code").workspaceRootArg,
18+
).toBeUndefined();
19+
});
20+
1321
it("has unique ids", () => {
1422
const ids = AGENTS_INIT_MCP_REGISTRY.map((def) => def.id);
1523
expect(new Set(ids).size).toBe(ids.length);

src/agents-init-mcp-registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface AgentsInitMcpTargetDef {
3131
readonly docsUrl: string;
3232
/** Written by default when `--mcp` has no integration filter. */
3333
readonly defaultOnMcp: boolean;
34-
/** Cursor: inject `--root ${workspaceFolder}`. */
34+
/** Cursor / VS Code: inject `--root ${workspaceFolder}`. */
3535
readonly workspaceRootArg?: boolean | undefined;
3636
/** Matching `agents init --interactive` integration pick, when any. */
3737
readonly integrationTarget?: AgentsInitTarget | undefined;
@@ -77,6 +77,7 @@ export const AGENTS_INIT_MCP_REGISTRY: readonly AgentsInitMcpTargetDef[] =
7777
docsUrl:
7878
"https://code.visualstudio.com/docs/copilot/reference/mcp-configuration",
7979
defaultOnMcp: true,
80+
workspaceRootArg: true,
8081
integrationTarget: "copilot",
8182
},
8283
{

src/agents-init-mcp.test.ts

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function seedBunInstalledCodemapProject(dir: string): void {
6464
}
6565

6666
describe("buildCodemapMcpSpawn", () => {
67-
it("includes workspace root for Cursor", () => {
67+
it("includes workspace root when includeWorkspaceRoot is true", () => {
6868
expect(buildCodemapMcpSpawn(NPM_LOCAL_INVOCATION, true)).toEqual({
6969
command: "npx",
7070
args: ["codemap", "mcp", "--watch", "--root", "${workspaceFolder}"],
@@ -179,13 +179,13 @@ describe("mergeCodemapVsCodeServer", () => {
179179
other: { command: "npx", args: ["-y", "other"] },
180180
},
181181
},
182-
buildCodemapMcpSpawn(NPM_LOCAL_INVOCATION, false),
182+
buildCodemapMcpSpawn(NPM_LOCAL_INVOCATION, true),
183183
);
184184
expect(merged.servers?.other?.command).toBe("npx");
185185
expect(merged.servers?.[CODEMAP_MCP_SERVER_KEY]).toEqual({
186186
type: "stdio",
187187
command: "npx",
188-
args: ["codemap", "mcp", "--watch"],
188+
args: ["codemap", "mcp", "--watch", "--root", "${workspaceFolder}"],
189189
});
190190
});
191191
});
@@ -300,7 +300,13 @@ describe("applyAgentsInitMcp", () => {
300300
};
301301
expect(vscode.servers[CODEMAP_MCP_SERVER_KEY]?.type).toBe("stdio");
302302
expect(vscode.servers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("npx");
303-
expect(vscode.servers[CODEMAP_MCP_SERVER_KEY]?.args?.[0]).toBe("codemap");
303+
expect(vscode.servers[CODEMAP_MCP_SERVER_KEY]?.args).toEqual([
304+
"codemap",
305+
"mcp",
306+
"--watch",
307+
"--root",
308+
"${workspaceFolder}",
309+
]);
304310

305311
const amazonDefault = JSON.parse(
306312
readFileSync(join(dir, ".amazonq", "default.json"), "utf-8"),
@@ -376,6 +382,31 @@ describe("applyAgentsInitMcp", () => {
376382
}
377383
});
378384

385+
it("writes project .vscode/mcp.json when vscode target selected", async () => {
386+
const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-vs-"));
387+
try {
388+
seedInstalledCodemapProject(dir);
389+
await applyAgentsInitMcp({ projectRoot: dir, targets: ["vscode"] });
390+
expect(existsSync(join(dir, ".vscode", "mcp.json"))).toBe(true);
391+
expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(false);
392+
const vscode = JSON.parse(
393+
readFileSync(join(dir, ".vscode", "mcp.json"), "utf-8"),
394+
) as {
395+
servers: Record<
396+
string,
397+
{ type: string; command: string; args: string[] }
398+
>;
399+
};
400+
expect(vscode.servers[CODEMAP_MCP_SERVER_KEY]).toEqual({
401+
type: "stdio",
402+
command: "npx",
403+
args: ["codemap", "mcp", "--watch", "--root", "${workspaceFolder}"],
404+
});
405+
} finally {
406+
rmSync(dir, { recursive: true, force: true });
407+
}
408+
});
409+
379410
it("writes project .cline/mcp.json when cline target selected", async () => {
380411
const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-cl-"));
381412
const fakeHome = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-cl-home-"));
@@ -447,6 +478,105 @@ describe("applyAgentsInitMcp", () => {
447478
}
448479
});
449480

481+
it("merges into existing .vscode/mcp.json without clobbering other servers", async () => {
482+
const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-vs-merge-"));
483+
try {
484+
seedInstalledCodemapProject(dir);
485+
mkdirSync(join(dir, ".vscode"), { recursive: true });
486+
writeFileSync(
487+
join(dir, ".vscode", "mcp.json"),
488+
`${JSON.stringify(
489+
{
490+
servers: {
491+
foreign: { type: "stdio", command: "node", args: ["server.js"] },
492+
},
493+
},
494+
null,
495+
2,
496+
)}\n`,
497+
"utf-8",
498+
);
499+
await applyAgentsInitMcp({ projectRoot: dir, targets: ["vscode"] });
500+
const parsed = JSON.parse(
501+
readFileSync(join(dir, ".vscode", "mcp.json"), "utf-8"),
502+
) as {
503+
servers: Record<
504+
string,
505+
{ type?: string; command: string; args: string[] }
506+
>;
507+
};
508+
expect(parsed.servers.foreign).toEqual({
509+
type: "stdio",
510+
command: "node",
511+
args: ["server.js"],
512+
});
513+
expect(parsed.servers[CODEMAP_MCP_SERVER_KEY]?.args).toEqual([
514+
"codemap",
515+
"mcp",
516+
"--watch",
517+
"--root",
518+
"${workspaceFolder}",
519+
]);
520+
} finally {
521+
rmSync(dir, { recursive: true, force: true });
522+
}
523+
});
524+
525+
it("is idempotent on vscode re-run", async () => {
526+
const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-vs-idem-"));
527+
try {
528+
seedInstalledCodemapProject(dir);
529+
await applyAgentsInitMcp({ projectRoot: dir, targets: ["vscode"] });
530+
const before = readFileSync(join(dir, ".vscode", "mcp.json"), "utf-8");
531+
await applyAgentsInitMcp({ projectRoot: dir, targets: ["vscode"] });
532+
expect(readFileSync(join(dir, ".vscode", "mcp.json"), "utf-8")).toBe(
533+
before,
534+
);
535+
} finally {
536+
rmSync(dir, { recursive: true, force: true });
537+
}
538+
});
539+
540+
it("upgrades stale vscode codemap entry without --root on re-run", async () => {
541+
const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-vs-upgrade-"));
542+
try {
543+
seedInstalledCodemapProject(dir);
544+
mkdirSync(join(dir, ".vscode"), { recursive: true });
545+
writeFileSync(
546+
join(dir, ".vscode", "mcp.json"),
547+
`${JSON.stringify(
548+
{
549+
servers: {
550+
[CODEMAP_MCP_SERVER_KEY]: {
551+
type: "stdio",
552+
command: "npx",
553+
args: ["codemap", "mcp", "--watch"],
554+
},
555+
},
556+
},
557+
null,
558+
2,
559+
)}\n`,
560+
"utf-8",
561+
);
562+
await applyAgentsInitMcp({ projectRoot: dir, targets: ["vscode"] });
563+
const parsed = JSON.parse(
564+
readFileSync(join(dir, ".vscode", "mcp.json"), "utf-8"),
565+
) as {
566+
servers: Record<string, { args: string[] }>;
567+
};
568+
expect(parsed.servers[CODEMAP_MCP_SERVER_KEY]?.args).toEqual([
569+
"codemap",
570+
"mcp",
571+
"--watch",
572+
"--root",
573+
"${workspaceFolder}",
574+
]);
575+
} finally {
576+
rmSync(dir, { recursive: true, force: true });
577+
}
578+
});
579+
450580
it("merges into existing .cursor/mcp.json without clobbering other servers", async () => {
451581
const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-"));
452582
try {

src/agents-init-mcp.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export interface ClaudeSettingsFile {
5252
};
5353
}
5454

55-
/** Host-specific codemap MCP entry (Cursor root arg, Amazon Q IDE transport fields, …). */
55+
/** Host-specific codemap MCP entry (workspace-root arg, Amazon Q IDE transport fields, …). */
5656
export function buildMcpServerEntryForDef(
5757
def: Pick<AgentsInitMcpTargetDef, "format" | "workspaceRootArg">,
5858
invocation: ResolvedCodemapInvocation,
@@ -434,8 +434,8 @@ export interface ApplyAgentsInitMcpOptions {
434434
}
435435

436436
/**
437-
* Write MCP config for selected integrations. Cursor uses
438-
* `${workspaceFolder}` root injection; most other clients rely on workspace cwd.
437+
* Write MCP config for selected integrations. Cursor and VS Code get
438+
* `${workspaceFolder}` root injection; other cwd-based clients omit `--root`.
439439
*/
440440
export async function applyAgentsInitMcp(
441441
opts: ApplyAgentsInitMcpOptions,

src/cli.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ describe("CLI unknown / invalid args", () => {
150150
}
151151
});
152152

153-
test("agents init --force --mcp writes .cursor/mcp.json under --root", async () => {
153+
test("agents init --force --mcp writes project MCP configs under --root", async () => {
154154
const dir = mkdtempSync(join(tmpdir(), "codemap-cli-agents-mcp-"));
155155
try {
156156
const { exitCode, err } = await runCli([
@@ -168,6 +168,12 @@ describe("CLI unknown / invalid args", () => {
168168
readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"),
169169
) as { mcpServers: Record<string, { command: string }> };
170170
expect(parsed.mcpServers.codemap?.command).toBe("npx");
171+
expect(existsSync(join(dir, ".vscode", "mcp.json"))).toBe(true);
172+
const vscode = JSON.parse(
173+
readFileSync(join(dir, ".vscode", "mcp.json"), "utf-8"),
174+
) as { servers: Record<string, { args: string[] }> };
175+
expect(vscode.servers.codemap?.args).toContain("--root");
176+
expect(vscode.servers.codemap?.args).toContain("${workspaceFolder}");
171177
} finally {
172178
rmSync(dir, { recursive: true, force: true });
173179
}

0 commit comments

Comments
 (0)