@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,689 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ExtensionAPI } 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 { registerMcp, setIndexSeamsForTest } from "./index.js";
9
+ import { ServerManager } from "./server-manager.js";
10
+ import { ServerClient } from "./server-client.js";
11
+ import {
12
+ clearRegisteredForTest,
13
+ getRegisteredNamesForTest,
14
+ } from "./direct-tools.js";
15
+ import { setCachePathForTest, saveServerCache } from "./metadata-cache.js";
16
+ import { DEFAULT_MCP_CONFIG } from "./types.js";
17
+ import type { McpConfig, ServerDef } from "./types.js";
18
+
19
+ // ── mocks ──────────────────────────────────────────────────────────────────
20
+ // The real BorderedLoader needs a live TUI; a stub with the same surface
21
+ // (constructor message + onAbort) is enough to drive runAuthWithLoader.
22
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
23
+ BorderedLoader: class {
24
+ message: string;
25
+ onAbort?: () => void;
26
+ constructor(_tui: unknown, _theme: unknown, message: string) {
27
+ this.message = message;
28
+ }
29
+ dispose() {}
30
+ },
31
+ // core/settings-io builds its settings path at module load
32
+ getAgentDir: () => `${process.env.TMPDIR ?? "/tmp"}/pi-archimedes-mock-agent`,
33
+ }));
34
+
35
+ // ── fakes ────────────────────────────────────────────────────────────────────
36
+
37
+ interface CapturedTool {
38
+ name: string;
39
+ execute?: (
40
+ toolCallId: string,
41
+ params: unknown,
42
+ signal?: AbortSignal,
43
+ onUpdate?: unknown,
44
+ ctx?: unknown,
45
+ ) => Promise<ExecuteResult>;
46
+ }
47
+
48
+ interface ExecuteResult {
49
+ content: Array<{ type: string; text?: string }>;
50
+ details?: Record<string, unknown>;
51
+ isError?: boolean;
52
+ }
53
+
54
+ /** Fake ExtensionAPI capturing event handlers, registered tools, and commands */
55
+ function makeFakePi(): {
56
+ pi: ExtensionAPI;
57
+ handlers: Record<string, Array<(...args: unknown[]) => unknown>>;
58
+ tools: CapturedTool[];
59
+ commands: Record<string, { description?: string; handler: unknown }>;
60
+ } {
61
+ const handlers: Record<string, Array<(...args: unknown[]) => unknown>> = {};
62
+ const tools: CapturedTool[] = [];
63
+ const commands: Record<string, { description?: string; handler: unknown }> = {};
64
+ const pi = {
65
+ on: (name: string, fn: (...args: unknown[]) => unknown) => {
66
+ (handlers[name] ??= []).push(fn);
67
+ },
68
+ registerTool: (def: CapturedTool) => {
69
+ tools.push(def);
70
+ },
71
+ registerCommand: (name: string, def: { description?: string; handler: unknown }) => {
72
+ commands[name] = def;
73
+ },
74
+ } as unknown as ExtensionAPI;
75
+ return { pi, handlers, tools, commands };
76
+ }
77
+
78
+ /**
79
+ * Fake SDK Client. Only the surface ServerClient touches is implemented.
80
+ * `callLog` records the RAW tool names passed to callTool so tests can assert
81
+ * on the name-resolution behaviour of the proxy.
82
+ */
83
+ function makeFakeSdkClient(opts: {
84
+ tools?: Array<{ name: string; description?: string; inputSchema: unknown }>;
85
+ onConnect?: (transport: unknown) => Promise<void> | void;
86
+ } = {}) {
87
+ const fake = {
88
+ callLog: [] as Array<{ name: string; args: Record<string, unknown> }>,
89
+ async connect(transport: unknown) {
90
+ await opts.onConnect?.(transport);
91
+ },
92
+ async listTools() {
93
+ return { tools: opts.tools ?? [] };
94
+ },
95
+ async listResources() {
96
+ return { resources: [] };
97
+ },
98
+ async listPrompts() {
99
+ return { prompts: [] };
100
+ },
101
+ getServerCapabilities() {
102
+ return undefined;
103
+ },
104
+ getInstructions() {
105
+ return undefined;
106
+ },
107
+ async callTool(params: { name: string; arguments: Record<string, unknown> }) {
108
+ fake.callLog.push({ name: params.name, args: params.arguments });
109
+ return { content: [{ type: "text", text: `result:${params.name}` }] };
110
+ },
111
+ async close() {},
112
+ };
113
+ return fake;
114
+ }
115
+
116
+ let tmp: string;
117
+ beforeEach(() => {
118
+ tmp = mkdtempSync(join(tmpdir(), "mcp-index-test-"));
119
+ setCachePathForTest(join(tmp, "cache.json"));
120
+ });
121
+
122
+ afterEach(() => {
123
+ setIndexSeamsForTest(null);
124
+ setCachePathForTest(null);
125
+ });
126
+
127
+ /** Wire the index module to a fake manager + config loaders, return the proxy execute fn */
128
+ function setupProxy(
129
+ defs: Record<string, ServerDef>,
130
+ sdkClient: unknown,
131
+ config: McpConfig = DEFAULT_MCP_CONFIG,
132
+ ): (params: Record<string, unknown>) => Promise<ExecuteResult> {
133
+ const manager = new ServerManager({
134
+ clientFactory: () => sdkClient as unknown as Client,
135
+ });
136
+ setIndexSeamsForTest({
137
+ manager,
138
+ loadServerDefs: () => defs,
139
+ loadMcpConfig: () => config,
140
+ });
141
+ const { pi, tools } = makeFakePi();
142
+ registerMcp(pi);
143
+ const mcpTool = tools.find((t) => t.name === "mcp");
144
+ if (!mcpTool?.execute) throw new Error("mcp proxy tool not registered");
145
+ const execute = mcpTool.execute;
146
+ return (params) => execute!("call", params, undefined, undefined, undefined);
147
+ }
148
+
149
+ // ── call: raw tool-name resolution ───────────────────────────────────────────
150
+
151
+ describe("mcp proxy — call tool", () => {
152
+ it("calls the RAW dotted tool name when given its sanitized prefixed name", async () => {
153
+ const def: ServerDef = { type: "stdio", command: "true" };
154
+ const sdk = makeFakeSdkClient({
155
+ tools: [
156
+ { name: "a.b", description: "dotted tool", inputSchema: {} },
157
+ { name: "plain", inputSchema: {} },
158
+ ],
159
+ });
160
+ // Seed the metadata cache so getToolsForServer has the raw names offline
161
+ saveServerCache("srv", def, {
162
+ tools: [
163
+ { name: "a.b", description: "dotted tool", inputSchema: {} },
164
+ { name: "plain", inputSchema: {} },
165
+ ],
166
+ resources: [],
167
+ });
168
+ const run = setupProxy({ srv: def }, sdk);
169
+
170
+ // "srv_a_b" is the SANITIZED form of the raw tool "a.b" — the proxy must
171
+ // call the server with "a.b", not "a_b".
172
+ const result = await run({ tool: "srv_a_b" });
173
+ expect(sdk.callLog).toEqual([{ name: "a.b", args: {} }]);
174
+ expect(result.content).toEqual([{ type: "text", text: "result:a.b" }]);
175
+ expect(result.details).toEqual({ server: "srv", tool: "a.b" });
176
+
177
+ // Dot-free names keep working via the same path
178
+ await run({ tool: "srv_plain" });
179
+ expect(sdk.callLog[1]).toEqual({ name: "plain", args: {} });
180
+ });
181
+
182
+ it("still accepts the raw (unprefixed) tool name", async () => {
183
+ const def: ServerDef = { type: "stdio", command: "true" };
184
+ const sdk = makeFakeSdkClient({ tools: [{ name: "a.b", inputSchema: {} }] });
185
+ saveServerCache("srv", def, {
186
+ tools: [{ name: "a.b", inputSchema: {} }],
187
+ resources: [],
188
+ });
189
+ const run = setupProxy({ srv: def }, sdk);
190
+
191
+ await run({ tool: "a.b" });
192
+ expect(sdk.callLog).toEqual([{ name: "a.b", args: {} }]);
193
+ });
194
+
195
+ it("resolves a prefixed tool name when an explicit server is also given", async () => {
196
+ const def: ServerDef = { type: "stdio", command: "true" };
197
+ const sdk = makeFakeSdkClient({
198
+ tools: [
199
+ { name: "a.b", description: "dotted tool", inputSchema: {} },
200
+ { name: "plain", inputSchema: {} },
201
+ ],
202
+ });
203
+ saveServerCache("srv", def, {
204
+ tools: [
205
+ { name: "a.b", description: "dotted tool", inputSchema: {} },
206
+ { name: "plain", inputSchema: {} },
207
+ ],
208
+ resources: [],
209
+ });
210
+ const run = setupProxy({ srv: def }, sdk);
211
+
212
+ // mcp({ tool: "srv_a_b", server: "srv" }) — the prefixed name must be
213
+ // resolved to the raw dotted name, not passed through as-is.
214
+ const result = await run({ server: "srv", tool: "srv_a_b" });
215
+ expect(sdk.callLog).toEqual([{ name: "a.b", args: {} }]);
216
+ expect(result.details).toEqual({ server: "srv", tool: "a.b" });
217
+
218
+ // A dot-free prefixed name resolves too
219
+ await run({ server: "srv", tool: "srv_plain" });
220
+ expect(sdk.callLog[1]).toEqual({ name: "plain", args: {} });
221
+
222
+ // A raw name with an explicit server still passes through unchanged
223
+ await run({ server: "srv", tool: "a.b" });
224
+ expect(sdk.callLog[2]).toEqual({ name: "a.b", args: {} });
225
+ });
226
+
227
+ it("passes string args through JSON parsing to the raw tool", async () => {
228
+ const def: ServerDef = { type: "stdio", command: "true" };
229
+ const sdk = makeFakeSdkClient({ tools: [{ name: "a.b", inputSchema: {} }] });
230
+ saveServerCache("srv", def, {
231
+ tools: [{ name: "a.b", inputSchema: {} }],
232
+ resources: [],
233
+ });
234
+ const run = setupProxy({ srv: def }, sdk);
235
+
236
+ await run({ tool: "srv_a_b", args: '{"q": 42}' });
237
+ expect(sdk.callLog).toEqual([{ name: "a.b", args: { q: 42 } }]);
238
+ });
239
+ });
240
+
241
+ // ── status action ────────────────────────────────────────────────────────────────────
242
+
243
+ describe("mcp proxy — status action", () => {
244
+ it("treats action:'status' the same as a no-parameter call", async () => {
245
+ const run = setupProxy({}, makeFakeSdkClient());
246
+ const result = await run({ action: "status" });
247
+ expect(result.content[0]?.text).toBe("No MCP servers configured.");
248
+ });
249
+
250
+ it("lists server statuses for action:'status' with configured servers", async () => {
251
+ const def: ServerDef = { type: "stdio", command: "true" };
252
+ const run = setupProxy({ srv: def }, makeFakeSdkClient());
253
+ const result = await run({ action: "status" });
254
+ expect(result.content[0]?.text).toContain("srv: disconnected");
255
+ });
256
+
257
+ it("still reports unknown actions as unknown", async () => {
258
+ const run = setupProxy({}, makeFakeSdkClient());
259
+ const result = await run({ action: "bogus" });
260
+ expect(result.content[0]?.text).toBe("Unknown action");
261
+ });
262
+ });
263
+
264
+ // ── search / describe / list accept prefixed names ──────────────────────────────
265
+
266
+ describe("mcp proxy — prefixed-name support in discovery actions", () => {
267
+ const def: ServerDef = { type: "stdio", command: "true" };
268
+ const seedCache = (): unknown => {
269
+ const sdk = makeFakeSdkClient({
270
+ tools: [{ name: "a.b", description: "dotted tool", inputSchema: {} }],
271
+ });
272
+ saveServerCache("srv", def, {
273
+ tools: [{ name: "a.b", description: "dotted tool", inputSchema: {} }],
274
+ resources: [],
275
+ });
276
+ return sdk;
277
+ };
278
+
279
+ it("describe resolves a sanitized prefixed name to the raw tool", async () => {
280
+ const sdk = seedCache();
281
+ const run = setupProxy({ srv: def }, sdk);
282
+ const result = await run({ describe: "srv_a_b" });
283
+ expect(result.content[0]?.text).toContain("a.b (srv)");
284
+ expect(result.content[0]?.text).toContain("dotted tool");
285
+ expect(result.content[0]?.text).toContain("Schema:");
286
+ });
287
+
288
+ it("describe still resolves the raw name", async () => {
289
+ const sdk = seedCache();
290
+ const run = setupProxy({ srv: def }, sdk);
291
+ const result = await run({ describe: "a.b" });
292
+ expect(result.content[0]?.text).toContain("a.b (srv)");
293
+ });
294
+
295
+ it("search retries with the raw name when the query is a prefixed name", async () => {
296
+ const sdk = seedCache();
297
+ const run = setupProxy({ srv: def }, sdk);
298
+ const result = await run({ search: "srv_a_b" });
299
+ expect(result.content[0]?.text).toContain("a.b (srv)");
300
+ });
301
+
302
+ it("search by plain keyword is unchanged", async () => {
303
+ const sdk = seedCache();
304
+ const run = setupProxy({ srv: def }, sdk);
305
+ const result = await run({ search: "dotted" });
306
+ expect(result.content[0]?.text).toContain("a.b (srv)");
307
+ });
308
+
309
+ it("list accepts a bare server prefix (short mode) as the server reference", async () => {
310
+ const shortDef: ServerDef = { type: "stdio", command: "true", toolPrefix: "short" };
311
+ const sdk = makeFakeSdkClient({
312
+ tools: [{ name: "search", description: "find things", inputSchema: {} }],
313
+ });
314
+ saveServerCache("github-mcp", shortDef, {
315
+ tools: [{ name: "search", description: "find things", inputSchema: {} }],
316
+ resources: [],
317
+ });
318
+ const run = setupProxy({ "github-mcp": shortDef }, sdk);
319
+ // "github" is the SHORT prefix of server "github-mcp" — not a server name
320
+ const result = await run({ server: "github" });
321
+ expect(result.content[0]?.text).toContain("search");
322
+ expect(result.details).toEqual({ server: "github-mcp", toolCount: 1 });
323
+ });
324
+
325
+ it("list still rejects unknown server references", async () => {
326
+ const sdk = seedCache();
327
+ const run = setupProxy({ srv: def }, sdk);
328
+ const result = await run({ server: "nope" });
329
+ expect(result.content[0]?.text).toBe("Unknown server: nope");
330
+ });
331
+ });
332
+
333
+ // ── connect action: needs-auth must not be reported as success ────────────
334
+
335
+ describe("mcp proxy — connect action with needs-auth", () => {
336
+ const httpDef: ServerDef = { type: "http", url: "http://127.0.0.1:1/mcp" };
337
+
338
+ it("reports the needs-auth error instead of 'Connected to X'", async () => {
339
+ const sdk = makeFakeSdkClient({
340
+ onConnect: () => {
341
+ throw new StreamableHTTPError(401, "Unauthorized");
342
+ },
343
+ });
344
+ const run = setupProxy({ "auth-srv": httpDef }, sdk);
345
+ const result = await run({ connect: "auth-srv" });
346
+ const text = result.content[0]?.text ?? "";
347
+ expect(text).toContain("requires authentication");
348
+ expect(text).toContain("OAuth");
349
+ expect(text).not.toContain("tools available");
350
+ expect(result.details).toEqual({ server: "auth-srv", status: "needs-auth" });
351
+ });
352
+
353
+ it("still reports a successful connect with the tool count", async () => {
354
+ const def: ServerDef = { type: "stdio", command: "true" };
355
+ const sdk = makeFakeSdkClient({ tools: [{ name: "t1", inputSchema: {} }] });
356
+ const run = setupProxy({ srv: def }, sdk);
357
+ const result = await run({ connect: "srv" });
358
+ expect(result.content[0]?.text).toBe("Connected to srv. 1 tools available.");
359
+ });
360
+ });
361
+
362
+ // ── call action: needs-auth at call time (autoAuth) ──────────────────────
363
+
364
+ describe("mcp proxy — call with needs-auth server", () => {
365
+ const httpDef: ServerDef = { type: "http", url: "http://127.0.0.1:1/mcp", auth: "oauth" };
366
+
367
+ /** SDK fake that 401s on connect until `approved` flips (post-auth reconnect succeeds). */
368
+ function makeOauthSdk(approved: { value: boolean }) {
369
+ return makeFakeSdkClient({
370
+ tools: [{ name: "t1", inputSchema: {} }],
371
+ onConnect: () => {
372
+ if (!approved.value) throw new StreamableHTTPError(401, "Unauthorized");
373
+ },
374
+ });
375
+ }
376
+
377
+ it("returns /mcp auth guidance (isError false) and never authenticates when autoAuth is off (default)", async () => {
378
+ const approved = { value: false };
379
+ const sdk = makeOauthSdk(approved);
380
+ saveServerCache("auth-srv", httpDef, { tools: [{ name: "t1", inputSchema: {} }], resources: [] });
381
+ const run = setupProxy({ "auth-srv": httpDef }, sdk);
382
+ const authSpy = vi.spyOn(ServerClient.prototype, "authenticate");
383
+ try {
384
+ const result = await run({ tool: "t1", server: "auth-srv" });
385
+ const text = result.content[0]?.text ?? "";
386
+ expect(text).toContain("requires authentication");
387
+ expect(text).toContain("/mcp auth auth-srv");
388
+ expect(result.isError).toBeFalsy();
389
+ expect(authSpy).not.toHaveBeenCalled();
390
+ // The tool was never dispatched to the server
391
+ expect(sdk.callLog).toEqual([]);
392
+ } finally {
393
+ authSpy.mockRestore();
394
+ }
395
+ });
396
+
397
+ it("auto-authenticates and retries the call once when autoAuth is on and the flow succeeds", async () => {
398
+ const approved = { value: false };
399
+ const sdk = makeOauthSdk(approved);
400
+ saveServerCache("auth-srv", httpDef, { tools: [{ name: "t1", inputSchema: {} }], resources: [] });
401
+ const run = setupProxy(
402
+ { "auth-srv": httpDef },
403
+ sdk,
404
+ { ...DEFAULT_MCP_CONFIG, autoAuth: true },
405
+ );
406
+ const authSpy = vi.spyOn(ServerClient.prototype, "authenticate").mockImplementation(
407
+ async function (this: ServerClient) {
408
+ // Simulate a completed browser flow (fresh tokens stored)
409
+ approved.value = true;
410
+ },
411
+ );
412
+ try {
413
+ const result = await run({ tool: "t1", server: "auth-srv" });
414
+ expect(authSpy).toHaveBeenCalledTimes(1);
415
+ // The single retry reached the server with the raw tool name
416
+ expect(sdk.callLog).toEqual([{ name: "t1", args: {} }]);
417
+ expect(result.content).toEqual([{ type: "text", text: "result:t1" }]);
418
+ expect(result.isError).toBe(false);
419
+ } finally {
420
+ authSpy.mockRestore();
421
+ }
422
+ });
423
+
424
+ it("returns guidance with the error when autoAuth is on but the flow is cancelled", async () => {
425
+ const approved = { value: false };
426
+ const sdk = makeOauthSdk(approved);
427
+ saveServerCache("auth-srv", httpDef, { tools: [{ name: "t1", inputSchema: {} }], resources: [] });
428
+ const run = setupProxy(
429
+ { "auth-srv": httpDef },
430
+ sdk,
431
+ { ...DEFAULT_MCP_CONFIG, autoAuth: true },
432
+ );
433
+ const authSpy = vi.spyOn(ServerClient.prototype, "authenticate").mockRejectedValue(
434
+ new Error("OAuth cancelled"),
435
+ );
436
+ try {
437
+ const result = await run({ tool: "t1", server: "auth-srv" });
438
+ const text = result.content[0]?.text ?? "";
439
+ expect(text).toContain("OAuth cancelled");
440
+ expect(text).toContain("/mcp auth auth-srv");
441
+ expect(result.isError).toBeFalsy();
442
+ expect(approved.value).toBe(false);
443
+ // No retry was attempted
444
+ expect(sdk.callLog).toEqual([]);
445
+ } finally {
446
+ authSpy.mockRestore();
447
+ }
448
+ });
449
+ });
450
+
451
+ // ── command wiring: index registers ONLY /mcp; auth flows through it ──────
452
+
453
+ describe("mcp proxy — command wiring", () => {
454
+ it("registers only the mcp command (standalone OAuth commands retired)", async () => {
455
+ const def: ServerDef = { type: "stdio", command: "true" };
456
+ const manager = new ServerManager({
457
+ clientFactory: () => makeFakeSdkClient() as unknown as Client,
458
+ });
459
+ setIndexSeamsForTest({
460
+ manager,
461
+ loadServerDefs: () => ({ srv: def }),
462
+ loadAllServerDefs: () => ({ srv: def }),
463
+ loadMcpConfig: () => DEFAULT_MCP_CONFIG,
464
+ });
465
+ const { pi, commands } = makeFakePi();
466
+ registerMcp(pi);
467
+ // The command registry contains ONLY "mcp" — /mcp auth and /mcp logout
468
+ // dispatch through it; the standalone commands are gone.
469
+ expect(Object.keys(commands).sort()).toEqual(["mcp"]);
470
+
471
+ // A stdio server is not OAuth-capable: /mcp auth must surface it as an
472
+ // unknown (non-http) server, not attempt auth.
473
+ const notify = vi.fn();
474
+ const ctx = { hasUI: true, ui: { notify, custom: vi.fn() } } as never;
475
+ const handler = commands["mcp"]!.handler as (
476
+ args: string,
477
+ ctx: unknown,
478
+ ) => Promise<void>;
479
+ await handler("auth srv", ctx);
480
+ expect(notify).toHaveBeenCalledWith("Unknown server: srv", "error");
481
+ });
482
+
483
+ it("/mcp auth finds a typeless url server configured for oauth (shape-based classification)", async () => {
484
+ const def: ServerDef = { url: "https://mcp.example.com/mcp", auth: "oauth" };
485
+ const manager = new ServerManager({
486
+ clientFactory: () => makeFakeSdkClient() as unknown as Client,
487
+ });
488
+ setIndexSeamsForTest({
489
+ manager,
490
+ loadServerDefs: () => ({ srv: def }),
491
+ loadAllServerDefs: () => ({ srv: def }),
492
+ loadMcpConfig: () => DEFAULT_MCP_CONFIG,
493
+ });
494
+ const { pi, commands } = makeFakePi();
495
+ registerMcp(pi);
496
+
497
+ // The auth flow needs a managed client: session_start syncs in
498
+ // production; mirror that here.
499
+ manager.sync({ srv: def });
500
+
501
+ const authSpy = vi.spyOn(ServerClient.prototype, "authenticate").mockResolvedValue(
502
+ undefined,
503
+ );
504
+ try {
505
+ const notify = vi.fn();
506
+ // ui.custom runs the loader factory and resolves when done() is called
507
+ const custom = vi.fn(
508
+ (factory: (
509
+ tui: unknown,
510
+ theme: unknown,
511
+ keybindings: unknown,
512
+ done: (result: unknown) => void,
513
+ ) => unknown) => {
514
+ let resolve!: (result: unknown) => void;
515
+ const pending = new Promise<unknown>((r) => (resolve = r));
516
+ let settled = false;
517
+ const done = (result: unknown) => {
518
+ if (!settled) {
519
+ settled = true;
520
+ resolve(result);
521
+ }
522
+ };
523
+ factory({}, {}, {}, done);
524
+ return pending;
525
+ },
526
+ );
527
+ const ctx = { hasUI: true, ui: { notify, custom } } as never;
528
+ const handler = commands["mcp"]!.handler as (
529
+ args: string,
530
+ ctx: unknown,
531
+ ) => Promise<void>;
532
+ await handler("auth srv", ctx);
533
+
534
+ // Shape (url) classified it as HTTP: the OAuth entry point ran and the
535
+ // full flow completed — NOT "Unknown server".
536
+ expect(authSpy).toHaveBeenCalledTimes(1);
537
+ expect(notify).not.toHaveBeenCalledWith("Unknown server: srv", "error");
538
+ expect(notify).toHaveBeenCalledWith("✓ srv authenticated — 0 tools available", "info");
539
+ } finally {
540
+ authSpy.mockRestore();
541
+ }
542
+ });
543
+ });
544
+
545
+ // ── test seam reset ───────────────────────────────────────────────────────────
546
+
547
+ describe("mcp proxy — test seam reset", () => {
548
+ it("setIndexSeamsForTest(null) discards the swapped-in manager", async () => {
549
+ const def: ServerDef = { type: "stdio", command: "true" };
550
+ const sdk = makeFakeSdkClient({ tools: [{ name: "t1", inputSchema: {} }] });
551
+ const manager = new ServerManager({
552
+ clientFactory: () => sdk as unknown as Client,
553
+ });
554
+ manager.sync({ srv: def });
555
+ await manager.getClient("srv")!.connect();
556
+ expect(manager.getClient("srv")!.status).toBe("connected");
557
+
558
+ setIndexSeamsForTest({
559
+ manager,
560
+ loadServerDefs: () => ({ srv: def }),
561
+ loadMcpConfig: () => DEFAULT_MCP_CONFIG,
562
+ });
563
+
564
+ // Reset the seam — the swapped-in (fake, connected) manager must be
565
+ // discarded, not left in place.
566
+ setIndexSeamsForTest(null);
567
+
568
+ // Re-arm only the config loaders: the module-level manager should now be
569
+ // a FRESH one, so the same server must be reported as a brand-new
570
+ // (disconnected) client rather than the stale fake (connected).
571
+ setIndexSeamsForTest({
572
+ loadServerDefs: () => ({ srv: def }),
573
+ loadMcpConfig: () => DEFAULT_MCP_CONFIG,
574
+ });
575
+ const { pi, tools } = makeFakePi();
576
+ registerMcp(pi);
577
+ const mcpTool = tools.find((t) => t.name === "mcp");
578
+ if (!mcpTool?.execute) throw new Error("mcp proxy tool not registered");
579
+ const result = await mcpTool.execute!("call", {}, undefined, undefined, undefined);
580
+ expect(result.content[0]?.text).toBe("srv: disconnected");
581
+ });
582
+ });
583
+
584
+ // ── session_start probe: superseded client must not evict registered names ──
585
+
586
+ describe("mcp proxy — session_start probe fenced by a superseded client", () => {
587
+ it("does not re-register when the probe's client is replaced mid-connect", async () => {
588
+ clearRegisteredForTest();
589
+ const def1: ServerDef = { type: "stdio", command: "cmd-v1" };
590
+ const def2: ServerDef = { type: "stdio", command: "cmd-v2" };
591
+ let defs: Record<string, ServerDef> = { srv: def1 };
592
+
593
+ // sdk1 backs the FIRST (stale) client. Its connect holds the probe at the
594
+ // gate after a concurrent session_start (a /reload with an updated def)
595
+ // has already sync()'d — closing (generation-fencing) and replacing the
596
+ // stale client. Releasing the gate lets the fenced connect resolve with
597
+ // EMPTY tools, exactly like the real fence path.
598
+ let releaseStale!: () => void;
599
+ const staleGate = new Promise<void>((r) => (releaseStale = r));
600
+ const sdk1 = makeFakeSdkClient({
601
+ tools: [{ name: "t1", inputSchema: {} }],
602
+ onConnect: async () => {
603
+ defs = { srv: def2 };
604
+ manager.sync(defs);
605
+ await staleGate;
606
+ },
607
+ });
608
+ const sdk2 = makeFakeSdkClient({
609
+ tools: [{ name: "t1", inputSchema: {} }],
610
+ });
611
+ let created = 0;
612
+ const manager = new ServerManager({
613
+ clientFactory: () => (created++ === 0 ? sdk1 : sdk2) as unknown as Client,
614
+ });
615
+ setIndexSeamsForTest({
616
+ manager,
617
+ loadServerDefs: () => defs,
618
+ loadMcpConfig: () => DEFAULT_MCP_CONFIG,
619
+ });
620
+ const { pi, handlers, tools } = makeFakePi();
621
+ registerMcp(pi);
622
+ const start = handlers["session_start"]?.[0];
623
+ if (!start) throw new Error("session_start handler not registered");
624
+
625
+ // 1st session_start: no valid cache → background probe starts connecting
626
+ // the stale client (blocked at the gate after the sync above).
627
+ await start();
628
+
629
+ // 2nd session_start (the /reload): the client has been replaced, its
630
+ // probe connects the fresh client and registers "srv_t1".
631
+ await start();
632
+ await vi.waitFor(() => {
633
+ expect(getRegisteredNamesForTest().get("srv")?.has("srv_t1")).toBe(true);
634
+ });
635
+
636
+ // The stale connect now completes. Its probe must detect that it was
637
+ // superseded and NOT run a zero-tool registration pass — that would
638
+ // replace the server's tracked set with an empty one, evicting the
639
+ // names the current session just registered.
640
+ releaseStale();
641
+ await new Promise((r) => setTimeout(r, 10)); // drain the probe's microtasks
642
+
643
+ expect(getRegisteredNamesForTest().get("srv")?.has("srv_t1")).toBe(true);
644
+
645
+ // A later session_start must therefore NOT call pi.registerTool again
646
+ // for a name pi already holds (duplicate registration).
647
+ await start();
648
+ expect(tools.filter((t) => t.name === "srv_t1")).toHaveLength(1);
649
+ });
650
+ });
651
+
652
+ // ── session_start probe: needs-auth servers warn instead of registering 0 tools ──
653
+
654
+ describe("mcp proxy — session_start background probe with needs-auth", () => {
655
+ const httpDef: ServerDef = { type: "http", url: "http://127.0.0.1:1/mcp" };
656
+
657
+ it("warns and skips registration when a server 401s during the probe", async () => {
658
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
659
+ const sdk = makeFakeSdkClient({
660
+ onConnect: () => {
661
+ throw new StreamableHTTPError(401, "Unauthorized");
662
+ },
663
+ });
664
+ const manager = new ServerManager({
665
+ clientFactory: () => sdk as unknown as Client,
666
+ });
667
+ setIndexSeamsForTest({
668
+ manager,
669
+ loadServerDefs: () => ({ "auth-srv": httpDef }),
670
+ loadMcpConfig: () => DEFAULT_MCP_CONFIG,
671
+ });
672
+ const { pi, handlers, tools } = makeFakePi();
673
+ registerMcp(pi);
674
+ const start = handlers["session_start"]?.[0];
675
+ if (!start) throw new Error("session_start handler not registered");
676
+
677
+ await start();
678
+ // The probe is fire-and-forget — wait for it to settle
679
+ await vi.waitFor(() =>
680
+ expect(warn).toHaveBeenCalledWith(
681
+ expect.stringContaining('server "auth-srv" requires authentication'),
682
+ ),
683
+ );
684
+ // No direct tools registered for the needs-auth server (proxy only)
685
+ expect(tools).toHaveLength(1);
686
+ expect(tools[0]?.name).toBe("mcp");
687
+ warn.mockRestore();
688
+ });
689
+ });