@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,298 @@
1
+ /**
2
+ * Tests for the management panel's pure helpers (plan-027, Task 3).
3
+ *
4
+ * Only the pure, unrendered logic is unit-tested here — the overlay
5
+ * component itself is exercised by the deferred live-TUI manual test.
6
+ *
7
+ * - buildVisibleRows: flat list of visible rows (collapsed → servers only;
8
+ * expanded → server rows interleaved with their tool rows, in order)
9
+ * - filterRows: substring narrowing over name + tool name/description
10
+ * (case-insensitive; empty query passes the array through unchanged)
11
+ * - toggleTool: flips isDirect without touching wasDirect
12
+ * - computeSelection: the per-server save value (true / false / subset)
13
+ * - openMcpPanel (defensive): opens the panel with an UNVALIDATED
14
+ * (JSON-shaped) non-boolean non-array directTools — no throw, boolean
15
+ * row results (the panel is the config's trust boundary)
16
+ */
17
+ import { describe, expect, it } from "vitest";
18
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
19
+ import { openMcpPanel } from "./panel.js";
20
+ import {
21
+ buildVisibleRows,
22
+ computeSelection,
23
+ filterRows,
24
+ toggleTool,
25
+ type ServerRow,
26
+ type ToolRow,
27
+ type VisibleRow,
28
+ } from "./panel-rows.js";
29
+ import type { ServerManager } from "./server-manager.js";
30
+ import type { ServerDef } from "./types.js";
31
+
32
+ // ── fixtures ─────────────────────────────────────────────────────────────────
33
+
34
+ function tool(
35
+ name: string,
36
+ description: string = "",
37
+ isDirect: boolean = true,
38
+ wasDirect: boolean | undefined = undefined,
39
+ ): ToolRow {
40
+ return { name, description, isDirect, wasDirect: wasDirect ?? isDirect };
41
+ }
42
+
43
+ function server(name: string, opts?: { expanded?: boolean; tools?: ToolRow[] }): ServerRow {
44
+ return {
45
+ name,
46
+ expanded: opts?.expanded ?? false,
47
+ status: "cached",
48
+ tools: opts?.tools ?? [],
49
+ hasCachedData: false,
50
+ };
51
+ }
52
+
53
+ function kinds(rows: VisibleRow[]): string[] {
54
+ return rows.map((r) => r.kind);
55
+ }
56
+
57
+ // ── buildVisibleRows ─────────────────────────────────────────────────────────
58
+
59
+ describe("buildVisibleRows", () => {
60
+ it("shows only server rows (in order) when everything is collapsed", () => {
61
+ const rows = buildVisibleRows([
62
+ server("alpha", { tools: [tool("a1"), tool("a2")] }),
63
+ server("beta", { tools: [tool("b1")] }),
64
+ ]);
65
+ expect(rows.map((r) => (r.kind === "server" ? r.server.name : "tool"))).toEqual([
66
+ "alpha",
67
+ "beta",
68
+ ]);
69
+ });
70
+
71
+ it("returns an empty list for no servers", () => {
72
+ expect(buildVisibleRows([])).toEqual([]);
73
+ });
74
+
75
+ it("interleaves tool rows after the expanded server's row, in order", () => {
76
+ const a1 = tool("a1");
77
+ const a2 = tool("a2");
78
+ const rows = buildVisibleRows([
79
+ server("alpha", { expanded: true, tools: [a1, a2] }),
80
+ server("beta"),
81
+ ]);
82
+ expect(kinds(rows)).toEqual(["server", "tool", "tool", "server"]);
83
+ const byServer = (r: VisibleRow) => r.server.name;
84
+ const names = rows.map((r) => (r.kind === "tool" ? r.tool.name : r.server.name));
85
+ expect(names).toEqual(["alpha", "a1", "a2", "beta"]);
86
+ // Tool rows reference the right parent server
87
+ expect(byServer(rows[1]!)).toBe("alpha");
88
+ const third = rows[2];
89
+ expect(third?.kind).toBe("tool");
90
+ if (third?.kind === "tool") expect(third.tool).toBe(a2);
91
+ });
92
+
93
+ it("interleaves only for expanded servers, crossing server boundaries", () => {
94
+ const rows = buildVisibleRows([
95
+ server("first"),
96
+ server("mid", { expanded: true, tools: [tool("m1")] }),
97
+ server("last", { expanded: true, tools: [tool("l1"), tool("l2")] }),
98
+ ]);
99
+ expect(rows.map((r) => (r.kind === "tool" ? r.tool.name : r.server.name))).toEqual([
100
+ "first",
101
+ "mid",
102
+ "m1",
103
+ "last",
104
+ "l1",
105
+ "l2",
106
+ ]);
107
+ });
108
+
109
+ it("collapsed server with tools hides all its tool rows", () => {
110
+ const rows = buildVisibleRows([server("solo", { tools: [tool("s1"), tool("s2")] })]);
111
+ expect(rows).toHaveLength(1);
112
+ expect(kinds(rows)).toEqual(["server"]);
113
+ });
114
+ });
115
+
116
+ // ── filterRows ───────────────────────────────────────────────────────────────
117
+
118
+ describe("filterRows", () => {
119
+ const servers = [
120
+ server("filesystem", {
121
+ tools: [tool("fs_read", "Read a file"), tool("fs_write", "Write a file")],
122
+ }),
123
+ server("postgres", {
124
+ tools: [tool("query", "Run a SQL query"), tool("explain", "")],
125
+ }),
126
+ server("github", { tools: [tool("search_issues", "Find issues")] }),
127
+ ];
128
+
129
+ it("passes the same array through for an empty query", () => {
130
+ expect(filterRows(servers, "")).toBe(servers);
131
+ });
132
+
133
+ it("narrows by server name, case-insensitively", () => {
134
+ const filtered = filterRows(servers, "FILE");
135
+ expect(filtered.map((s) => s.name)).toEqual(["filesystem"]);
136
+ });
137
+
138
+ it("keeps a server when a tool NAME matches, even if the server name doesn't", () => {
139
+ const filtered = filterRows(servers, "issues");
140
+ expect(filtered.map((s) => s.name)).toEqual(["github"]);
141
+ });
142
+
143
+ it("keeps a server when a tool DESCRIPTION matches", () => {
144
+ const filtered = filterRows(servers, "sql query");
145
+ expect(filtered.map((s) => s.name)).toEqual(["postgres"]);
146
+ });
147
+
148
+ it("is case-insensitive over descriptions too", () => {
149
+ const filtered = filterRows(servers, "WRITE");
150
+ expect(filtered.map((s) => s.name)).toEqual(["filesystem"]);
151
+ });
152
+
153
+ it("returns [] when nothing matches", () => {
154
+ expect(filterRows(servers, "no-such-thing")).toEqual([]);
155
+ });
156
+
157
+ it("matches multiple servers", () => {
158
+ const filtered = filterRows(servers, "fs_");
159
+ expect(filtered.map((s) => s.name)).toEqual(["filesystem"]);
160
+ const both = filterRows(servers, "e"); // present in many names/descriptions
161
+ expect(both.length).toBeGreaterThanOrEqual(2);
162
+ });
163
+
164
+ it("never mutates the input servers (statuses keep reporting the full tree)", () => {
165
+ const copy = servers.map((s) => ({ ...s }));
166
+ filterRows(copy, "query");
167
+ // Input still has all instances intact
168
+ expect(copy).toHaveLength(3);
169
+ });
170
+ });
171
+
172
+ // ── toggleTool ───────────────────────────────────────────────────────────────
173
+
174
+ describe("toggleTool", () => {
175
+ it("flips isDirect from true to false", () => {
176
+ const t = tool("x", "desc", true, false);
177
+ toggleTool(t);
178
+ expect(t.isDirect).toBe(false);
179
+ });
180
+
181
+ it("flips isDirect from false to true", () => {
182
+ const t = tool("x", "desc", false, true);
183
+ toggleTool(t);
184
+ expect(t.isDirect).toBe(true);
185
+ });
186
+
187
+ it("does not touch wasDirect (dirty tracking baseline)", () => {
188
+ const t = tool("x", "desc", true, false);
189
+ toggleTool(t);
190
+ toggleTool(t);
191
+ expect(t.wasDirect).toBe(false);
192
+ });
193
+
194
+ it("does not touch name or description", () => {
195
+ const t = tool("x", "the desc", true);
196
+ toggleTool(t);
197
+ expect(t.name).toBe("x");
198
+ expect(t.description).toBe("the desc");
199
+ });
200
+ });
201
+
202
+ // ── computeSelection ─────────────────────────────────────────────────────────
203
+
204
+ describe("computeSelection", () => {
205
+ it("returns true when all tools are direct", () => {
206
+ expect(computeSelection([tool("a", "", true), tool("b", "", true)])).toBe(true);
207
+ });
208
+
209
+ it("returns false when no tools are direct", () => {
210
+ expect(computeSelection([tool("a", "", false), tool("b", "", false)])).toBe(false);
211
+ });
212
+
213
+ it("returns the exact direct-name subset (in row order) when mixed", () => {
214
+ expect(computeSelection([
215
+ tool("a", "", true),
216
+ tool("b", "", false),
217
+ tool("c", "", true),
218
+ tool("d", "", false),
219
+ ])).toEqual(["a", "c"]);
220
+ });
221
+
222
+ it("returns the subset with a single direct tool", () => {
223
+ expect(computeSelection([tool("a", "", false), tool("b", "", true)])).toEqual(["b"]);
224
+ });
225
+
226
+ it("treats the empty tool list as 'all direct' (true)", () => {
227
+ expect(computeSelection([])).toBe(true);
228
+ });
229
+ });
230
+
231
+ // ── openMcpPanel: defensive config guard ──────────────────────────────
232
+
233
+ interface PanelLike {
234
+ render(width: number): string[];
235
+ handleInput(data: string): void;
236
+ }
237
+
238
+ /**
239
+ * Minimal ctx stub for openMcpPanel: `custom` synchronously drives the
240
+ * overlay create-callback with no-op tui/theme doubles and captures the
241
+ * component. No module mocking — the panel's injected deps carry the
242
+ * scenario.
243
+ */
244
+ function fakePanelCtx(capture: (panel: PanelLike) => void): ExtensionCommandContext {
245
+ const ctx = {
246
+ hasUI: true,
247
+ cwd: process.cwd(),
248
+ ui: {
249
+ notify: () => {},
250
+ custom: async (
251
+ create: (tui: unknown, theme: unknown, kb: unknown, done: () => void) => unknown,
252
+ ) => {
253
+ const panel = create(
254
+ { requestRender: () => {} },
255
+ { fg: (_color: string, text: string) => text },
256
+ null,
257
+ () => {},
258
+ );
259
+ capture(panel as PanelLike);
260
+ },
261
+ },
262
+ };
263
+ return ctx as unknown as ExtensionCommandContext;
264
+ }
265
+
266
+ describe("openMcpPanel (malformed directTools)", () => {
267
+ it("survives a non-boolean non-array directTools (unvalidated JSON) and resolves boolean rows", async () => {
268
+ // Simulates a hand-edited mcp.json: directTools parses to a number —
269
+ // neither boolean nor string[]. Old code threw (n.includes is not
270
+ // a function) inside panel-open; the guard keeps it alive.
271
+ const defs: Record<string, ServerDef> = {
272
+ "mangled-direct-tools-srv": {
273
+ command: "cmd",
274
+ args: [],
275
+ directTools: 42 as unknown as boolean | string[],
276
+ },
277
+ };
278
+ const panels: PanelLike[] = [];
279
+ const ctx = fakePanelCtx((p) => panels.push(p));
280
+
281
+ await openMcpPanel({} as unknown as ExtensionAPI, ctx, {
282
+ getServerDefs: () => defs,
283
+ getCachedTools: () => [{ name: "t1", description: "d", inputSchema: {} }],
284
+ getManager: () => ({ getClient: () => undefined } as unknown as ServerManager),
285
+ });
286
+
287
+ const panel = panels[0];
288
+ if (!panel) throw new Error("panel component was not created");
289
+
290
+ // Expand (raw enter), then inspect rendered rows: isDirect must have
291
+ // resolved to a boolean. A non-boolean non-array is "not false" → all
292
+ // direct → "1/1".
293
+ panel.handleInput("\r");
294
+ const lines = panel.render(84);
295
+ expect(lines.some((l) => l.includes("(1/1 tools)"))).toBe(true);
296
+ expect(lines.some((l) => l.includes("● t1"))).toBe(true);
297
+ });
298
+ });