pi-soly 1.9.3 → 1.11.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 (104) hide show
  1. package/ask/index.ts +12 -11
  2. package/ask/picker.ts +356 -76
  3. package/ask/prompt.ts +6 -2
  4. package/ask/tests/picker.test.ts +273 -82
  5. package/codemap.ts +276 -0
  6. package/hotreload.ts +239 -0
  7. package/init.ts +302 -0
  8. package/mcp/CHANGELOG.md +384 -0
  9. package/mcp/LICENSE +21 -0
  10. package/mcp/OAUTH.md +355 -0
  11. package/mcp/README.md +410 -0
  12. package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
  13. package/mcp/__tests__/cli.upstream-test.ts +97 -0
  14. package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
  15. package/mcp/__tests__/config.upstream-test.ts +303 -0
  16. package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
  17. package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
  18. package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
  19. package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
  20. package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
  21. package/mcp/__tests__/errors.upstream-test.ts +218 -0
  22. package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
  23. package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
  24. package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
  25. package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
  26. package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
  27. package/mcp/__tests__/logger.upstream-test.ts +175 -0
  28. package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
  29. package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
  30. package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
  31. package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
  32. package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
  33. package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
  34. package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
  35. package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
  36. package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
  37. package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
  38. package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
  39. package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
  40. package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
  41. package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
  42. package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
  43. package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
  44. package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
  45. package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
  46. package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
  47. package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
  48. package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
  49. package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
  50. package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
  51. package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
  52. package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
  53. package/mcp/agent-dir.ts +20 -0
  54. package/mcp/app-bridge.bundle.js +67 -0
  55. package/mcp/cli.js +184 -0
  56. package/mcp/commands.ts +422 -0
  57. package/mcp/config.ts +666 -0
  58. package/mcp/consent-manager.ts +64 -0
  59. package/mcp/direct-tools.ts +439 -0
  60. package/mcp/elicitation-handler.ts +347 -0
  61. package/mcp/errors.ts +219 -0
  62. package/mcp/glimpse-ui.ts +80 -0
  63. package/mcp/host-html-template.ts +427 -0
  64. package/mcp/index.ts +362 -0
  65. package/mcp/init.ts +362 -0
  66. package/mcp/lifecycle.ts +93 -0
  67. package/mcp/logger.ts +169 -0
  68. package/mcp/mcp-auth-flow.ts +559 -0
  69. package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
  70. package/mcp/mcp-auth.ts +302 -0
  71. package/mcp/mcp-auth.upstream-test.ts +373 -0
  72. package/mcp/mcp-callback-server.ts +372 -0
  73. package/mcp/mcp-callback-server.upstream-test.ts +416 -0
  74. package/mcp/mcp-oauth-provider.ts +369 -0
  75. package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
  76. package/mcp/mcp-panel.ts +829 -0
  77. package/mcp/mcp-setup-panel.ts +580 -0
  78. package/mcp/metadata-cache.ts +201 -0
  79. package/mcp/notify.ts +111 -0
  80. package/mcp/npx-resolver.ts +424 -0
  81. package/mcp/oauth-handler.ts +57 -0
  82. package/mcp/onboarding-state.ts +68 -0
  83. package/mcp/package.json +106 -0
  84. package/mcp/panel-keys.ts +37 -0
  85. package/mcp/proxy-modes.ts +949 -0
  86. package/mcp/resource-tools.ts +17 -0
  87. package/mcp/sampling-handler.ts +268 -0
  88. package/mcp/server-manager.ts +545 -0
  89. package/mcp/state.ts +41 -0
  90. package/mcp/tool-metadata.ts +216 -0
  91. package/mcp/tool-registrar.ts +46 -0
  92. package/mcp/tool-result-renderer.ts +161 -0
  93. package/mcp/types.ts +448 -0
  94. package/mcp/ui-resource-handler.ts +146 -0
  95. package/mcp/ui-server.ts +623 -0
  96. package/mcp/ui-session.ts +386 -0
  97. package/mcp/ui-stream-types.ts +89 -0
  98. package/mcp/utils.ts +129 -0
  99. package/mcp/vitest.config.ts +14 -0
  100. package/migrate.ts +258 -0
  101. package/notification.ts +218 -0
  102. package/notifications-log.ts +83 -0
  103. package/package.json +20 -3
  104. package/status.ts +140 -0
@@ -0,0 +1,484 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ initializeMcp: vi.fn(),
5
+ updateStatusBar: vi.fn(),
6
+ flushMetadataCache: vi.fn(),
7
+ initializeOAuth: vi.fn().mockResolvedValue(undefined),
8
+ shutdownOAuth: vi.fn().mockResolvedValue(undefined),
9
+ loadMcpConfig: vi.fn(() => ({ mcpServers: {} })),
10
+ loadMetadataCache: vi.fn(() => null),
11
+ buildProxyDescription: vi.fn(() => "MCP gateway"),
12
+ createDirectToolExecutor: vi.fn(() => vi.fn()),
13
+ getMissingConfiguredDirectToolServers: vi.fn(() => []),
14
+ resolveDirectTools: vi.fn(() => []),
15
+ showStatus: vi.fn(),
16
+ showTools: vi.fn(),
17
+ reconnectServers: vi.fn(),
18
+ authenticateServer: vi.fn(),
19
+ logoutServer: vi.fn(),
20
+ openMcpAuthPanel: vi.fn(),
21
+ openMcpPanel: vi.fn(),
22
+ openMcpSetup: vi.fn(),
23
+ executeAuthComplete: vi.fn(),
24
+ executeAuthStart: vi.fn(),
25
+ executeCall: vi.fn(),
26
+ executeConnect: vi.fn(),
27
+ executeDescribe: vi.fn(),
28
+ executeList: vi.fn(),
29
+ executeSearch: vi.fn(),
30
+ executeStatus: vi.fn(),
31
+ executeUiMessages: vi.fn(),
32
+ getConfigPathFromArgv: vi.fn(() => undefined),
33
+ truncateAtWord: vi.fn((text: string) => text),
34
+ }));
35
+
36
+ vi.mock("../init.ts", () => ({
37
+ initializeMcp: mocks.initializeMcp,
38
+ updateStatusBar: mocks.updateStatusBar,
39
+ flushMetadataCache: mocks.flushMetadataCache,
40
+ }));
41
+
42
+ vi.mock("../mcp-auth-flow.ts", () => ({
43
+ initializeOAuth: mocks.initializeOAuth,
44
+ shutdownOAuth: mocks.shutdownOAuth,
45
+ }));
46
+
47
+ vi.mock("../config.ts", () => ({
48
+ loadMcpConfig: mocks.loadMcpConfig,
49
+ }));
50
+
51
+ vi.mock("../metadata-cache.ts", () => ({
52
+ loadMetadataCache: mocks.loadMetadataCache,
53
+ }));
54
+
55
+ vi.mock("../direct-tools.ts", () => ({
56
+ buildProxyDescription: mocks.buildProxyDescription,
57
+ createDirectToolExecutor: mocks.createDirectToolExecutor,
58
+ getMissingConfiguredDirectToolServers: mocks.getMissingConfiguredDirectToolServers,
59
+ resolveDirectTools: mocks.resolveDirectTools,
60
+ }));
61
+
62
+ vi.mock("../commands.ts", () => ({
63
+ showStatus: mocks.showStatus,
64
+ showTools: mocks.showTools,
65
+ reconnectServers: mocks.reconnectServers,
66
+ authenticateServer: mocks.authenticateServer,
67
+ logoutServer: mocks.logoutServer,
68
+ openMcpAuthPanel: mocks.openMcpAuthPanel,
69
+ openMcpPanel: mocks.openMcpPanel,
70
+ openMcpSetup: mocks.openMcpSetup,
71
+ }));
72
+
73
+ vi.mock("../proxy-modes.ts", () => ({
74
+ executeAuthComplete: mocks.executeAuthComplete,
75
+ executeAuthStart: mocks.executeAuthStart,
76
+ executeCall: mocks.executeCall,
77
+ executeConnect: mocks.executeConnect,
78
+ executeDescribe: mocks.executeDescribe,
79
+ executeList: mocks.executeList,
80
+ executeSearch: mocks.executeSearch,
81
+ executeStatus: mocks.executeStatus,
82
+ executeUiMessages: mocks.executeUiMessages,
83
+ }));
84
+
85
+ vi.mock("../utils.ts", () => ({
86
+ getConfigPathFromArgv: mocks.getConfigPathFromArgv,
87
+ truncateAtWord: mocks.truncateAtWord,
88
+ }));
89
+
90
+ function createDeferred<T>() {
91
+ let resolve!: (value: T) => void;
92
+ const promise = new Promise<T>((res) => {
93
+ resolve = res;
94
+ });
95
+ return { promise, resolve };
96
+ }
97
+
98
+ function createState() {
99
+ return {
100
+ manager: { getAllConnections: () => new Map() },
101
+ lifecycle: { gracefulShutdown: vi.fn().mockResolvedValue(undefined) },
102
+ toolMetadata: new Map(),
103
+ config: { mcpServers: {} },
104
+ failureTracker: new Map(),
105
+ uiResourceHandler: {},
106
+ consentManager: {},
107
+ uiServer: null,
108
+ completedUiSessions: [],
109
+ openBrowser: vi.fn(),
110
+ } as any;
111
+ }
112
+
113
+ function createPi() {
114
+ const handlers = new Map<string, (...args: any[]) => unknown>();
115
+ return {
116
+ handlers,
117
+ api: {
118
+ registerTool: vi.fn(),
119
+ registerFlag: vi.fn(),
120
+ registerCommand: vi.fn(),
121
+ on: vi.fn((event: string, handler: (...args: any[]) => unknown) => {
122
+ handlers.set(event, handler);
123
+ }),
124
+ getAllTools: vi.fn(() => []),
125
+ } as any,
126
+ };
127
+ }
128
+
129
+ describe("mcpAdapter session lifecycle", () => {
130
+ const originalDirectTools = process.env.MCP_DIRECT_TOOLS;
131
+
132
+ beforeEach(() => {
133
+ delete process.env.MCP_DIRECT_TOOLS;
134
+ vi.resetModules();
135
+ for (const value of Object.values(mocks)) {
136
+ if (typeof value === "function" && "mockReset" in value) {
137
+ value.mockReset();
138
+ }
139
+ }
140
+
141
+ mocks.initializeOAuth.mockResolvedValue(undefined);
142
+ mocks.shutdownOAuth.mockResolvedValue(undefined);
143
+ mocks.loadMcpConfig.mockReturnValue({ mcpServers: {} });
144
+ mocks.loadMetadataCache.mockReturnValue(null);
145
+ mocks.buildProxyDescription.mockReturnValue("MCP gateway");
146
+ mocks.createDirectToolExecutor.mockReturnValue(vi.fn());
147
+ mocks.getMissingConfiguredDirectToolServers.mockReturnValue([]);
148
+ mocks.resolveDirectTools.mockReturnValue([]);
149
+ mocks.getConfigPathFromArgv.mockReturnValue(undefined);
150
+ mocks.truncateAtWord.mockImplementation((text: string) => text);
151
+ });
152
+
153
+ afterEach(() => {
154
+ if (originalDirectTools === undefined) {
155
+ delete process.env.MCP_DIRECT_TOOLS;
156
+ } else {
157
+ process.env.MCP_DIRECT_TOOLS = originalDirectTools;
158
+ }
159
+ });
160
+
161
+ it("keeps the proxy tool when direct tools are still missing from cache", async () => {
162
+ mocks.loadMcpConfig.mockReturnValue({
163
+ mcpServers: {
164
+ demo: { command: "npx", args: ["-y", "demo-server"], directTools: true },
165
+ },
166
+ settings: { disableProxyTool: true },
167
+ });
168
+ mocks.resolveDirectTools.mockReturnValue([
169
+ {
170
+ serverName: "demo",
171
+ originalName: "search",
172
+ prefixedName: "demo_search",
173
+ description: "Search demo",
174
+ },
175
+ ]);
176
+ mocks.getMissingConfiguredDirectToolServers.mockReturnValue(["demo"]);
177
+
178
+ const { default: mcpAdapter } = await import("../index.ts");
179
+ const { api } = createPi();
180
+ mcpAdapter(api);
181
+
182
+ expect(api.registerTool).toHaveBeenCalledWith(expect.objectContaining({
183
+ name: "demo_search",
184
+ renderResult: expect.any(Function),
185
+ }));
186
+ expect(api.registerTool).toHaveBeenCalledWith(expect.objectContaining({
187
+ name: "mcp",
188
+ renderResult: expect.any(Function),
189
+ }));
190
+ });
191
+
192
+ it("skips the proxy tool once direct tools are fully available", async () => {
193
+ mocks.loadMcpConfig.mockReturnValue({
194
+ mcpServers: {
195
+ demo: { command: "npx", args: ["-y", "demo-server"], directTools: true },
196
+ },
197
+ settings: { disableProxyTool: true },
198
+ });
199
+ mocks.resolveDirectTools.mockReturnValue([
200
+ {
201
+ serverName: "demo",
202
+ originalName: "search",
203
+ prefixedName: "demo_search",
204
+ description: "Search demo",
205
+ },
206
+ ]);
207
+
208
+ const { default: mcpAdapter } = await import("../index.ts");
209
+ const { api } = createPi();
210
+ mcpAdapter(api);
211
+
212
+ expect(api.registerTool).toHaveBeenCalledWith(expect.objectContaining({
213
+ name: "demo_search",
214
+ renderResult: expect.any(Function),
215
+ }));
216
+ expect(api.registerTool).not.toHaveBeenCalledWith(expect.objectContaining({ name: "mcp" }));
217
+ });
218
+
219
+ it("routes manual auth actions through the proxy tool", async () => {
220
+ const state = createState();
221
+ mocks.initializeMcp.mockResolvedValue(state);
222
+ mocks.executeAuthStart.mockResolvedValue({ content: [{ type: "text", text: "auth url" }] });
223
+ mocks.executeAuthComplete.mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
224
+
225
+ const { default: mcpAdapter } = await import("../index.ts");
226
+ const { api, handlers } = createPi();
227
+ mcpAdapter(api);
228
+
229
+ const sessionStart = handlers.get("session_start");
230
+ await sessionStart?.({}, {});
231
+ await Promise.resolve();
232
+ await Promise.resolve();
233
+
234
+ const proxyTool = api.registerTool.mock.calls.find((call: any[]) => call[0].name === "mcp")?.[0];
235
+ expect(proxyTool).toBeDefined();
236
+
237
+ await proxyTool.execute("call-1", { action: "auth-start", server: "demo" });
238
+ await proxyTool.execute("call-2", {
239
+ action: "auth-complete",
240
+ server: "demo",
241
+ args: '{"redirectUrl":"http://localhost:19876/callback?code=abc&state=state"}',
242
+ });
243
+
244
+ expect(mocks.executeAuthStart).toHaveBeenCalledWith(state, "demo");
245
+ expect(mocks.executeAuthComplete).toHaveBeenCalledWith(
246
+ state,
247
+ "demo",
248
+ "http://localhost:19876/callback?code=abc&state=state",
249
+ );
250
+ });
251
+
252
+ it("starts a replacement init immediately and shuts down stale init results", async () => {
253
+ const first = createDeferred<any>();
254
+ const second = createDeferred<any>();
255
+ mocks.initializeMcp
256
+ .mockReturnValueOnce(first.promise)
257
+ .mockReturnValueOnce(second.promise);
258
+
259
+ const { default: mcpAdapter } = await import("../index.ts");
260
+ const { api, handlers } = createPi();
261
+ mcpAdapter(api);
262
+
263
+ const sessionStart = handlers.get("session_start");
264
+ expect(sessionStart).toBeTypeOf("function");
265
+
266
+ await sessionStart?.({}, {});
267
+ expect(mocks.initializeMcp).toHaveBeenCalledTimes(1);
268
+ expect(mocks.shutdownOAuth).toHaveBeenCalledTimes(1);
269
+
270
+ await sessionStart?.({}, {});
271
+ expect(mocks.initializeMcp).toHaveBeenCalledTimes(2);
272
+ expect(mocks.shutdownOAuth).toHaveBeenCalledTimes(2);
273
+
274
+ const activeState = createState();
275
+ second.resolve(activeState);
276
+ await Promise.resolve();
277
+ await Promise.resolve();
278
+
279
+ expect(mocks.updateStatusBar).toHaveBeenCalledWith(activeState);
280
+ expect(activeState.lifecycle.gracefulShutdown).not.toHaveBeenCalled();
281
+
282
+ const staleState = createState();
283
+ first.resolve(staleState);
284
+ await Promise.resolve();
285
+ await Promise.resolve();
286
+
287
+ expect(mocks.updateStatusBar).not.toHaveBeenCalledWith(staleState);
288
+ expect(mocks.flushMetadataCache).toHaveBeenCalledWith(staleState);
289
+ expect(staleState.lifecycle.gracefulShutdown).toHaveBeenCalledTimes(1);
290
+ });
291
+
292
+ it("shuts down OAuth on session_shutdown", async () => {
293
+ const state = createState();
294
+ mocks.initializeMcp.mockResolvedValue(state);
295
+
296
+ const { default: mcpAdapter } = await import("../index.ts");
297
+ const { api, handlers } = createPi();
298
+ mcpAdapter(api);
299
+
300
+ const sessionStart = handlers.get("session_start");
301
+ const sessionShutdown = handlers.get("session_shutdown");
302
+
303
+ await sessionStart?.({}, {});
304
+ await Promise.resolve();
305
+ await Promise.resolve();
306
+
307
+ mocks.shutdownOAuth.mockClear();
308
+
309
+ await sessionShutdown?.();
310
+
311
+ expect(mocks.shutdownOAuth).toHaveBeenCalledTimes(1);
312
+ });
313
+
314
+ it("routes `/mcp setup` to the onboarding flow", async () => {
315
+ const state = createState();
316
+ mocks.initializeMcp.mockResolvedValue(state);
317
+
318
+ const { default: mcpAdapter } = await import("../index.ts");
319
+ const { api, handlers } = createPi();
320
+ mcpAdapter(api);
321
+
322
+ const sessionStart = handlers.get("session_start");
323
+ await sessionStart?.({}, { hasUI: true, ui: { notify: vi.fn() } });
324
+ await Promise.resolve();
325
+ await Promise.resolve();
326
+
327
+ const commandDef = api.registerCommand.mock.calls.find((call: any[]) => call[0] === "mcp")?.[1];
328
+ expect(commandDef).toBeDefined();
329
+
330
+ await commandDef.handler("setup", { hasUI: true, ui: { notify: vi.fn() } });
331
+
332
+ expect(mocks.openMcpSetup).toHaveBeenCalledWith(state, api, expect.any(Object), undefined, "setup");
333
+ });
334
+
335
+ it("routes `/mcp logout <server>` to credential logout", async () => {
336
+ const state = createState();
337
+ mocks.initializeMcp.mockResolvedValue(state);
338
+
339
+ const { default: mcpAdapter } = await import("../index.ts");
340
+ const { api, handlers } = createPi();
341
+ mcpAdapter(api);
342
+
343
+ const ui = { notify: vi.fn() };
344
+ const sessionStart = handlers.get("session_start");
345
+ await sessionStart?.({}, { hasUI: true, ui });
346
+ await Promise.resolve();
347
+ await Promise.resolve();
348
+
349
+ const commandDef = api.registerCommand.mock.calls.find((call: any[]) => call[0] === "mcp")?.[1];
350
+ await commandDef.handler("logout oauth-server", { hasUI: true, ui });
351
+
352
+ expect(mocks.logoutServer).toHaveBeenCalledWith("oauth-server", state, expect.any(Object));
353
+ });
354
+
355
+ it("shows usage for `/mcp logout` without a server", async () => {
356
+ const state = createState();
357
+ mocks.initializeMcp.mockResolvedValue(state);
358
+
359
+ const { default: mcpAdapter } = await import("../index.ts");
360
+ const { api, handlers } = createPi();
361
+ mcpAdapter(api);
362
+
363
+ const ui = { notify: vi.fn() };
364
+ const sessionStart = handlers.get("session_start");
365
+ await sessionStart?.({}, { hasUI: true, ui });
366
+ await Promise.resolve();
367
+ await Promise.resolve();
368
+
369
+ const commandDef = api.registerCommand.mock.calls.find((call: any[]) => call[0] === "mcp")?.[1];
370
+ await commandDef.handler("logout", { hasUI: true, ui });
371
+
372
+ expect(mocks.logoutServer).not.toHaveBeenCalled();
373
+ expect(ui.notify).toHaveBeenCalledWith("Usage: /mcp logout <server>", "error");
374
+ });
375
+
376
+ it("triggers core reload after setup changes config", async () => {
377
+ const initialState = createState();
378
+ mocks.initializeMcp.mockResolvedValue(initialState);
379
+ mocks.openMcpSetup.mockResolvedValue({ configChanged: true });
380
+
381
+ const { default: mcpAdapter } = await import("../index.ts");
382
+ const { api, handlers } = createPi();
383
+ mcpAdapter(api);
384
+
385
+ const ui = { notify: vi.fn() };
386
+ const reload = vi.fn().mockResolvedValue(undefined);
387
+ const sessionStart = handlers.get("session_start");
388
+ await sessionStart?.({}, { hasUI: true, ui });
389
+ await Promise.resolve();
390
+ await Promise.resolve();
391
+
392
+ const commandDef = api.registerCommand.mock.calls.find((call: any[]) => call[0] === "mcp")?.[1];
393
+ await commandDef.handler("setup", { hasUI: true, ui, reload });
394
+
395
+ expect(reload).toHaveBeenCalledTimes(1);
396
+ expect(mocks.initializeMcp).toHaveBeenCalledTimes(1);
397
+ expect(mocks.flushMetadataCache).not.toHaveBeenCalledWith(initialState);
398
+ });
399
+
400
+ it("opens the auth picker for `/mcp-auth` without args in UI sessions", async () => {
401
+ const state = createState();
402
+ mocks.initializeMcp.mockResolvedValue(state);
403
+
404
+ const { default: mcpAdapter } = await import("../index.ts");
405
+ const { api, handlers } = createPi();
406
+ mcpAdapter(api);
407
+
408
+ const ui = { notify: vi.fn() };
409
+ const sessionStart = handlers.get("session_start");
410
+ await sessionStart?.({}, { hasUI: true, ui });
411
+ await Promise.resolve();
412
+ await Promise.resolve();
413
+
414
+ const commandDef = api.registerCommand.mock.calls.find((call: any[]) => call[0] === "mcp-auth")?.[1];
415
+ await commandDef.handler("", { hasUI: true, ui });
416
+
417
+ expect(mocks.openMcpAuthPanel).toHaveBeenCalledWith(state, api, expect.any(Object), undefined);
418
+ expect(mocks.authenticateServer).not.toHaveBeenCalled();
419
+ });
420
+
421
+ it("keeps explicit `/mcp-auth <server>` routed to direct authentication", async () => {
422
+ const state = createState();
423
+ mocks.initializeMcp.mockResolvedValue(state);
424
+
425
+ const { default: mcpAdapter } = await import("../index.ts");
426
+ const { api, handlers } = createPi();
427
+ mcpAdapter(api);
428
+
429
+ const ui = { notify: vi.fn() };
430
+ const sessionStart = handlers.get("session_start");
431
+ await sessionStart?.({}, { hasUI: true, ui });
432
+ await Promise.resolve();
433
+ await Promise.resolve();
434
+
435
+ const commandDef = api.registerCommand.mock.calls.find((call: any[]) => call[0] === "mcp-auth")?.[1];
436
+ await commandDef.handler("github", { hasUI: true, ui });
437
+
438
+ expect(mocks.authenticateServer).toHaveBeenCalledWith("github", state.config, expect.any(Object));
439
+ expect(mocks.openMcpAuthPanel).not.toHaveBeenCalled();
440
+ });
441
+
442
+ it("documents that no-arg `/mcp-auth` has no non-UI picker or command feedback path", async () => {
443
+ const state = createState();
444
+ mocks.initializeMcp.mockResolvedValue(state);
445
+
446
+ const { default: mcpAdapter } = await import("../index.ts");
447
+ const { api } = createPi();
448
+ mcpAdapter(api);
449
+
450
+ const commandDef = api.registerCommand.mock.calls.find((call: any[]) => call[0] === "mcp-auth")?.[1];
451
+ await commandDef.handler("", { hasUI: false });
452
+
453
+ expect(mocks.openMcpAuthPanel).not.toHaveBeenCalled();
454
+ expect(mocks.authenticateServer).not.toHaveBeenCalled();
455
+ });
456
+
457
+ it("logs initialization errors when updateStatusBar throws", async () => {
458
+ const state = createState();
459
+ mocks.initializeMcp.mockResolvedValue(state);
460
+ mocks.updateStatusBar.mockImplementation(() => {
461
+ throw new Error("status boom");
462
+ });
463
+
464
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
465
+
466
+ try {
467
+ const { default: mcpAdapter } = await import("../index.ts");
468
+ const { api, handlers } = createPi();
469
+ mcpAdapter(api);
470
+
471
+ const sessionStart = handlers.get("session_start");
472
+ expect(sessionStart).toBeTypeOf("function");
473
+
474
+ await sessionStart?.({}, {});
475
+ await Promise.resolve();
476
+ await Promise.resolve();
477
+ await new Promise((resolve) => setImmediate(resolve));
478
+
479
+ expect(consoleError).toHaveBeenCalledWith("MCP initialization failed:", expect.any(Error));
480
+ } finally {
481
+ consoleError.mockRestore();
482
+ }
483
+ });
484
+ });
@@ -0,0 +1,86 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ ExtensionMode,
6
+ ExtensionUIContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+
9
+ const mocks = vi.hoisted(() => ({
10
+ loadMcpConfig: vi.fn(),
11
+ managers: [] as any[],
12
+ }));
13
+
14
+ vi.mock("../config.ts", async importOriginal => ({
15
+ ...(await importOriginal<typeof import("../config.ts")>()),
16
+ loadMcpConfig: mocks.loadMcpConfig,
17
+ }));
18
+
19
+ vi.mock("../server-manager.ts", () => ({
20
+ McpServerManager: vi.fn().mockImplementation(function (this: any) {
21
+ this.setSamplingConfig = vi.fn();
22
+ this.setElicitationConfig = vi.fn();
23
+ this.getConnection = vi.fn();
24
+ this.connect = vi.fn();
25
+ mocks.managers.push(this);
26
+ }),
27
+ }));
28
+
29
+ function context(overrides: { hasUI?: boolean; mode?: ExtensionMode } = {}): ExtensionContext {
30
+ return {
31
+ cwd: "/tmp/project",
32
+ hasUI: true,
33
+ mode: "tui",
34
+ ui: { select: vi.fn(), input: vi.fn(), notify: vi.fn() } as unknown as ExtensionUIContext,
35
+ modelRegistry: {},
36
+ model: undefined,
37
+ signal: undefined,
38
+ ...overrides,
39
+ } as unknown as ExtensionContext;
40
+ }
41
+
42
+ function extensionApi(): ExtensionAPI {
43
+ return { getFlag: vi.fn() } as unknown as ExtensionAPI;
44
+ }
45
+
46
+ describe("initializeMcp elicitation config", () => {
47
+ beforeEach(() => {
48
+ mocks.managers.length = 0;
49
+ mocks.loadMcpConfig.mockReturnValue({ mcpServers: {}, settings: {} });
50
+ });
51
+
52
+ it("enables form and URL elicitation in TUI mode", async () => {
53
+ const { initializeMcp } = await import("../init.ts");
54
+ const ctx = context();
55
+
56
+ await initializeMcp(extensionApi(), ctx);
57
+
58
+ expect(mocks.managers[0].setElicitationConfig).toHaveBeenCalledWith({
59
+ ui: ctx.ui,
60
+ allowUrl: true,
61
+ });
62
+ });
63
+
64
+ it("keeps RPC elicitation form-only so the backend never opens a browser", async () => {
65
+ const { initializeMcp } = await import("../init.ts");
66
+ const ctx = context({ mode: "rpc" });
67
+
68
+ await initializeMcp(extensionApi(), ctx);
69
+
70
+ expect(mocks.managers[0].setElicitationConfig).toHaveBeenCalledWith({
71
+ ui: ctx.ui,
72
+ allowUrl: false,
73
+ });
74
+ });
75
+
76
+ it("does not enable elicitation without UI or when disabled", async () => {
77
+ const { initializeMcp } = await import("../init.ts");
78
+
79
+ await initializeMcp(extensionApi(), context({ hasUI: false }));
80
+ expect(mocks.managers[0].setElicitationConfig).not.toHaveBeenCalled();
81
+
82
+ mocks.loadMcpConfig.mockReturnValue({ mcpServers: {}, settings: { elicitation: false } });
83
+ await initializeMcp(extensionApi(), context());
84
+ expect(mocks.managers[1].setElicitationConfig).not.toHaveBeenCalled();
85
+ });
86
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ describe("interactive visualizer", () => {
9
+ it("dist/app.html exists and contains chart.js", () => {
10
+ const html = readFileSync(
11
+ join(__dirname, "..", "examples", "interactive-visualizer", "dist", "app.html"),
12
+ "utf-8",
13
+ );
14
+ expect(html).toContain("<!doctype html>");
15
+ expect(html).toContain("chart.js");
16
+ expect(html).toContain('<div id="app">');
17
+ });
18
+
19
+ it("dist/server.js exists and is executable", () => {
20
+ const server = readFileSync(
21
+ join(__dirname, "..", "examples", "interactive-visualizer", "dist", "server.js"),
22
+ "utf-8",
23
+ );
24
+ expect(server).toContain("#!/usr/bin/env node");
25
+ expect(server).toContain("show_chart");
26
+ expect(server).toContain("interactive-visualizer");
27
+ });
28
+ });