@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,473 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ clearRegisteredForTest,
5
+ filterDirectTools,
6
+ getRegisteredNamesForTest,
7
+ pruneRegisteredNames,
8
+ registerDirectTools,
9
+ } from "./direct-tools.js";
10
+ import type { ServerClient } from "./server-client.js";
11
+ import type { CachedTool } from "./types.js";
12
+
13
+ // ── fakes ────────────────────────────────────────────────────────────────────
14
+
15
+ /** Minimal tool definition surface captured from pi.registerTool */
16
+ interface CapturedTool {
17
+ name: string;
18
+ label: string;
19
+ description: string;
20
+ execute?: (
21
+ toolCallId: string,
22
+ params: unknown,
23
+ signal?: AbortSignal,
24
+ ) => Promise<unknown>;
25
+ }
26
+
27
+ function makeFakePi(): { pi: ExtensionAPI; defs: CapturedTool[] } {
28
+ const defs: CapturedTool[] = [];
29
+ const pi = {
30
+ on: () => {},
31
+ registerTool: (def: CapturedTool) => {
32
+ defs.push(def);
33
+ },
34
+ } as unknown as ExtensionAPI;
35
+ return { pi, defs };
36
+ }
37
+
38
+ /** Minimal ServerClient fake — only the surface the executor touches */
39
+ function makeFakeClient(
40
+ name: string,
41
+ calls: Array<{ tool: string; args: Record<string, unknown> }>,
42
+ ): ServerClient {
43
+ const fake = {
44
+ name,
45
+ tools: [] as Array<{ name: string }>,
46
+ async connect(): Promise<void> {},
47
+ async callTool(
48
+ toolName: string,
49
+ args: Record<string, unknown>,
50
+ ): Promise<{ content: Array<{ type: string; text: string }>; isError: boolean }> {
51
+ calls.push({ tool: toolName, args });
52
+ return { content: [{ type: "text", text: `result:${toolName}` }], isError: false };
53
+ },
54
+ };
55
+ return fake as unknown as ServerClient;
56
+ }
57
+
58
+ const TOOLS: CachedTool[] = [
59
+ { name: "alpha", description: "Alpha tool", inputSchema: {} },
60
+ { name: "beta", inputSchema: {} },
61
+ { name: "gamma", description: "Gamma tool", inputSchema: {} },
62
+ ];
63
+
64
+ const resolveClient = (client: ServerClient) => async (name: string): Promise<ServerClient> => {
65
+ if (name !== client.name) throw new Error(`Unexpected server: ${name}`);
66
+ return client;
67
+ };
68
+
69
+ // ── filterDirectTools ────────────────────────────────────────────────────────
70
+
71
+ describe("filterDirectTools", () => {
72
+ it("exposes all tools when directTools is true with no include/exclude", () => {
73
+ const out = filterDirectTools(TOOLS, { directTools: true });
74
+ expect(out.map((t) => t.name)).toEqual(["alpha", "beta", "gamma"]);
75
+ });
76
+
77
+ it("exposes nothing when directTools is false", () => {
78
+ const out = filterDirectTools(TOOLS, { directTools: false });
79
+ expect(out).toEqual([]);
80
+ });
81
+
82
+ it("restricts to a subset when directTools is a string[]", () => {
83
+ const out = filterDirectTools(TOOLS, { directTools: ["beta", "nope"] });
84
+ expect(out.map((t) => t.name)).toEqual(["beta"]);
85
+ });
86
+
87
+ it("intersects with includeTools", () => {
88
+ const out = filterDirectTools(TOOLS, {
89
+ directTools: true,
90
+ includeTools: ["alpha", "gamma", "nope"],
91
+ });
92
+ expect(out.map((t) => t.name)).toEqual(["alpha", "gamma"]);
93
+ });
94
+
95
+ it("subtracts excludeTools", () => {
96
+ const out = filterDirectTools(TOOLS, {
97
+ directTools: true,
98
+ excludeTools: ["beta", "nope"],
99
+ });
100
+ expect(out.map((t) => t.name)).toEqual(["alpha", "gamma"]);
101
+ });
102
+
103
+ it("applies directTools subset, then includeTools, then excludeTools", () => {
104
+ const out = filterDirectTools(TOOLS, {
105
+ directTools: ["alpha", "beta", "gamma"],
106
+ includeTools: ["alpha", "beta"],
107
+ excludeTools: ["beta"],
108
+ });
109
+ expect(out.map((t) => t.name)).toEqual(["alpha"]);
110
+ });
111
+ });
112
+
113
+ // ── registerDirectTools ──────────────────────────────────────────────────────
114
+
115
+ describe("registerDirectTools", () => {
116
+ beforeEach(() => {
117
+ clearRegisteredForTest();
118
+ });
119
+
120
+ it("registers each tool under its prefixed name with server-scoped description", () => {
121
+ const { pi, defs } = makeFakePi();
122
+ const calls: Array<{ tool: string; args: Record<string, unknown> }> = [];
123
+ const registered = registerDirectTools(pi, {
124
+ serverName: "myserver",
125
+ prefix: "server",
126
+ tools: TOOLS,
127
+ resolveClient: resolveClient(makeFakeClient("myserver", calls)),
128
+ });
129
+
130
+ expect(registered).toEqual(["myserver_alpha", "myserver_beta", "myserver_gamma"]);
131
+ expect(defs.map((d) => d.name)).toEqual(registered);
132
+ const alpha = defs.find((d) => d.name === "myserver_alpha");
133
+ expect(alpha?.label).toBe("MCP: alpha");
134
+ expect(alpha?.description).toContain("[myserver]");
135
+ expect(alpha?.description).toContain("Alpha tool");
136
+ });
137
+
138
+ it("skips tools whose prefixed name collides with a pi builtin and warns", () => {
139
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
140
+ const { pi, defs } = makeFakePi();
141
+ const calls: Array<{ tool: string; args: Record<string, unknown> }> = [];
142
+ const registered = registerDirectTools(pi, {
143
+ serverName: "srv",
144
+ prefix: "none",
145
+ tools: [
146
+ { name: "read", description: "shadows builtin", inputSchema: {} },
147
+ { name: "search", description: "fine", inputSchema: {} },
148
+ ],
149
+ resolveClient: resolveClient(makeFakeClient("srv", calls)),
150
+ });
151
+
152
+ expect(registered).toEqual(["search"]);
153
+ expect(defs.map((d) => d.name)).toEqual(["search"]);
154
+ expect(warn).toHaveBeenCalledWith(
155
+ expect.stringContaining('skipping tool "read"'),
156
+ );
157
+ expect(warn).toHaveBeenCalledWith(
158
+ expect.stringContaining("collides with a built-in tool name"),
159
+ );
160
+ warn.mockRestore();
161
+ });
162
+
163
+ it("does not double-register when the same server is registered twice", () => {
164
+ const { pi, defs } = makeFakePi();
165
+ const calls: Array<{ tool: string; args: Record<string, unknown> }> = [];
166
+ const opts = {
167
+ serverName: "srv",
168
+ prefix: "server" as const,
169
+ tools: TOOLS,
170
+ resolveClient: resolveClient(makeFakeClient("srv", calls)),
171
+ };
172
+ const first = registerDirectTools(pi, opts);
173
+ const second = registerDirectTools(pi, opts);
174
+ expect(second).toEqual(first);
175
+ // No duplicates in the pi registry despite two registration passes
176
+ const names = defs.map((d) => d.name);
177
+ expect(new Set(names).size).toBe(names.length);
178
+ expect(names).toEqual(["srv_alpha", "srv_beta", "srv_gamma"]);
179
+ });
180
+
181
+ it("evicts stale names when a later pass registers a smaller tool list", () => {
182
+ const { pi } = makeFakePi();
183
+ const base = {
184
+ serverName: "srv",
185
+ prefix: "server" as const,
186
+ };
187
+ // Pass 1: full tool list
188
+ registerDirectTools(pi, {
189
+ ...base,
190
+ tools: TOOLS,
191
+ resolveClient: resolveClient(makeFakeClient("srv", [])),
192
+ });
193
+ expect([...getRegisteredNamesForTest().get("srv")!].sort()).toEqual([
194
+ "srv_alpha",
195
+ "srv_beta",
196
+ "srv_gamma",
197
+ ]);
198
+
199
+ // Pass 2: the server's tool list shrank — the tracked set must reflect
200
+ // ONLY this pass's names, so a later session_start can re-register the
201
+ // evicted tools (pi itself cannot unregister them).
202
+ registerDirectTools(pi, {
203
+ ...base,
204
+ tools: [TOOLS[0]!],
205
+ resolveClient: resolveClient(makeFakeClient("srv", [])),
206
+ });
207
+ expect([...getRegisteredNamesForTest().get("srv")!].sort()).toEqual(["srv_alpha"]);
208
+ });
209
+
210
+ it("dedupes across servers: a name claimed by one server blocks another", () => {
211
+ const { pi, defs } = makeFakePi();
212
+ const tool = { name: "x", inputSchema: {} };
213
+ registerDirectTools(pi, {
214
+ serverName: "s1",
215
+ prefix: "none",
216
+ tools: [tool],
217
+ resolveClient: resolveClient(makeFakeClient("s1", [])),
218
+ });
219
+ // Same raw tool name from a different server, prefix "none" → identical
220
+ // final name; the second server must not double-register it (the name is
221
+ // still reported, matching the repeated-pass contract)
222
+ const registered = registerDirectTools(pi, {
223
+ serverName: "s2",
224
+ prefix: "none",
225
+ tools: [tool],
226
+ resolveClient: resolveClient(makeFakeClient("s2", [])),
227
+ });
228
+ expect(registered).toEqual(["x"]);
229
+ expect(defs.filter((d) => d.name === "x")).toHaveLength(1);
230
+ });
231
+
232
+ it("pruneRegisteredNames evicts removed servers so their names are re-claimable", () => {
233
+ const { pi, defs } = makeFakePi();
234
+ const tool = { name: "foo", inputSchema: {} };
235
+ // Server A (prefix "none") claims the bare final name "foo"
236
+ registerDirectTools(pi, {
237
+ serverName: "A",
238
+ prefix: "none",
239
+ tools: [tool],
240
+ resolveClient: resolveClient(makeFakeClient("A", [])),
241
+ });
242
+ expect(defs.map((d) => d.name)).toEqual(["foo"]);
243
+ expect(getRegisteredNamesForTest().has("A")).toBe(true);
244
+
245
+ // A is removed; B is active. Without pruning, B's identical final name
246
+ // would be silently dropped by isClaimed("foo").
247
+ pruneRegisteredNames(new Set(["B"]));
248
+ expect(getRegisteredNamesForTest().has("A")).toBe(false);
249
+
250
+ const second = registerDirectTools(pi, {
251
+ serverName: "B",
252
+ prefix: "none",
253
+ tools: [tool],
254
+ resolveClient: resolveClient(makeFakeClient("B", [])),
255
+ });
256
+ expect(second).toEqual(["foo"]);
257
+ expect(defs.filter((d) => d.name === "foo")).toHaveLength(2);
258
+ });
259
+
260
+ it("pruneRegisteredNames keeps active servers' entries", () => {
261
+ registerDirectTools({ on: () => {}, registerTool: () => {} } as unknown as ExtensionAPI, {
262
+ serverName: "A",
263
+ prefix: "server",
264
+ tools: [TOOLS[0]!],
265
+ resolveClient: async () => makeFakeClient("A", []),
266
+ });
267
+ pruneRegisteredNames(new Set(["A", "B"]));
268
+ expect([...getRegisteredNamesForTest().get("A")!]).toEqual(["A_alpha"]);
269
+ });
270
+
271
+ it("warns once per server when two raw tools format to the same final name", () => {
272
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
273
+ const { pi } = makeFakePi();
274
+ const tools = [
275
+ { name: "a.b", inputSchema: {} },
276
+ { name: "a_b", inputSchema: {} },
277
+ { name: "plain", inputSchema: {} },
278
+ ];
279
+ const opts = {
280
+ serverName: "srv",
281
+ prefix: "server" as const,
282
+ tools,
283
+ resolveClient: resolveClient(makeFakeClient("srv", [])),
284
+ };
285
+ const collisionWarns = () =>
286
+ warn.mock.calls
287
+ .map((c) => String(c[0]))
288
+ .filter((s) => s.includes("both format to"));
289
+
290
+ registerDirectTools(pi, opts);
291
+ expect(collisionWarns()).toHaveLength(1);
292
+ expect(collisionWarns()[0]).toContain("a.b");
293
+ expect(collisionWarns()[0]).toContain("a_b");
294
+ expect(collisionWarns()[0]).toContain("srv_a_b");
295
+
296
+ // A repeated registration pass must not re-warn for the same collision
297
+ registerDirectTools(pi, opts);
298
+ expect(collisionWarns()).toHaveLength(1);
299
+ warn.mockRestore();
300
+ });
301
+
302
+ it("resolves the client lazily at call time, not at registration", async () => {
303
+ const { pi, defs } = makeFakePi();
304
+ const calls: Array<{ tool: string; args: Record<string, unknown> }> = [];
305
+ const resolveSpy = vi.fn(resolveClient(makeFakeClient("srv", calls)));
306
+ registerDirectTools(pi, {
307
+ serverName: "srv",
308
+ prefix: "server",
309
+ tools: [TOOLS[0]!],
310
+ resolveClient: resolveSpy,
311
+ });
312
+
313
+ // No connection attempt during registration
314
+ expect(resolveSpy).not.toHaveBeenCalled();
315
+
316
+ const result = (await defs[0]!.execute!("call-1", { q: 42 })) as {
317
+ content: Array<{ type: string; text: string }>;
318
+ details: { server: string; tool: string };
319
+ isError: boolean;
320
+ };
321
+
322
+ // Exactly one lazy resolution, with the owning server name
323
+ expect(resolveSpy).toHaveBeenCalledTimes(1);
324
+ expect(resolveSpy).toHaveBeenCalledWith("srv");
325
+ // The RAW tool name is forwarded to the server, args passed through
326
+ expect(calls).toEqual([{ tool: "alpha", args: { q: 42 } }]);
327
+ expect(result.content).toEqual([{ type: "text", text: "result:alpha" }]);
328
+ expect(result.details).toEqual({ server: "srv", tool: "alpha" });
329
+ expect(result.isError).toBe(false);
330
+ });
331
+
332
+ it("surfaces resolveClient failures as tool errors", async () => {
333
+ const { pi, defs } = makeFakePi();
334
+ registerDirectTools(pi, {
335
+ serverName: "gone",
336
+ prefix: "server",
337
+ tools: [TOOLS[0]!],
338
+ resolveClient: async () => {
339
+ throw new Error('Server "gone" is no longer configured');
340
+ },
341
+ });
342
+ await expect(defs[0]!.execute!("call-1", {})).rejects.toThrow(
343
+ /no longer configured/,
344
+ );
345
+ });
346
+ });
347
+
348
+ // ── needs-auth at call time (autoAuth) ──────────────────────────────────────
349
+
350
+ interface NeedsAuthClientFake {
351
+ name: string;
352
+ status: string;
353
+ error: string | null;
354
+ tools: never[];
355
+ close: ReturnType<typeof vi.fn>;
356
+ connect: ReturnType<typeof vi.fn>;
357
+ authenticate: ReturnType<typeof vi.fn>;
358
+ callTool: (
359
+ toolName: string,
360
+ args: Record<string, unknown>,
361
+ ) => Promise<{ content: Array<{ type: string; text: string }>; isError: boolean }>;
362
+ }
363
+
364
+ /** Minimal needs-auth ServerClient fake — authenticate/close/connect are scripted. */
365
+ function makeNeedsAuthClient(
366
+ calls: Array<{ tool: string; args: Record<string, unknown> }>,
367
+ opts: { outcome?: "success" | "throw"; error?: string } = {},
368
+ ): NeedsAuthClientFake {
369
+ const fake = {
370
+ name: "srv",
371
+ status: "needs-auth",
372
+ error: "authentication required or token rejected",
373
+ tools: [] as never[],
374
+ close: vi.fn(async () => {}),
375
+ connect: vi.fn(async function (this: { status: string }) {
376
+ this.status = "connected";
377
+ }),
378
+ authenticate: vi.fn(
379
+ opts.outcome === "throw"
380
+ ? async () => {
381
+ throw new Error(opts.error ?? "boom");
382
+ }
383
+ : async () => {},
384
+ ),
385
+ callTool: (async (toolName: string, args: Record<string, unknown>) => {
386
+ calls.push({ tool: toolName, args });
387
+ return { content: [{ type: "text", text: `result:${toolName}` }], isError: false };
388
+ }) as NeedsAuthClientFake["callTool"],
389
+ };
390
+ return fake as unknown as NeedsAuthClientFake;
391
+ }
392
+
393
+ describe("registerDirectTools — needs-auth at call time", () => {
394
+ beforeEach(() => {
395
+ // The module-level claim map persists across tests; clear it so these
396
+ // tests register fresh (same pattern as the registration describe).
397
+ clearRegisteredForTest();
398
+ });
399
+
400
+ it("returns /mcp auth guidance (isError false) and never authenticates when autoAuth is off", async () => {
401
+ const { pi, defs } = makeFakePi();
402
+ const calls: Array<{ tool: string; args: Record<string, unknown> }> = [];
403
+ const client = makeNeedsAuthClient(calls);
404
+ registerDirectTools(pi, {
405
+ serverName: "srv",
406
+ prefix: "server",
407
+ tools: [TOOLS[0]!],
408
+ autoAuth: () => false,
409
+ resolveClient: resolveClient(client as unknown as ServerClient),
410
+ });
411
+ const result = (await defs[0]!.execute!("call-1", { q: 1 })) as {
412
+ content: Array<{ type: string; text: string }>;
413
+ isError?: boolean;
414
+ };
415
+ const text = result.content[0]?.text ?? "";
416
+ expect(text).toContain("requires authentication");
417
+ expect(text).toContain("/mcp auth srv");
418
+ expect(result.isError).toBeFalsy();
419
+ // The tool was never dispatched to the server; no auth attempted
420
+ expect(calls).toEqual([]);
421
+ expect(client.authenticate).not.toHaveBeenCalled();
422
+ });
423
+
424
+ it("auto-authenticates, reconnects, and retries the call once when autoAuth is on", async () => {
425
+ const { pi, defs } = makeFakePi();
426
+ const calls: Array<{ tool: string; args: Record<string, unknown> }> = [];
427
+ const client = makeNeedsAuthClient(calls);
428
+ registerDirectTools(pi, {
429
+ serverName: "srv",
430
+ prefix: "server",
431
+ tools: [TOOLS[0]!],
432
+ autoAuth: () => true,
433
+ resolveClient: resolveClient(client as unknown as ServerClient),
434
+ });
435
+ const result = (await defs[0]!.execute!("call-1", { q: 1 })) as {
436
+ content: Array<{ type: string; text: string }>;
437
+ details?: { server: string; tool: string };
438
+ isError?: boolean;
439
+ };
440
+ expect(client.authenticate).toHaveBeenCalledTimes(1);
441
+ // Reconnect to pick up the freshly stored token (mirrors /mcp auth)
442
+ expect(client.close).toHaveBeenCalledTimes(1);
443
+ expect(client.connect).toHaveBeenCalledTimes(1);
444
+ // The single retry reached the server with the raw tool name
445
+ expect(calls).toEqual([{ tool: "alpha", args: { q: 1 } }]);
446
+ expect(result.content).toEqual([{ type: "text", text: "result:alpha" }]);
447
+ expect(result.details).toEqual({ server: "srv", tool: "alpha" });
448
+ expect(result.isError).toBe(false);
449
+ });
450
+
451
+ it("returns guidance with the error when the auto-auth flow fails (no throw leaks)", async () => {
452
+ const { pi, defs } = makeFakePi();
453
+ const calls: Array<{ tool: string; args: Record<string, unknown> }> = [];
454
+ const client = makeNeedsAuthClient(calls, { outcome: "throw", error: "OAuth cancelled" });
455
+ registerDirectTools(pi, {
456
+ serverName: "srv",
457
+ prefix: "server",
458
+ tools: [TOOLS[0]!],
459
+ autoAuth: () => true,
460
+ resolveClient: resolveClient(client as unknown as ServerClient),
461
+ });
462
+ const result = (await defs[0]!.execute!("call-1", {})) as {
463
+ content: Array<{ type: string; text: string }>;
464
+ isError?: boolean;
465
+ };
466
+ const text = result.content[0]?.text ?? "";
467
+ expect(text).toContain("OAuth cancelled");
468
+ expect(text).toContain("/mcp auth srv");
469
+ expect(result.isError).toBeFalsy();
470
+ expect(calls).toEqual([]);
471
+ expect(client.close).not.toHaveBeenCalled();
472
+ });
473
+ });