@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,834 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
+ import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8
+ import { parseMcpSubcommand, registerMcpCommand, type McpCommandDeps } from "./commands.js";
9
+ import { ServerManager } from "./server-manager.js";
10
+ import {
11
+ getCachedPrompts,
12
+ getCachedTools,
13
+ loadMetadataCache,
14
+ recordServerOutcome,
15
+ saveServerCache,
16
+ setCachePathForTest,
17
+ } from "./metadata-cache.js";
18
+ import type { ServerDef } from "./types.js";
19
+
20
+ // ── mocks ────────────────────────────────────────────────────────────────────
21
+ // The real BorderedLoader needs a live TUI; a stub with the same surface
22
+ // (constructor message + onAbort) is enough to drive the auth subcommand.
23
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
24
+ BorderedLoader: class {
25
+ message: string;
26
+ onAbort?: () => void;
27
+ constructor(_tui: unknown, _theme: unknown, message: string) {
28
+ this.message = message;
29
+ }
30
+ dispose() {}
31
+ },
32
+ CONFIG_DIR_NAME: ".pi",
33
+ // core/settings-io builds its settings path at module load
34
+ getAgentDir: () => `${process.env.TMPDIR ?? "/tmp"}/pi-archimedes-mock-agent`,
35
+ }));
36
+ vi.mock("open", () => ({ default: vi.fn().mockResolvedValue({}) }));
37
+ vi.mock("./auth-storage.js", () => ({ deleteAuthEntry: vi.fn(), getAuthEntry: vi.fn() }));
38
+
39
+ // ── fakes ────────────────────────────────────────────────────────────────────
40
+
41
+ interface CapturedCommand {
42
+ description?: string;
43
+ handler: (args: string, ctx: unknown) => Promise<void>;
44
+ }
45
+
46
+ function makeFakePi(): {
47
+ pi: ExtensionAPI;
48
+ commands: Record<string, CapturedCommand>;
49
+ } {
50
+ const commands: Record<string, CapturedCommand> = {};
51
+ const pi = {
52
+ on: vi.fn(),
53
+ registerTool: vi.fn(),
54
+ registerCommand: (name: string, def: CapturedCommand) => {
55
+ commands[name] = def;
56
+ },
57
+ } as unknown as ExtensionAPI;
58
+ return { pi, commands };
59
+ }
60
+
61
+ /** Fake SDK Client — the surface ServerClient touches. */
62
+ function makeFakeSdkClient(opts: {
63
+ tools?: Array<{ name: string; description?: string; inputSchema: unknown }>;
64
+ onConnect?: (transport: unknown) => Promise<void> | void;
65
+ } = {}) {
66
+ const fake = {
67
+ async connect(transport: unknown) {
68
+ await opts.onConnect?.(transport);
69
+ },
70
+ async listTools() {
71
+ return { tools: opts.tools ?? [] };
72
+ },
73
+ async listResources() {
74
+ return { resources: [] };
75
+ },
76
+ async listPrompts() {
77
+ return { prompts: [] };
78
+ },
79
+ getServerCapabilities() {
80
+ return undefined;
81
+ },
82
+ getInstructions() {
83
+ return undefined;
84
+ },
85
+ async callTool() {
86
+ return { content: [{ type: "text", text: "ok" }] };
87
+ },
88
+ async close() {},
89
+ closeFn: vi.fn().mockResolvedValue(undefined),
90
+ };
91
+ return fake;
92
+ }
93
+
94
+ interface CtxState {
95
+ notify: ReturnType<typeof vi.fn>;
96
+ custom: ReturnType<typeof vi.fn>;
97
+ lastLoader: { message: string; onAbort?: () => void } | null;
98
+ /** The overlay factory's return value (a BorderedLoader, or the mcp panel component). */
99
+ lastCustom: unknown;
100
+ /** The fake tui's requestRender — proves components trigger re-renders. */
101
+ requestRender: ReturnType<typeof vi.fn>;
102
+ }
103
+
104
+ /** Fake ExtensionCommandContext: notify captured; ui.custom runs the factory
105
+ * synchronously and resolves when done() is first called. The factory gets a
106
+ * minimal working tui (requestRender) and an identity theme so real overlay
107
+ * components (BorderedLoader, the mcp panel) can run against it. `hasUI`
108
+ * defaults to true (the interactive TUI). */
109
+ function makeCtx(cwd: string, hasUI: boolean = true): { ctx: ExtensionCommandContext; state: CtxState } {
110
+ const state: CtxState = {
111
+ notify: vi.fn(),
112
+ custom: vi.fn(),
113
+ lastLoader: null,
114
+ lastCustom: null,
115
+ requestRender: vi.fn(),
116
+ };
117
+ const ui = {
118
+ notify: (message: string, type?: "info" | "warning" | "error") =>
119
+ state.notify(message, type),
120
+ custom: (
121
+ factory: (
122
+ tui: unknown,
123
+ theme: unknown,
124
+ keybindings: unknown,
125
+ done: (result: unknown) => void,
126
+ ) => unknown,
127
+ ) => {
128
+ let resolve!: (result: unknown) => void;
129
+ const pending = new Promise<unknown>((r) => (resolve = r));
130
+ let settled = false;
131
+ const done = (result: unknown) => {
132
+ if (!settled) {
133
+ settled = true;
134
+ resolve(result);
135
+ }
136
+ };
137
+ const component = factory(
138
+ { requestRender: state.requestRender },
139
+ { fg: (_token: string, text: string) => text },
140
+ {},
141
+ done,
142
+ );
143
+ state.lastLoader = component as CtxState["lastLoader"];
144
+ state.lastCustom = component;
145
+ return pending;
146
+ },
147
+ };
148
+ return {
149
+ ctx: { hasUI, cwd, ui } as unknown as ExtensionCommandContext,
150
+ state,
151
+ };
152
+ }
153
+
154
+ // ── env ─────────────────────────────────────────────────────────────────────
155
+
156
+ const stdioDef: ServerDef = { type: "stdio", command: "true" };
157
+ const httpDef: ServerDef = { type: "http", url: "http://127.0.0.1:1/mcp" };
158
+ const httpOauthDef: ServerDef = { type: "http", url: "https://mcps.example/mcp", auth: "oauth" };
159
+
160
+ let workspace: string;
161
+ let cwd: string;
162
+ let cachePath: string;
163
+
164
+ beforeEach(() => {
165
+ workspace = mkdtempSync(join(tmpdir(), "mcp-commands-test-"));
166
+ cwd = join(workspace, "project");
167
+ cachePath = join(workspace, "cache.json");
168
+ mkdtempSync(cwd);
169
+ setCachePathForTest(cachePath);
170
+ });
171
+
172
+ afterEach(() => {
173
+ vi.restoreAllMocks();
174
+ setCachePathForTest(null);
175
+ rmSync(workspace, { recursive: true, force: true });
176
+ });
177
+
178
+ interface Env {
179
+ run: (args: string) => Promise<void>;
180
+ notify: CtxState["notify"];
181
+ state: CtxState;
182
+ manager: ServerManager;
183
+ sdk: ReturnType<typeof makeFakeSdkClient>;
184
+ }
185
+
186
+ /** Wire a command handler over a real manager + real (tmp) metadata cache.
187
+ * `serverDefs` may include disabled servers, exactly like loadAllServerDefs. */
188
+ function setupEnv(
189
+ serverDefs: Record<string, ServerDef>,
190
+ opts: { sdk?: ReturnType<typeof makeFakeSdkClient>; sync?: boolean; hasUI?: boolean } = {},
191
+ ): Env {
192
+ const sdk = opts.sdk ?? makeFakeSdkClient({ tools: [{ name: "t1", description: "d1", inputSchema: {} }] });
193
+ const manager = new ServerManager({
194
+ clientFactory: () => ({ ...sdk, close: sdk.closeFn }) as unknown as Client,
195
+ });
196
+ if (opts.sync !== false) {
197
+ // Mirror production: the manager only knows enabled, well-formed servers
198
+ manager.sync(
199
+ Object.fromEntries(
200
+ Object.entries(serverDefs).filter(
201
+ ([, d]) => d.disabled !== true && ("url" in d || "command" in d),
202
+ ),
203
+ ),
204
+ );
205
+ }
206
+ const deps: McpCommandDeps = {
207
+ getManager: () => manager,
208
+ getServerDefs: () => serverDefs,
209
+ getCachedTools: (name, def) => getCachedTools(name, def),
210
+ getCachedPrompts: (name, def) => getCachedPrompts(name, def),
211
+ };
212
+ const { pi, commands } = makeFakePi();
213
+ registerMcpCommand(pi, deps);
214
+ const mcp = commands["mcp"];
215
+ if (!mcp) throw new Error("/mcp command not registered");
216
+ const { ctx, state } = makeCtx(cwd, opts.hasUI ?? true);
217
+ return {
218
+ run: (args) => mcp.handler(args, ctx),
219
+ notify: state.notify,
220
+ state,
221
+ manager,
222
+ sdk,
223
+ };
224
+ }
225
+
226
+ // ── parseMcpSubcommand ──────────────────────────────────────────────────────
227
+
228
+ describe("parseMcpSubcommand", () => {
229
+ it("parses a lone subcommand", () => {
230
+ expect(parseMcpSubcommand("status")).toEqual({ subcommand: "status", rest: [] });
231
+ });
232
+
233
+ it("parses a subcommand with an argument", () => {
234
+ expect(parseMcpSubcommand("reconnect foo")).toEqual({ subcommand: "reconnect", rest: ["foo"] });
235
+ });
236
+
237
+ it("defaults to status when empty", () => {
238
+ expect(parseMcpSubcommand("")).toEqual({ subcommand: "status", rest: [] });
239
+ });
240
+
241
+ it("collapses extra whitespace between tokens", () => {
242
+ expect(parseMcpSubcommand(" reconnect foo bar ")).toEqual({
243
+ subcommand: "reconnect",
244
+ rest: ["foo", "bar"],
245
+ });
246
+ });
247
+
248
+ it("treats a whitespace-only string as no subcommand (status default)", () => {
249
+ expect(parseMcpSubcommand(" ")).toEqual({ subcommand: "status", rest: [] });
250
+ });
251
+
252
+ it("passes unknown first tokens through unchanged", () => {
253
+ expect(parseMcpSubcommand("frobnicate xyz")).toEqual({ subcommand: "frobnicate", rest: ["xyz"] });
254
+ });
255
+ });
256
+
257
+ // ── registration ────────────────────────────────────────────────────────────
258
+
259
+ describe("registerMcpCommand", () => {
260
+ it("registers exactly the mcp command", () => {
261
+ const { pi, commands } = makeFakePi();
262
+ const deps: McpCommandDeps = setupDepsOnly();
263
+ registerMcpCommand(pi, deps);
264
+ expect(Object.keys(commands)).toEqual(["mcp"]);
265
+ expect(typeof commands["mcp"]!.handler).toBe("function");
266
+ });
267
+
268
+ function setupDepsOnly(): McpCommandDeps {
269
+ return {
270
+ getManager: () => new ServerManager(),
271
+ getServerDefs: () => ({}),
272
+ getCachedTools: () => undefined,
273
+ getCachedPrompts: () => undefined,
274
+ };
275
+ }
276
+ });
277
+
278
+ // ── status ──────────────────────────────────────────────────────────────────
279
+
280
+ describe("/mcp status", () => {
281
+ it("points at /mcp setup when no servers are configured", async () => {
282
+ const env = setupEnv({});
283
+ await env.run("status");
284
+ expect(env.notify).toHaveBeenCalledWith("No MCP servers configured — run /mcp setup", "info");
285
+ });
286
+
287
+ it("shows a live connected server with its tool count", async () => {
288
+ const env = setupEnv({ srv: stdioDef });
289
+ await env.manager.getClient("srv")!.connect();
290
+ await env.run("status");
291
+ expect(env.notify).toHaveBeenCalledWith("✓ srv: connected (1 tools)", "info");
292
+ });
293
+
294
+ it("shows a disabled server with the enable hint", async () => {
295
+ const env = setupEnv({ srv: { ...stdioDef, disabled: true } });
296
+ await env.run("status");
297
+ expect(env.notify).toHaveBeenCalledWith(
298
+ "○ srv: disabled (run /mcp enable srv, then /reload)",
299
+ "info",
300
+ );
301
+ });
302
+
303
+ it("shows a persisted needs-auth outcome across sessions (ADR 0004)", async () => {
304
+ const env = setupEnv({ srv: httpOauthDef });
305
+ recordServerOutcome("srv", "needs-auth");
306
+ await env.run("status"); // explicit status stays the text list (even in a TUI)
307
+ expect(env.notify).toHaveBeenCalledWith(
308
+ "⚠ srv: needs auth — run /mcp auth srv",
309
+ "info",
310
+ );
311
+ });
312
+
313
+ it("timestamps a stale persisted outcome", async () => {
314
+ const env = setupEnv({ srv: httpOauthDef });
315
+ recordServerOutcome("srv", "needs-auth");
316
+ // Age the outcome 2 days
317
+ const cache = loadMetadataCache();
318
+ if (cache.serverStatuses?.["srv"]) cache.serverStatuses["srv"].at = Date.now() - 2 * 86_400_000;
319
+ const { writeFileSync } = await import("node:fs");
320
+ writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8");
321
+ await env.run("status");
322
+ expect(env.notify).toHaveBeenCalledWith("⚠ srv: needs auth (2d ago) — run /mcp auth srv", "info");
323
+ });
324
+
325
+ it("shows a persisted error outcome with its message", async () => {
326
+ const env = setupEnv({ srv: httpDef });
327
+ recordServerOutcome("srv", "error", "ECONNREFUSED 127.0.0.1:1");
328
+ await env.run("status");
329
+ expect(env.notify).toHaveBeenCalledWith(
330
+ "✗ srv: error — ECONNREFUSED 127.0.0.1:1",
331
+ "info",
332
+ );
333
+ });
334
+
335
+ it("shows an unconnected server with cached tool count", async () => {
336
+ saveServerCache("srv", stdioDef, {
337
+ tools: [{ name: "t1", description: "d1", inputSchema: {} }],
338
+ resources: [],
339
+ });
340
+ const env = setupEnv({ srv: stdioDef });
341
+ await env.run("status");
342
+ expect(env.notify).toHaveBeenCalledWith("○ srv: not connected (1 tools cached)", "info");
343
+ });
344
+
345
+ it("shows an unconnected server without cache plain", async () => {
346
+ const env = setupEnv({ srv: stdioDef });
347
+ await env.run("status");
348
+ expect(env.notify).toHaveBeenCalledWith("○ srv: not connected", "info");
349
+ });
350
+
351
+ it("lists one line per server", async () => {
352
+ const env = setupEnv({
353
+ alpha: { type: "stdio", command: "true", disabled: true },
354
+ beta: stdioDef,
355
+ });
356
+ await env.run("status");
357
+ expect(env.notify).toHaveBeenCalledWith(
358
+ "○ alpha: disabled (run /mcp enable alpha, then /reload)\n○ beta: not connected",
359
+ "info",
360
+ );
361
+ });
362
+ });
363
+
364
+ // ── tools ───────────────────────────────────────────────────────────────────
365
+
366
+ describe("/mcp tools", () => {
367
+ function seedTools(name: string, def: ServerDef, tools: Array<{ name: string; description?: string }>): void {
368
+ saveServerCache(name, def, {
369
+ tools: tools.map((t) => ({ ...t, inputSchema: {} })),
370
+ resources: [],
371
+ });
372
+ }
373
+
374
+ it("lists tools per server across all servers", async () => {
375
+ seedTools("srv1", stdioDef, [{ name: "a", description: "first" }, { name: "b" }]);
376
+ seedTools("srv2", httpDef, [{ name: "c", description: "third" }]);
377
+ const env = setupEnv({ srv1: stdioDef, srv2: httpDef });
378
+ await env.run("tools");
379
+ expect(env.notify).toHaveBeenCalledWith(
380
+ "srv1:\n a — first\n b\n\nsrv2:\n c — third",
381
+ "info",
382
+ );
383
+ });
384
+
385
+ it("lists tools for a single server", async () => {
386
+ seedTools("srv", stdioDef, [{ name: "a", description: "first" }]);
387
+ const env = setupEnv({ srv: stdioDef });
388
+ await env.run("tools srv");
389
+ expect(env.notify).toHaveBeenCalledWith("a — first", "info");
390
+ });
391
+
392
+ it("marks disabled servers in the all-servers listing", async () => {
393
+ seedTools("srv", stdioDef, [{ name: "a" }]);
394
+ const env = setupEnv({ srv: { ...stdioDef, disabled: true } });
395
+ await env.run("tools");
396
+ expect(env.notify).toHaveBeenCalledWith("srv (disabled):\n a", "info");
397
+ });
398
+
399
+ it("reports a clear error for an unknown server", async () => {
400
+ const env = setupEnv({ srv: stdioDef });
401
+ await env.run("tools ghost");
402
+ expect(env.notify).toHaveBeenCalledWith("Unknown server: ghost", "error");
403
+ });
404
+
405
+ it("notes missing cache rather than listing", async () => {
406
+ const env = setupEnv({ srv: stdioDef });
407
+ await env.run("tools srv");
408
+ expect(env.notify).toHaveBeenCalledWith("(no cached tool metadata)", "info");
409
+ });
410
+
411
+ it("points at /mcp setup when no servers are configured", async () => {
412
+ const env = setupEnv({});
413
+ await env.run("tools");
414
+ expect(env.notify).toHaveBeenCalledWith("No MCP servers configured — run /mcp setup", "info");
415
+ });
416
+ });
417
+
418
+ // ── prompts ─────────────────────────────────────────────────────────────────
419
+
420
+ describe("/mcp prompts", () => {
421
+ function seedPrompts(name: string, def: ServerDef, prompts: Array<{ name: string; description?: string }>): void {
422
+ saveServerCache(name, def, {
423
+ tools: [],
424
+ resources: [],
425
+ prompts,
426
+ });
427
+ }
428
+
429
+ it("lists prompts with descriptions", async () => {
430
+ seedPrompts("srv", stdioDef, [{ name: "daily", description: "daily report" }]);
431
+ const env = setupEnv({ srv: stdioDef });
432
+ await env.run("prompts srv");
433
+ expect(env.notify).toHaveBeenCalledWith("daily — daily report", "info");
434
+ });
435
+
436
+ it("lists prompts per server across all servers", async () => {
437
+ seedPrompts("srv1", stdioDef, [{ name: "p1", description: "one" }]);
438
+ const env = setupEnv({ srv1: stdioDef });
439
+ await env.run("prompts");
440
+ expect(env.notify).toHaveBeenCalledWith("srv1:\n p1 — one", "info");
441
+ });
442
+
443
+ it("notes missing cached prompts", async () => {
444
+ const env = setupEnv({ srv: stdioDef });
445
+ await env.run("prompts srv");
446
+ expect(env.notify).toHaveBeenCalledWith("(no cached prompt metadata)", "info");
447
+ });
448
+
449
+ it("reports a clear error for an unknown server", async () => {
450
+ const env = setupEnv({ srv: stdioDef });
451
+ await env.run("prompts ghost");
452
+ expect(env.notify).toHaveBeenCalledWith("Unknown server: ghost", "error");
453
+ });
454
+ });
455
+
456
+ // ── reconnect ───────────────────────────────────────────────────────────────
457
+
458
+ describe("/mcp reconnect", () => {
459
+ it("closes and reconnects a single server, records the outcome (ADR 0004)", async () => {
460
+ const env = setupEnv({ srv: stdioDef });
461
+ const client = env.manager.getClient("srv")!;
462
+ const closeSpy = vi.spyOn(client, "close");
463
+ await env.run("reconnect srv");
464
+ expect(closeSpy).toHaveBeenCalled();
465
+ expect(env.notify).toHaveBeenCalledWith("✓ srv: connected (1 tools)", "info");
466
+ expect(loadMetadataCache().serverStatuses?.["srv"]?.status).toBe("connected");
467
+ });
468
+
469
+ it("surfaces needs-auth and points at /mcp auth after a 401", async () => {
470
+ const sdk = makeFakeSdkClient({
471
+ onConnect: () => {
472
+ throw new StreamableHTTPError(401, "Unauthorized");
473
+ },
474
+ });
475
+ const env = setupEnv({ "auth-srv": httpOauthDef }, { sdk });
476
+ await env.run("reconnect auth-srv");
477
+ expect(env.notify).toHaveBeenCalledWith(
478
+ "⚠ auth-srv: needs auth — run /mcp auth auth-srv",
479
+ "info",
480
+ );
481
+ expect(loadMetadataCache().serverStatuses?.["auth-srv"]?.status).toBe("needs-auth");
482
+ });
483
+
484
+ it("surfaces connect failures with the error text", async () => {
485
+ const sdk = makeFakeSdkClient({
486
+ onConnect: () => {
487
+ throw new Error("connection refused");
488
+ },
489
+ });
490
+ const env = setupEnv({ srv: httpDef }, { sdk });
491
+ await env.run("reconnect srv");
492
+ expect(env.notify).toHaveBeenCalledWith("✗ srv: error — connection refused", "info");
493
+ expect(loadMetadataCache().serverStatuses?.["srv"]?.status).toBe("error");
494
+ expect(loadMetadataCache().serverStatuses?.["srv"]?.error).toBe("connection refused");
495
+ });
496
+
497
+ it("reconnects all enabled servers when no arg is given", async () => {
498
+ const env = setupEnv({
499
+ a: { type: "stdio", command: "true" },
500
+ b: { type: "stdio", command: "true" },
501
+ c: { type: "stdio", command: "true", disabled: true },
502
+ });
503
+ await env.run("reconnect");
504
+ expect(env.notify).toHaveBeenCalledWith(
505
+ "✓ a: connected (1 tools)\n✓ b: connected (1 tools)",
506
+ "info",
507
+ );
508
+ });
509
+
510
+ it("rejects a disabled server", async () => {
511
+ const env = setupEnv({ srv: { ...stdioDef, disabled: true } });
512
+ await env.run("reconnect srv");
513
+ expect(env.notify).toHaveBeenCalledWith(
514
+ "Server srv is disabled (run /mcp enable srv, then /reload)",
515
+ "error",
516
+ );
517
+ });
518
+
519
+ it("reports a clear error for an unknown server", async () => {
520
+ const env = setupEnv({ srv: stdioDef });
521
+ await env.run("reconnect ghost");
522
+ expect(env.notify).toHaveBeenCalledWith("Unknown server: ghost", "error");
523
+ });
524
+ });
525
+
526
+ // ── enable / disable ────────────────────────────────────────────────────────
527
+
528
+ describe("/mcp enable", () => {
529
+ it("writes disabled:false to the project override and hints /reload", async () => {
530
+ const defs = { srv: { ...stdioDef, disabled: true } };
531
+ const env = setupEnv(defs);
532
+ await env.run("enable srv");
533
+ expect(env.notify).toHaveBeenCalledWith("✓ srv enabled — run /reload to apply", "info");
534
+ const doc = JSON.parse(readFileSync(join(cwd, ".pi", "mcp.json"), "utf-8")) as {
535
+ mcpServers: Record<string, Record<string, unknown>>;
536
+ };
537
+ expect(doc.mcpServers.srv).toEqual({ disabled: false });
538
+ });
539
+
540
+ it("rejects a server that is not disabled", async () => {
541
+ const env = setupEnv({ srv: stdioDef });
542
+ await env.run("enable srv");
543
+ expect(env.notify).toHaveBeenCalledWith("Server srv is already enabled", "error");
544
+ });
545
+
546
+ it("rejects an unknown server", async () => {
547
+ const env = setupEnv({ srv: stdioDef });
548
+ await env.run("enable ghost");
549
+ expect(env.notify).toHaveBeenCalledWith("Unknown server: ghost", "error");
550
+ });
551
+
552
+ it("asks for a server name when none is given", async () => {
553
+ const env = setupEnv({ srv: stdioDef });
554
+ await env.run("enable");
555
+ expect(env.notify).toHaveBeenCalledWith("Usage: /mcp enable <server>", "info");
556
+ });
557
+ });
558
+
559
+ describe("/mcp disable", () => {
560
+ it("writes disabled:true, closes the managed client, and hints /reload", async () => {
561
+ const env = setupEnv({ srv: stdioDef });
562
+ const client = env.manager.getClient("srv")!;
563
+ await client.connect();
564
+ const closeSpy = vi.spyOn(client, "close");
565
+ await env.run("disable srv");
566
+ expect(env.notify).toHaveBeenCalledWith("✓ srv disabled — run /reload to apply", "info");
567
+ const doc = JSON.parse(readFileSync(join(cwd, ".pi", "mcp.json"), "utf-8")) as {
568
+ mcpServers: Record<string, Record<string, unknown>>;
569
+ };
570
+ expect(doc.mcpServers.srv).toEqual({ disabled: true });
571
+ expect(closeSpy).toHaveBeenCalled();
572
+ });
573
+
574
+ it("rejects a server that is already disabled", async () => {
575
+ const env = setupEnv({ srv: { ...stdioDef, disabled: true } });
576
+ await env.run("disable srv");
577
+ expect(env.notify).toHaveBeenCalledWith("Server srv is already disabled", "error");
578
+ });
579
+
580
+ it("rejects an unknown server", async () => {
581
+ const env = setupEnv({ srv: stdioDef });
582
+ await env.run("disable ghost");
583
+ expect(env.notify).toHaveBeenCalledWith("Unknown server: ghost", "error");
584
+ });
585
+
586
+ it("asks for a server name when none is given", async () => {
587
+ const env = setupEnv({ srv: stdioDef });
588
+ await env.run("disable");
589
+ expect(env.notify).toHaveBeenCalledWith("Usage: /mcp disable <server>", "info");
590
+ });
591
+
592
+ it("surfaces write-back failures as error notifications", async () => {
593
+ const { writeFileSync, mkdirSync } = await import("node:fs");
594
+ mkdirSync(join(cwd, ".pi"), { recursive: true });
595
+ writeFileSync(join(cwd, ".pi", "mcp.json"), "{not json", "utf-8");
596
+ const env = setupEnv({ srv: { ...stdioDef, disabled: true } });
597
+ await env.run("enable srv");
598
+ expect(env.notify).toHaveBeenCalledWith(expect.stringContaining("Refusing to overwrite"), "error");
599
+ });
600
+ });
601
+
602
+ // ── logout (shared mcpLogoutServer) ─────────────────────────────────────────
603
+
604
+ describe("/mcp logout", () => {
605
+ it("deletes the keyring entry and notifies", async () => {
606
+ const { deleteAuthEntry } = vi.mocked(await import("./auth-storage.js"));
607
+ const env = setupEnv({ srv: stdioDef });
608
+ await env.run("logout srv");
609
+ expect(deleteAuthEntry).toHaveBeenCalledWith("srv");
610
+ expect(env.notify).toHaveBeenCalledWith("Logged out of srv", "info");
611
+ });
612
+
613
+ it("closes the managed client when one exists", async () => {
614
+ const env = setupEnv({ srv: httpOauthDef });
615
+ const closeSpy = vi.spyOn(env.manager.getClient("srv")!, "close");
616
+ await env.run("logout srv");
617
+ expect(closeSpy).toHaveBeenCalled();
618
+ });
619
+
620
+ it("asks for a server name when none is given", async () => {
621
+ const env = setupEnv({ srv: stdioDef });
622
+ await env.run("logout");
623
+ expect(env.notify).toHaveBeenCalledWith("Usage: /mcp logout <server>", "info");
624
+ });
625
+
626
+ it("reports keyring failures instead of throwing", async () => {
627
+ const { deleteAuthEntry } = vi.mocked(await import("./auth-storage.js"));
628
+ deleteAuthEntry.mockImplementationOnce(() => {
629
+ throw new Error("OS credential store unavailable — cannot store OAuth tokens securely");
630
+ });
631
+ const env = setupEnv({ srv: stdioDef });
632
+ await env.run("logout srv");
633
+ expect(env.notify).toHaveBeenCalledWith(
634
+ expect.stringContaining("OS credential store unavailable"),
635
+ "error",
636
+ );
637
+ });
638
+ });
639
+
640
+ // ── auth (delegates to runMcpAuthCommand) ───────────────────────────────────
641
+
642
+ describe("/mcp auth", () => {
643
+ it("asks for a server name when none is given", async () => {
644
+ const env = setupEnv({ srv: httpOauthDef });
645
+ await env.run("auth");
646
+ expect(env.notify).toHaveBeenCalledWith("Usage: /mcp auth <server>", "info");
647
+ });
648
+
649
+ it("rejects an unknown server", async () => {
650
+ const env = setupEnv({ srv: httpOauthDef });
651
+ await env.run("auth ghost");
652
+ expect(env.notify).toHaveBeenCalledWith("Unknown server: ghost", "error");
653
+ });
654
+
655
+ it("treats stdio servers as unknown (OAuth is http-only)", async () => {
656
+ const env = setupEnv({ srv: stdioDef });
657
+ await env.run("auth srv");
658
+ expect(env.notify).toHaveBeenCalledWith("Unknown server: srv", "error");
659
+ });
660
+
661
+ it("runs the full OAuth loader flow for an http server (same UX as the former /mcp-auth)", async () => {
662
+ const env = setupEnv({ srv: httpOauthDef });
663
+ // The auth entry point is scripted (the real flow needs a live browser/
664
+ // callback server — exercised separately in the auth-flow tests); the
665
+ // loader, reconnect, and notification machinery under test is real.
666
+ const client = env.manager.getClient("srv")!;
667
+ vi.spyOn(client, "authenticate").mockResolvedValue(undefined);
668
+ await env.run("auth srv");
669
+ // The BorderedLoader stub surfaced its label
670
+ expect(env.state.lastLoader?.message).toContain("Authenticating srv");
671
+ expect(env.state.lastLoader?.message).toContain("esc to cancel");
672
+ // Success path: client reconnected, tool count reported
673
+ expect(env.notify).toHaveBeenCalledWith("✓ srv authenticated — 1 tools available", "info");
674
+ // ADR 0004: the post-auth reconnect is a settle point the panel task wires;
675
+ // the text command itself does not record outcomes in this task.
676
+ });
677
+ });
678
+
679
+ // ── panel / setup ─────────────────────────────────────────────────────
680
+
681
+ describe("/mcp panel", () => {
682
+ it("opens the shared-chrome management panel overlay; esc closes it", async () => {
683
+ const env = setupEnv({ srv: stdioDef });
684
+ const runPromise = env.run("panel");
685
+ // cmdPanel lazy-imports panel.js — wait for the overlay factory to run.
686
+ await vi.waitFor(() => expect(env.state.lastCustom).toBeTruthy());
687
+ const panel = env.state.lastCustom as {
688
+ render(width: number): string[];
689
+ handleInput(data: string): void;
690
+ invalidate(): void;
691
+ dispose(): void;
692
+ };
693
+ // Shared overlay chrome (ADR 0003): bordered, header shows the server
694
+ // count, server row is listed, and the footer hints pre-split into two
695
+ // lines at the standard 84-char width (116 > 80 content width).
696
+ const lines = panel.render(84);
697
+ expect(lines[0]).toContain("┌");
698
+ expect(lines[lines.length - 1]).toContain("└");
699
+ expect(lines[1]).toContain("MCP Servers [1]");
700
+ const joined = lines.join("\n");
701
+ expect(joined).toContain("srv");
702
+ expect(joined).toContain("(0/0 tools)");
703
+ expect(joined).toContain("[↑/↓] move");
704
+ expect(joined).toContain("[ctrl+s] save");
705
+ expect(joined).toContain("[esc] close");
706
+ expect(joined).not.toContain("later task");
707
+ // Esc outside authing closes the overlay (done() → run resolves) with no
708
+ // confirmation and no notifications.
709
+ panel.handleInput("\x1b");
710
+ await runPromise;
711
+ expect(env.notify).not.toHaveBeenCalled();
712
+ });
713
+
714
+ it("redirects to the setup overlay (not the management panel) when zero servers are configured", async () => {
715
+ const env = setupEnv({});
716
+ const runPromise = env.run("panel");
717
+ // Zero configured servers: cmdPanel must not open the management panel —
718
+ // the setup overlay factory runs instead, plus the announce notification.
719
+ await vi.waitFor(() => expect(env.state.lastCustom).toBeTruthy());
720
+ const panel = env.state.lastCustom as {
721
+ render(width: number): string[];
722
+ handleInput(data: string): void;
723
+ };
724
+ const lines = panel.render(84);
725
+ expect(lines[1]).toContain("MCP Setup");
726
+ expect(lines.join("\n")).not.toContain("MCP Servers");
727
+ // esc in the menu closes the overlay (done() → run resolves).
728
+ panel.handleInput("\x1b");
729
+ await runPromise;
730
+ expect(env.notify).toHaveBeenCalledTimes(1);
731
+ expect(env.notify).toHaveBeenCalledWith("No MCP servers configured — opening setup", "info");
732
+ });
733
+ });
734
+
735
+ describe("/mcp setup", () => {
736
+ it("opens the shared-chrome setup overlay; menu lists the actions; esc closes it", async () => {
737
+ const env = setupEnv({ srv: stdioDef });
738
+ const runPromise = env.run("setup");
739
+ // cmdSetup lazy-imports setup-panel.js — wait for the overlay factory to run.
740
+ await vi.waitFor(() => expect(env.state.lastCustom).toBeTruthy());
741
+ const panel = env.state.lastCustom as {
742
+ render(width: number): string[];
743
+ handleInput(data: string): void;
744
+ invalidate(): void;
745
+ dispose(): void;
746
+ };
747
+ // Shared overlay chrome (ADR 0003): bordered, "MCP Setup" header, the
748
+ // four menu actions, and bracket footer hints.
749
+ const lines = panel.render(84);
750
+ expect(lines[0]).toContain("┌");
751
+ expect(lines[lines.length - 1]).toContain("└");
752
+ expect(lines[1]).toContain("MCP Setup");
753
+ const joined = lines.join("\n");
754
+ expect(joined).toContain("Scaffold minimal .mcp.json");
755
+ expect(joined).toContain("Add a known server");
756
+ expect(joined).toContain("Import from another tool");
757
+ expect(joined).toContain("Cancel");
758
+ expect(joined).toContain("[enter] select");
759
+ expect(joined).not.toContain("later task");
760
+ // esc in the menu closes the overlay (done() → run resolves) without
761
+ // notifications.
762
+ panel.handleInput("\x1b");
763
+ await runPromise;
764
+ expect(env.notify).not.toHaveBeenCalled();
765
+ });
766
+ });
767
+
768
+ // ── bare /mcp (no subcommand) ───────────────────────────────────────────
769
+
770
+ describe("bare /mcp (no subcommand)", () => {
771
+ it("opens the management panel when a TUI is available", async () => {
772
+ const env = setupEnv({ srv: stdioDef }); // hasUI defaults to true
773
+ const runPromise = env.run("");
774
+ // The dispatcher special-cases the bare call (the parser still maps
775
+ // "" → status) — with hasUI the panel overlay factory must run.
776
+ await vi.waitFor(() => expect(env.state.lastCustom).toBeTruthy());
777
+ const panel = env.state.lastCustom as {
778
+ render(width: number): string[];
779
+ handleInput(data: string): void;
780
+ };
781
+ const lines = panel.render(84);
782
+ expect(lines[0]).toContain("┌");
783
+ expect(lines[1]).toContain("MCP Servers [1]");
784
+ expect(lines.join("\n")).toContain("srv");
785
+ // esc closes the overlay (done() → run resolves) with no notifications.
786
+ panel.handleInput("\x1b");
787
+ await runPromise;
788
+ expect(env.notify).not.toHaveBeenCalled();
789
+ });
790
+
791
+ it("falls back to the text status list when there is no TUI", async () => {
792
+ const env = setupEnv({ srv: stdioDef }, { hasUI: false });
793
+ await env.run("");
794
+ expect(env.notify).toHaveBeenCalledWith("○ srv: not connected", "info");
795
+ expect(env.state.lastCustom).toBeFalsy();
796
+ });
797
+
798
+ it("keeps an explicit /mcp status as text even in a TUI (regression guard)", async () => {
799
+ const env = setupEnv({ srv: stdioDef }); // hasUI: true
800
+ await env.run("status");
801
+ expect(env.notify).toHaveBeenCalledWith("○ srv: not connected", "info");
802
+ expect(env.state.lastCustom).toBeFalsy();
803
+ });
804
+
805
+ it("redirects to the setup panel when zero servers are configured (TUI)", async () => {
806
+ const env = setupEnv({}); // hasUI: true
807
+ const runPromise = env.run("");
808
+ await vi.waitFor(() => expect(env.state.lastCustom).toBeTruthy());
809
+ const panel = env.state.lastCustom as {
810
+ render(width: number): string[];
811
+ handleInput(data: string): void;
812
+ };
813
+ const lines = panel.render(84);
814
+ expect(lines[1]).toContain("MCP Setup");
815
+ panel.handleInput("\x1b");
816
+ await runPromise;
817
+ expect(env.notify).toHaveBeenCalledTimes(1);
818
+ expect(env.notify).toHaveBeenCalledWith("No MCP servers configured — opening setup", "info");
819
+ });
820
+ });
821
+
822
+ // ── unknown subcommand usage ────────────────────────────────────────────────
823
+
824
+ describe("/mcp unknown subcommand", () => {
825
+ it("shows a usage line listing the subcommands", async () => {
826
+ const env = setupEnv({ srv: stdioDef });
827
+ await env.run("frobnicate");
828
+ const [message] = env.notify.mock.calls[0] ?? [];
829
+ expect(message).toContain("Usage: /mcp");
830
+ for (const sub of ["status", "tools", "prompts", "reconnect", "enable", "disable", "logout", "auth", "panel", "setup"]) {
831
+ expect(message).toContain(sub);
832
+ }
833
+ });
834
+ });