@pi-archimedes/mcp 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +39 -0
  4. package/src/auth-flow.test.ts +583 -0
  5. package/src/auth-flow.ts +310 -0
  6. package/src/auth-run.test.ts +309 -0
  7. package/src/auth-run.ts +146 -0
  8. package/src/auth-storage.test.ts +338 -0
  9. package/src/auth-storage.ts +330 -0
  10. package/src/auto-auth.test.ts +231 -0
  11. package/src/auto-auth.ts +135 -0
  12. package/src/callback-server.test.ts +446 -0
  13. package/src/callback-server.ts +538 -0
  14. package/src/commands-auth.test.ts +320 -0
  15. package/src/commands-auth.ts +128 -0
  16. package/src/commands.test.ts +834 -0
  17. package/src/commands.ts +424 -0
  18. package/src/config-write.test.ts +213 -0
  19. package/src/config-write.ts +207 -0
  20. package/src/config.test.ts +468 -0
  21. package/src/config.ts +278 -0
  22. package/src/direct-tools.test.ts +473 -0
  23. package/src/direct-tools.ts +250 -0
  24. package/src/host-configs.test.ts +231 -0
  25. package/src/host-configs.ts +106 -0
  26. package/src/index.test.ts +689 -0
  27. package/src/index.ts +146 -0
  28. package/src/lifecycle.test.ts +274 -0
  29. package/src/lifecycle.ts +77 -0
  30. package/src/metadata-cache.test.ts +383 -0
  31. package/src/metadata-cache.ts +231 -0
  32. package/src/npx-resolver.test.ts +142 -0
  33. package/src/npx-resolver.ts +126 -0
  34. package/src/oauth-provider.test.ts +404 -0
  35. package/src/oauth-provider.ts +197 -0
  36. package/src/oauth-types.ts +54 -0
  37. package/src/panel-rows.ts +210 -0
  38. package/src/panel.test.ts +298 -0
  39. package/src/panel.ts +742 -0
  40. package/src/proxy-tool.ts +524 -0
  41. package/src/renderer.test.ts +326 -0
  42. package/src/renderer.ts +239 -0
  43. package/src/schema-validator.test.ts +56 -0
  44. package/src/schema-validator.ts +42 -0
  45. package/src/server-client.test.ts +1001 -0
  46. package/src/server-client.ts +576 -0
  47. package/src/server-manager.ts +139 -0
  48. package/src/setup-panel.test.ts +162 -0
  49. package/src/setup-panel.ts +715 -0
  50. package/src/tool-naming.test.ts +168 -0
  51. package/src/tool-naming.ts +114 -0
  52. package/src/types.ts +162 -0
@@ -0,0 +1,424 @@
1
+ /**
2
+ * The `/mcp` command (plan-027, Task 2): a single dispatcher with text
3
+ * subcommands. The standalone `/mcp-auth` and `/mcp-logout` commands are
4
+ * retired — they live on as `/mcp auth <server>` and `/mcp logout <server>`
5
+ * on top of the shared fns in `commands-auth.ts` (unchanged UX).
6
+ *
7
+ * Dispatch: the first whitespace-separated token of the raw args string
8
+ * selects the subcommand; `""` parses as `status`, and the dispatcher
9
+ * special-cases the bare call (not merely the parser) — with a TUI
10
+ * (`ctx.hasUI`) bare `/mcp` opens the management panel, without one it
11
+ * reports the text status. An explicit `status` is ALWAYS the text list.
12
+ * `panel` opens the management panel (lazy-loaded from `panel.ts`; with
13
+ * zero configured servers it redirects to `setup` instead); `setup` opens
14
+ * the onboarding panel (lazy-loaded from `setup-panel.ts`).
15
+ *
16
+ * Dependency discipline: only what the subcommands touch is injected
17
+ * (manager / defs / cache readers). `writeServerDisabled`, `deleteAuthEntry`
18
+ * (inside `mcpLogoutServer`), `extractOAuthConfig` and `loadMetadataCache`
19
+ * are imported directly — none of them need a seam.
20
+ */
21
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
22
+ import { isHttpDef } from "./config.js";
23
+ import { mcpLogoutServer, runMcpAuthCommand } from "./commands-auth.js";
24
+ import { writeServerDisabled } from "./config-write.js";
25
+ import { loadMetadataCache, recordClientOutcome } from "./metadata-cache.js";
26
+ import type { ServerManager } from "./server-manager.js";
27
+ import type { CachedTool, ServerDef, ServerOutcomeRecord } from "./types.js";
28
+
29
+ /** Dependencies injected by index.ts — the seams every subcommand touches. */
30
+ export interface McpCommandDeps {
31
+ /** Module-level singleton via getter (session-resilient across /reload). */
32
+ getManager: () => ServerManager;
33
+ /** loadAllServerDefs() — INCLUDES disabled servers (they have status). */
34
+ getServerDefs: () => Record<string, ServerDef>;
35
+ getCachedTools: (serverName: string, def: ServerDef) => CachedTool[] | undefined;
36
+ getCachedPrompts: (serverName: string, def: ServerDef) => Array<{ name: string; description?: string }> | undefined;
37
+ }
38
+
39
+ /** Result of splitting the raw args string into subcommand + rest. */
40
+ export interface ParsedSubcommand {
41
+ subcommand: string;
42
+ rest: string[];
43
+ }
44
+
45
+ /**
46
+ * Split the raw `/mcp` args string: first whitespace-separated token is the
47
+ * subcommand, the remainder is `rest`. An empty (or whitespace-only) string
48
+ * defaults to `status`; the dispatcher then decides what a bare call does
49
+ * (panel in a TUI, text status otherwise) — see `registerMcpCommand`.
50
+ */
51
+ export function parseMcpSubcommand(args: string): ParsedSubcommand {
52
+ const tokens = args.split(/\s+/).filter((t) => t.length > 0);
53
+ if (tokens.length === 0) return { subcommand: "status", rest: [] };
54
+ return { subcommand: tokens[0]!, rest: tokens.slice(1) };
55
+ }
56
+
57
+ const USAGE =
58
+ "Usage: /mcp [status | tools [server] | prompts [server] | reconnect [server] | " +
59
+ "enable <server> | disable <server> | logout <server> | auth <server> | panel | setup]";
60
+
61
+ /** Register the `/mcp` dispatcher command on pi. */
62
+ export function registerMcpCommand(pi: ExtensionAPI, deps: McpCommandDeps): void {
63
+ pi.registerCommand("mcp", {
64
+ description:
65
+ "Manage MCP servers (opens the management panel with no args; Usage: /mcp [status | tools | prompts | reconnect | enable | disable | logout | auth | panel | setup] …)",
66
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
67
+ // A bare call (no token at all, not even `status`) is special-cased in
68
+ // the dispatcher, not the parser: panel in a TUI, text status without
69
+ // one. Explicit `status` always stays the text list.
70
+ const bare = args.trim() === "";
71
+ const { subcommand, rest } = parseMcpSubcommand(args);
72
+ try {
73
+ switch (subcommand) {
74
+ case "status":
75
+ if (bare && ctx.hasUI) {
76
+ await cmdPanel(pi, deps, ctx);
77
+ } else {
78
+ await cmdStatus(deps, ctx);
79
+ }
80
+ break;
81
+ case "tools":
82
+ cmdTools(deps, rest[0], ctx);
83
+ break;
84
+ case "prompts":
85
+ cmdPrompts(deps, rest[0], ctx);
86
+ break;
87
+ case "reconnect":
88
+ await cmdReconnect(deps, rest[0], ctx);
89
+ break;
90
+ case "enable":
91
+ await cmdToggleEnabled(deps, rest[0], ctx, false);
92
+ break;
93
+ case "disable":
94
+ await cmdToggleEnabled(deps, rest[0], ctx, true);
95
+ break;
96
+ case "logout":
97
+ await cmdLogout(deps, rest[0], ctx);
98
+ break;
99
+ case "auth":
100
+ await cmdAuth(deps, rest[0], ctx);
101
+ break;
102
+ case "panel":
103
+ await cmdPanel(pi, deps, ctx);
104
+ break;
105
+ case "setup":
106
+ await cmdSetup(pi, ctx);
107
+ break;
108
+ default:
109
+ ctx.ui.notify(USAGE, "info");
110
+ }
111
+ } catch (e) {
112
+ // Subcommand failures (e.g. a refused config write-back) surface as
113
+ // a single error notification rather than an unhandled rejection.
114
+ ctx.ui.notify(`/mcp ${subcommand}: ${e instanceof Error ? e.message : String(e)}`, "error");
115
+ }
116
+ },
117
+ });
118
+ }
119
+
120
+ // ── status ──────────────────────────────────────────────────────────────────
121
+
122
+ function cmdStatus(deps: McpCommandDeps, ctx: ExtensionCommandContext): void {
123
+ const defs = deps.getServerDefs();
124
+ const names = Object.keys(defs);
125
+ if (names.length === 0) {
126
+ ctx.ui.notify("No MCP servers configured — run /mcp setup", "info");
127
+ return;
128
+ }
129
+ const outcomes = loadMetadataCache().serverStatuses ?? {};
130
+ const lines = names.map((name) =>
131
+ statusLine(name, defs[name]!, deps.getManager(), outcomes[name], deps),
132
+ );
133
+ ctx.ui.notify(lines.join("\n"), "info");
134
+ }
135
+
136
+ function statusLine(
137
+ name: string,
138
+ def: ServerDef,
139
+ manager: ServerManager,
140
+ outcome: ServerOutcomeRecord | undefined,
141
+ deps: McpCommandDeps,
142
+ ): string {
143
+ if (def.disabled === true) {
144
+ return `○ ${name}: disabled (run /mcp enable ${name}, then /reload)`;
145
+ }
146
+ const client = manager.getClient(name);
147
+ const age = ageSuffix(outcome?.at);
148
+ if (client?.status === "connected") {
149
+ return `✓ ${name}: connected (${client.tools.length} tools)`;
150
+ }
151
+ if (client?.status === "needs-auth") {
152
+ return `⚠ ${name}: needs auth${age} — run /mcp auth ${name}`;
153
+ }
154
+ if (client?.status === "error") {
155
+ return `✗ ${name}: error${age} — ${firstLine(client.error) ?? "connect failed"}`;
156
+ }
157
+ // Not connected live — the persisted outcome (ADR 0004) is the freshest
158
+ // truth across sessions.
159
+ if (outcome?.status === "needs-auth") {
160
+ return `⚠ ${name}: needs auth${age} — run /mcp auth ${name}`;
161
+ }
162
+ if (outcome?.status === "error") {
163
+ return `✗ ${name}: error${age} — ${outcome.error ?? "connect failed"}`;
164
+ }
165
+ const cached = deps.getCachedTools(name, def);
166
+ const note = cachedNote(cached);
167
+ if (outcome?.status === "connected") {
168
+ return `○ ${name}: was connected${age}${note}`;
169
+ }
170
+ return `○ ${name}: not connected${note}`;
171
+ }
172
+
173
+ function cachedNote(tools: CachedTool[] | undefined): string {
174
+ if (!tools || tools.length === 0) return "";
175
+ return ` (${tools.length} tools cached)`;
176
+ }
177
+
178
+ /** Staleness suffix for a persisted outcome — omitted for fresh (<1m) entries. */
179
+ function ageSuffix(at: number | undefined): string {
180
+ if (at === undefined) return "";
181
+ const age = Date.now() - at;
182
+ if (age < 60_000) return "";
183
+ return ` (${formatAge(age)})`;
184
+ }
185
+
186
+ function formatAge(ms: number): string {
187
+ const s = Math.floor(ms / 1000);
188
+ if (s < 60) return `${Math.max(1, s)}s ago`;
189
+ const m = Math.floor(s / 60);
190
+ if (m < 60) return `${m}m ago`;
191
+ const h = Math.floor(m / 60);
192
+ if (h < 24) return `${h}h ago`;
193
+ const d = Math.floor(h / 24);
194
+ if (d < 7) return `${d}d ago`;
195
+ if (d < 45) return `${Math.max(1, Math.round(d / 7))}w ago`;
196
+ return `${Math.max(1, Math.round(d / 30))}mo ago`;
197
+ }
198
+
199
+ function firstLine(text: string | null | undefined): string | undefined {
200
+ const line = text?.split("\n")[0]?.trim();
201
+ return line === undefined || line.length === 0 ? undefined : line;
202
+ }
203
+
204
+ // ── tools / prompts (cache only — no connections) ──────────────────────────
205
+
206
+ function cmdTools(deps: McpCommandDeps, server: string | undefined, ctx: ExtensionCommandContext): void {
207
+ const defs = deps.getServerDefs();
208
+ if (server !== undefined) {
209
+ const def = defs[server];
210
+ if (def === undefined) {
211
+ ctx.ui.notify(`Unknown server: ${server}`, "error");
212
+ return;
213
+ }
214
+ ctx.ui.notify(toolListFor(server, def, deps) ?? "(no cached tool metadata)", "info");
215
+ return;
216
+ }
217
+ const names = Object.keys(defs);
218
+ if (names.length === 0) {
219
+ ctx.ui.notify("No MCP servers configured — run /mcp setup", "info");
220
+ return;
221
+ }
222
+ const blocks = names.map((name) => {
223
+ const def = defs[name]!;
224
+ const header = `${name}${def.disabled === true ? " (disabled)" : ""}:`;
225
+ const body = toolListFor(name, def, deps) ?? "(no cached tool metadata)";
226
+ return `${header}\n ${body.replace(/\n/g, "\n ")}`;
227
+ });
228
+ ctx.ui.notify(blocks.join("\n\n"), "info");
229
+ }
230
+
231
+ function toolListFor(name: string, def: ServerDef, deps: McpCommandDeps): string | undefined {
232
+ const tools = deps.getCachedTools(name, def);
233
+ if (!tools) return undefined;
234
+ if (tools.length === 0) return "(no tools)";
235
+ return tools
236
+ .map((t) => (t.description ? `${t.name} — ${t.description}` : t.name))
237
+ .join("\n");
238
+ }
239
+
240
+ function cmdPrompts(deps: McpCommandDeps, server: string | undefined, ctx: ExtensionCommandContext): void {
241
+ const defs = deps.getServerDefs();
242
+ const listFor = (name: string, def: ServerDef): string | undefined => {
243
+ const prompts = deps.getCachedPrompts(name, def);
244
+ if (!prompts) return undefined;
245
+ if (prompts.length === 0) return "(no prompts)";
246
+ return prompts
247
+ .map((p) => (p.description ? `${p.name} — ${p.description}` : p.name))
248
+ .join("\n");
249
+ };
250
+ if (server !== undefined) {
251
+ const def = defs[server];
252
+ if (def === undefined) {
253
+ ctx.ui.notify(`Unknown server: ${server}`, "error");
254
+ return;
255
+ }
256
+ ctx.ui.notify(listFor(server, def) ?? "(no cached prompt metadata)", "info");
257
+ return;
258
+ }
259
+ const names = Object.keys(defs);
260
+ if (names.length === 0) {
261
+ ctx.ui.notify("No MCP servers configured — run /mcp setup", "info");
262
+ return;
263
+ }
264
+ const blocks = names.map((name) => {
265
+ const def = defs[name]!;
266
+ const header = `${name}${def.disabled === true ? " (disabled)" : ""}:`;
267
+ const body = listFor(name, def) ?? "(no cached prompt metadata)";
268
+ return `${header}\n ${body.replace(/\n/g, "\n ")}`;
269
+ });
270
+ ctx.ui.notify(blocks.join("\n\n"), "info");
271
+ }
272
+
273
+ // ── reconnect ───────────────────────────────────────────────────────────────
274
+
275
+ async function cmdReconnect(deps: McpCommandDeps, server: string | undefined, ctx: ExtensionCommandContext): Promise<void> {
276
+ const defs = deps.getServerDefs();
277
+ const manager = deps.getManager();
278
+
279
+ let targets: string[];
280
+ if (server !== undefined) {
281
+ const def = defs[server];
282
+ if (def === undefined) {
283
+ ctx.ui.notify(`Unknown server: ${server}`, "error");
284
+ return;
285
+ }
286
+ if (def.disabled === true) {
287
+ ctx.ui.notify(`Server ${server} is disabled (run /mcp enable ${server}, then /reload)`, "error");
288
+ return;
289
+ }
290
+ targets = [server];
291
+ } else {
292
+ targets = Object.keys(defs).filter((name) => defs[name]!.disabled !== true);
293
+ if (targets.length === 0) {
294
+ ctx.ui.notify("No enabled servers to reconnect", "info");
295
+ return;
296
+ }
297
+ }
298
+
299
+ // Bring the manager up to date with the current ENABLED, well-formed set
300
+ // (same pattern the proxy actions use) so a fresh or removed server is
301
+ // handled correctly. Disabled servers never get a managed client.
302
+ const enabled = Object.fromEntries(
303
+ Object.entries(defs).filter(
304
+ ([, d]) => d.disabled !== true && ("url" in d || "command" in d),
305
+ ),
306
+ );
307
+ manager.sync(enabled);
308
+
309
+ const lines: string[] = [];
310
+ for (const name of targets) {
311
+ const client = manager.getClient(name);
312
+ if (!client) {
313
+ lines.push(`✗ ${name}: no managed connection — run /reload to pick up config changes`);
314
+ continue;
315
+ }
316
+ await client.close();
317
+ try {
318
+ await client.connect();
319
+ } catch {
320
+ // A failed connect settles the client into "error" (message on
321
+ // client.error) — nothing else to do here.
322
+ }
323
+ // ADR 0004: persist the settled outcome (one recorder per settle point).
324
+ recordClientOutcome(client);
325
+ if (client.status === "connected") {
326
+ lines.push(`✓ ${name}: connected (${client.tools.length} tools)`);
327
+ } else if (client.status === "needs-auth") {
328
+ lines.push(`⚠ ${name}: needs auth — run /mcp auth ${name}`);
329
+ } else {
330
+ lines.push(`✗ ${name}: error — ${firstLine(client.error) ?? "connect failed"}`);
331
+ }
332
+ }
333
+ ctx.ui.notify(lines.join("\n"), "info");
334
+ }
335
+
336
+ // ── enable / disable (write-back to <cwd>/.pi/mcp.json, ADR 0002) ──────────
337
+
338
+ async function cmdToggleEnabled(
339
+ deps: McpCommandDeps,
340
+ server: string | undefined,
341
+ ctx: ExtensionCommandContext,
342
+ disable: boolean,
343
+ ): Promise<void> {
344
+ const verb = disable ? "disable" : "enable";
345
+ if (server === undefined) {
346
+ ctx.ui.notify(`Usage: /mcp ${verb} <server>`, "info");
347
+ return;
348
+ }
349
+ const def = deps.getServerDefs()[server];
350
+ if (def === undefined) {
351
+ ctx.ui.notify(`Unknown server: ${server}`, "error");
352
+ return;
353
+ }
354
+ const isDisabled = def.disabled === true;
355
+ if (disable === isDisabled) {
356
+ ctx.ui.notify(`Server ${server} is already ${disable ? "disabled" : "enabled"}`, "error");
357
+ return;
358
+ }
359
+ // Throws on an unparseable override file — the dispatcher's catch renders it.
360
+ writeServerDisabled(ctx.cwd, server, disable);
361
+ if (disable) {
362
+ // Drop the live connection so the server is torn down at /reload.
363
+ deps.getManager().getClient(server)?.close();
364
+ }
365
+ ctx.ui.notify(`✓ ${server} ${disable ? "disabled" : "enabled"} — run /reload to apply`, "info");
366
+ }
367
+
368
+ // ── logout (shared fn — keyring delete + client close) ──────────────────────
369
+
370
+ async function cmdLogout(deps: McpCommandDeps, server: string | undefined, ctx: ExtensionCommandContext): Promise<void> {
371
+ if (server === undefined) {
372
+ ctx.ui.notify("Usage: /mcp logout <server>", "info");
373
+ return;
374
+ }
375
+ const result = mcpLogoutServer(server, deps.getManager);
376
+ if (!result.ok) {
377
+ ctx.ui.notify(`Could not log out of ${server}: ${result.error}`, "error");
378
+ return;
379
+ }
380
+ ctx.ui.notify(`Logged out of ${server}`, "info");
381
+ }
382
+
383
+ // ── panel (lazy-loads the TUI component — same pattern as /agents) ─────────
384
+
385
+ async function cmdPanel(pi: ExtensionAPI, deps: McpCommandDeps, ctx: ExtensionCommandContext): Promise<void> {
386
+ // Zero configured servers → the management panel has nothing to show; do
387
+ // the same no-servers redirect as the text path and land in onboarding.
388
+ // Covers both explicit `/mcp panel` and bare `/mcp` in a TUI.
389
+ if (Object.keys(deps.getServerDefs()).length === 0) {
390
+ ctx.ui.notify("No MCP servers configured — opening setup", "info");
391
+ await cmdSetup(pi, ctx);
392
+ return;
393
+ }
394
+ const { openMcpPanel } = await import("./panel.js");
395
+ // deps is a structural superset of McpPanelDeps (extra getCachedPrompts
396
+ // seam is simply unused by the panel); ctx.cwd supplies the write-back
397
+ // target for e / ctrl+s (ADR 0002).
398
+ await openMcpPanel(pi, ctx, deps);
399
+ }
400
+
401
+ // ── setup (lazy-loads the onboarding panel — same pattern as panel) ─────────
402
+
403
+ async function cmdSetup(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
404
+ const { openMcpSetupPanel } = await import("./setup-panel.js");
405
+ await openMcpSetupPanel(pi, ctx);
406
+ }
407
+
408
+ // ── auth (delegates to the extracted runMcpAuthCommand) ─────────────────────
409
+
410
+ async function cmdAuth(deps: McpCommandDeps, server: string | undefined, ctx: ExtensionCommandContext): Promise<void> {
411
+ if (server === undefined) {
412
+ ctx.ui.notify("Usage: /mcp auth <server>", "info");
413
+ return;
414
+ }
415
+ // The http-only single-server lookup is derived at the call site —
416
+ // stdio and unknown servers both read as "unknown" for OAuth — and stays
417
+ // test-injectable through getServerDefs (the seam the deps expose).
418
+ const d = deps.getServerDefs()[server];
419
+ const def = d !== undefined && isHttpDef(d) ? d : undefined;
420
+ await runMcpAuthCommand(server, ctx, {
421
+ getServerDef: () => def,
422
+ getManager: deps.getManager,
423
+ });
424
+ }
@@ -0,0 +1,213 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
6
+ import { writeServerDisabled, writeServerDirectTools, mergeServerDefinitions, writeJsonFileAtomic } from "./config-write.js";
7
+
8
+ /** Target file path, built from the pi-coding-agent constant (never hardcoded). */
9
+ const configPath = (cwd: string): string => join(cwd, CONFIG_DIR_NAME, "mcp.json");
10
+
11
+ /** Project-shared target path (the merge destination — NOT the Pi override). */
12
+ const projectPath = (cwd: string): string => join(cwd, ".mcp.json");
13
+
14
+ function readConfig(cwd: string): Record<string, any> {
15
+ return JSON.parse(readFileSync(configPath(cwd), "utf-8")) as Record<string, any>;
16
+ }
17
+
18
+ let cwd: string;
19
+
20
+ beforeEach(() => {
21
+ cwd = mkdtempSync(join(tmpdir(), "mcp-config-write-"));
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(cwd, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("writeServerDisabled", () => {
29
+ it("creates <cwd>/<CONFIG_DIR_NAME>/mcp.json with only { disabled } for the server", () => {
30
+ writeServerDisabled(cwd, "x", true);
31
+
32
+ const p = configPath(cwd);
33
+ expect(existsSync(p)).toBe(true);
34
+ expect(readConfig(cwd)).toEqual({ mcpServers: { x: { disabled: true } } });
35
+ });
36
+
37
+ it("merges with a previously written directTools value without clobbering it", () => {
38
+ writeServerDirectTools(cwd, "x", ["a"]);
39
+ writeServerDisabled(cwd, "x", false);
40
+
41
+ expect(readConfig(cwd)).toEqual({
42
+ mcpServers: { x: { directTools: ["a"], disabled: false } },
43
+ });
44
+ });
45
+ });
46
+
47
+ describe("writeServerDirectTools", () => {
48
+ it("merges with a previously written disabled value without clobbering it", () => {
49
+ writeServerDisabled(cwd, "x", true);
50
+ writeServerDirectTools(cwd, "x", ["a"]);
51
+
52
+ expect(readConfig(cwd)).toEqual({
53
+ mcpServers: { x: { disabled: true, directTools: ["a"] } },
54
+ });
55
+ });
56
+
57
+ it("preserves another server's entry when writing a different server", () => {
58
+ writeServerDisabled(cwd, "x", true);
59
+ writeServerDirectTools(cwd, "x", ["a"]);
60
+ writeServerDisabled(cwd, "y", false);
61
+
62
+ const parsed = readConfig(cwd);
63
+ expect(parsed.mcpServers.x).toEqual({ disabled: true, directTools: ["a"] });
64
+ expect(parsed.mcpServers.y).toEqual({ disabled: false });
65
+ });
66
+
67
+ it("accepts a boolean value (directTools: false disables direct tools)", () => {
68
+ writeServerDirectTools(cwd, "x", false);
69
+
70
+ expect(readConfig(cwd)).toEqual({ mcpServers: { x: { directTools: false } } });
71
+ });
72
+ });
73
+
74
+ describe("mergeServerDefinitions", () => {
75
+ function readProject(cwd: string): Record<string, any> {
76
+ return JSON.parse(readFileSync(projectPath(cwd), "utf-8")) as Record<string, any>;
77
+ }
78
+
79
+ it("creates a fresh <cwd>/.mcp.json with the given servers", () => {
80
+ mergeServerDefinitions(cwd, {
81
+ context7: { command: "npx", args: ["-y", "@upstash/context7-mcp"] },
82
+ });
83
+
84
+ expect(existsSync(projectPath(cwd))).toBe(true);
85
+ expect(readProject(cwd)).toEqual({
86
+ mcpServers: { context7: { command: "npx", args: ["-y", "@upstash/context7-mcp"] } },
87
+ });
88
+ // Must NOT touch the Pi override file.
89
+ expect(existsSync(configPath(cwd))).toBe(false);
90
+ });
91
+
92
+ it("merging server B preserves server A's entry", () => {
93
+ mergeServerDefinitions(cwd, { alpha: { url: "https://alpha.example/mcp" } });
94
+ mergeServerDefinitions(cwd, { beta: { command: "uvx", args: ["beta-server"] } });
95
+
96
+ expect(readProject(cwd)).toEqual({
97
+ mcpServers: {
98
+ alpha: { url: "https://alpha.example/mcp" },
99
+ beta: { command: "uvx", args: ["beta-server"] },
100
+ },
101
+ });
102
+ });
103
+
104
+ it("does NOT overwrite an existing entry (add-if-absent)", () => {
105
+ const original = { url: "https://original.example/mcp", headers: { Authorization: "Bearer keep-me" } };
106
+ mkdirSync(cwd, { recursive: true });
107
+ writeFileSync(projectPath(cwd), JSON.stringify({ mcpServers: { alpha: original } }, null, 2) + "\n", "utf-8");
108
+
109
+ mergeServerDefinitions(cwd, { alpha: { command: "npx" }, other: { url: "https://other.example" } });
110
+
111
+ const parsed = readProject(cwd);
112
+ expect(parsed.mcpServers.alpha).toEqual(original); // untouched
113
+ expect(parsed.mcpServers.other).toEqual({ url: "https://other.example" }); // added
114
+ });
115
+
116
+ it("preserves other top-level keys of the existing file", () => {
117
+ const existing = { $schema: "https://example/schemas/mcp.json", foo: "bar", mcpServers: { alpha: { url: "https://a.example" } } };
118
+ mkdirSync(cwd, { recursive: true });
119
+ writeFileSync(projectPath(cwd), JSON.stringify(existing, null, 2) + "\n", "utf-8");
120
+
121
+ mergeServerDefinitions(cwd, { beta: { command: "uvx", args: ["beta-server"] } });
122
+
123
+ const parsed = readProject(cwd);
124
+ expect(parsed.$schema).toBe("https://example/schemas/mcp.json");
125
+ expect(parsed.foo).toBe("bar");
126
+ expect(parsed.mcpServers).toEqual({
127
+ alpha: { url: "https://a.example" },
128
+ beta: { command: "uvx", args: ["beta-server"] },
129
+ });
130
+ });
131
+
132
+ it("tolerates // comments in the existing file (read side)", () => {
133
+ mkdirSync(cwd, { recursive: true });
134
+ writeFileSync(projectPath(cwd), '// project config\n{"mcpServers": {"alpha": {"url": "https://a.example"}},}', "utf-8");
135
+
136
+ expect(() => mergeServerDefinitions(cwd, { beta: { command: "uvx", args: ["b"] } })).not.toThrow();
137
+
138
+ expect(readProject(cwd).mcpServers).toEqual({
139
+ alpha: { url: "https://a.example" },
140
+ beta: { command: "uvx", args: ["b"] },
141
+ });
142
+ });
143
+ });
144
+
145
+ describe("read-modify-write tolerance", () => {
146
+ it("tolerates // comments and trailing commas in the existing file", () => {
147
+ mkdirSync(join(cwd, CONFIG_DIR_NAME), { recursive: true });
148
+ writeFileSync(
149
+ configPath(cwd),
150
+ [
151
+ "// comment",
152
+ '{"mcpServers": {"z": {"disabled": false}},}',
153
+ "",
154
+ ].join("\n"),
155
+ "utf-8",
156
+ );
157
+
158
+ expect(() => writeServerDisabled(cwd, "x", true)).not.toThrow();
159
+
160
+ expect(readConfig(cwd)).toEqual({
161
+ mcpServers: { z: { disabled: false }, x: { disabled: true } },
162
+ });
163
+ });
164
+
165
+ it("preserves credential fields of other servers verbatim (never copies credentials)", () => {
166
+ const credServer = {
167
+ type: "http",
168
+ url: "http://example.com:3000/mcp",
169
+ headers: { Authorization: "Bearer s3cret-token" },
170
+ };
171
+ mkdirSync(join(cwd, CONFIG_DIR_NAME), { recursive: true });
172
+ writeFileSync(
173
+ configPath(cwd),
174
+ JSON.stringify({ mcpServers: { cred: credServer } }, null, 2) + "\n",
175
+ "utf-8",
176
+ );
177
+
178
+ writeServerDisabled(cwd, "x", true);
179
+
180
+ const parsed = readConfig(cwd);
181
+ expect(parsed.mcpServers.cred).toEqual(credServer);
182
+ expect(parsed.mcpServers.x).toEqual({ disabled: true });
183
+ });
184
+ });
185
+
186
+ describe("writeJsonFileAtomic", () => {
187
+ it("writes the doc with 2-space indent and a trailing newline", () => {
188
+ const doc = { mcpServers: { x: { url: "https://x.example" } } };
189
+ const p = join(cwd, "sub", "out.json");
190
+
191
+ writeJsonFileAtomic(p, doc);
192
+
193
+ expect(readFileSync(p, "utf-8")).toBe(JSON.stringify(doc, null, 2) + "\n");
194
+ });
195
+
196
+ it("creates the target's parent dir and leaves no .tmp file behind", () => {
197
+ const p = join(cwd, "freshdir", "out.json");
198
+
199
+ writeJsonFileAtomic(p, { a: 1 });
200
+
201
+ expect(existsSync(p)).toBe(true);
202
+ expect(existsSync(p + ".tmp")).toBe(false);
203
+ });
204
+
205
+ it("replaces an existing target file (tmp+rename over the target)", () => {
206
+ const p = join(cwd, "existing.json");
207
+ writeFileSync(p, JSON.stringify({ old: true }, null, 2) + "\n", "utf-8");
208
+
209
+ writeJsonFileAtomic(p, { new: 1 });
210
+
211
+ expect(readFileSync(p, "utf-8")).toBe('{\n "new": 1\n}\n');
212
+ });
213
+ });