@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,524 @@
1
+ /**
2
+ * Factories for the `mcp` proxy tool's execute handler and session_start
3
+ * direct-tool registration logic.
4
+ *
5
+ * Extracted from index.ts (plan-028, Task 6) so the inline action-dispatch
6
+ * block no longer bloats the registration module. Each factory receives the
7
+ * runtime dependencies as closures and returns a function that the
8
+ * registration module can consume directly.
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { autoAuthenticate, needsAuthToolResult } from "./auto-auth.js";
13
+ import { resolveServerSettings } from "./config.js";
14
+ import {
15
+ filterDirectTools,
16
+ pruneRegisteredNames,
17
+ registerDirectTools,
18
+ } from "./direct-tools.js";
19
+ import { getCachedTools, recordClientOutcome } from "./metadata-cache.js";
20
+ import type { ServerClient } from "./server-client.js";
21
+ import type { ServerManager } from "./server-manager.js";
22
+ import {
23
+ getServerPrefix,
24
+ matchRawToolName,
25
+ resolveServerFromToolName,
26
+ resolveServerRef,
27
+ } from "./tool-naming.js";
28
+ import type { CachedTool, McpConfig, ServerDef } from "./types.js";
29
+
30
+ // ── Types ──────────────────────────────────────────────────────────────────
31
+
32
+ type ExecuteParams = {
33
+ tool?: string;
34
+ args?: string | Record<string, unknown>;
35
+ search?: string;
36
+ describe?: string;
37
+ connect?: string;
38
+ server?: string;
39
+ action?: string;
40
+ };
41
+
42
+ /** Shape of what pi.registerTool receives for the execute function. */
43
+ type ExecuteFn = Parameters<ExtensionAPI["registerTool"]>[0]["execute"];
44
+
45
+ // ── Helper ─────────────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Resolve a tool reference (raw name OR final prefixed name) to the owning
49
+ * server and the raw server tool name. Prefixed lookups use each server's
50
+ * OWN prefix mode, exactly as the call path does.
51
+ */
52
+ function resolveToolRef(
53
+ ref: string,
54
+ defs: Record<string, ServerDef>,
55
+ config: McpConfig,
56
+ manager: ServerManager,
57
+ allTools: Array<CachedTool & { serverName: string }>,
58
+ ): { serverName: string; rawName: string } | undefined {
59
+ // 1. Exact raw name
60
+ const rawHit = allTools.find((t) => t.name === ref);
61
+ if (rawHit) return { serverName: rawHit.serverName, rawName: rawHit.name };
62
+ // 2. Final prefixed name → owning server → format-match the raw tool name
63
+ const servers = Object.entries(defs).map(([name, def]) => ({
64
+ name,
65
+ prefix: resolveServerSettings(def, config).toolPrefix,
66
+ }));
67
+ const serverName = resolveServerFromToolName(ref, servers);
68
+ const def = serverName ? defs[serverName] : undefined;
69
+ if (!serverName || !def) return undefined;
70
+ const prefixMode = resolveServerSettings(def, config).toolPrefix;
71
+ const rawName = matchRawToolName(
72
+ ref,
73
+ serverName,
74
+ prefixMode,
75
+ manager.getToolsForServer(serverName, def),
76
+ );
77
+ return rawName !== undefined ? { serverName, rawName } : undefined;
78
+ }
79
+
80
+ // ── Factory ────────────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Build the execute function for the `mcp` proxy tool.
84
+ *
85
+ * @param deps.getManager - returns the current module-level ServerManager
86
+ * @param deps.loadDefs - load enabled server definitions (seam-aware)
87
+ * @param deps.loadCfg - load the MCP config (seam-aware)
88
+ */
89
+ export function buildProxyToolExecute(deps: {
90
+ getManager: () => ServerManager;
91
+ loadDefs: () => Record<string, ServerDef>;
92
+ loadCfg: () => McpConfig;
93
+ }): ExecuteFn {
94
+ const { getManager, loadDefs, loadCfg } = deps;
95
+
96
+ return async function execute(_toolCallId, params, signal, _onUpdate, ctx) {
97
+ const manager = getManager();
98
+ const p = params as ExecuteParams;
99
+
100
+ // ── status (no meaningful params; action:'status' is an alias) ──────
101
+ if (
102
+ !p.tool &&
103
+ !p.search &&
104
+ !p.describe &&
105
+ !p.connect &&
106
+ !p.server &&
107
+ (p.action === undefined || p.action === "status")
108
+ ) {
109
+ const defs = loadDefs();
110
+ manager.sync(defs);
111
+ const clients = manager.getClients();
112
+ const lines =
113
+ clients.length === 0
114
+ ? ["No MCP servers configured."]
115
+ : clients.map(
116
+ (c) =>
117
+ `${c.name}: ${c.status}${c.error ? ` (${c.error})` : ""}`,
118
+ );
119
+ return {
120
+ content: [{ type: "text" as const, text: lines.join("\n") }],
121
+ details: {},
122
+ };
123
+ }
124
+
125
+ // ── connect ──────────────────────────────────────────────────────────
126
+ if (p.connect) {
127
+ const defs = loadDefs();
128
+ manager.sync(defs);
129
+ const client = manager.getClient(p.connect);
130
+ if (!client) {
131
+ return {
132
+ content: [{ type: "text" as const, text: `Unknown server: ${p.connect}` }],
133
+ details: {},
134
+ };
135
+ }
136
+ // ADR 0004 settle point: proxy p.connect action — recorded in
137
+ // `finally` so a failed connect ("error") persists too.
138
+ try {
139
+ await client.connect();
140
+ } finally {
141
+ recordClientOutcome(client);
142
+ }
143
+ // A 401 surfaces as status 'needs-auth' (connect() does not throw) —
144
+ // reporting that as "Connected with 0 tools" would be a lie.
145
+ if (client.status === "needs-auth") {
146
+ return {
147
+ content: [
148
+ {
149
+ type: "text" as const,
150
+ text: `Server ${p.connect} requires authentication: ${client.error ?? "token missing or rejected"}`,
151
+ },
152
+ ],
153
+ details: { server: p.connect, status: "needs-auth" },
154
+ };
155
+ }
156
+ return {
157
+ content: [
158
+ {
159
+ type: "text" as const,
160
+ text: `Connected to ${p.connect}. ${client.tools.length} tools available.`,
161
+ },
162
+ ],
163
+ details: { server: p.connect, toolCount: client.tools.length },
164
+ };
165
+ }
166
+
167
+ // ── search ───────────────────────────────────────────────────────────
168
+ if (p.search) {
169
+ // Served from live + metadata cache — no server connections
170
+ const defs = loadDefs();
171
+ manager.sync(defs);
172
+ const config = loadCfg();
173
+ const allTools = manager.getAllToolsWithCache(defs);
174
+ const find = (query: string, serverFilter: string | undefined) =>
175
+ allTools.filter(
176
+ (t) =>
177
+ (!serverFilter || t.serverName === serverFilter) &&
178
+ (t.name.toLowerCase().includes(query) ||
179
+ (t.description ?? "").toLowerCase().includes(query)),
180
+ );
181
+ let results = find(p.search.toLowerCase(), p.server);
182
+ if (results.length === 0) {
183
+ // The query may be a final prefixed name (e.g. "srv_a_b" for
184
+ // tool "a.b"): resolve it and retry with the raw tool name.
185
+ const ref = resolveToolRef(p.search, defs, config, manager, allTools);
186
+ if (ref) results = find(ref.rawName.toLowerCase(), ref.serverName);
187
+ }
188
+ if (results.length === 0) {
189
+ return {
190
+ content: [{ type: "text" as const, text: `No tools matching "${p.search}"` }],
191
+ details: {},
192
+ };
193
+ }
194
+ const text = results
195
+ .map((t) => `${t.name} (${t.serverName})\n ${t.description ?? "(no description)"}`)
196
+ .join("\n\n");
197
+ return { content: [{ type: "text" as const, text }], details: {} };
198
+ }
199
+
200
+ // ── describe ─────────────────────────────────────────────────────────
201
+ if (p.describe) {
202
+ // Served from live + metadata cache — no server connections
203
+ const defs = loadDefs();
204
+ manager.sync(defs);
205
+ const config = loadCfg();
206
+ const allTools = manager.getAllToolsWithCache(defs);
207
+ let tool = allTools.find((t) => t.name === p.describe);
208
+ // Also accept a final prefixed name (e.g. "srv_a_b" for tool "a.b")
209
+ if (!tool) {
210
+ const ref = resolveToolRef(p.describe, defs, config, manager, allTools);
211
+ if (ref) {
212
+ tool = allTools.find(
213
+ (t) => t.serverName === ref.serverName && t.name === ref.rawName,
214
+ );
215
+ }
216
+ }
217
+ if (!tool) {
218
+ return {
219
+ content: [{ type: "text" as const, text: `Tool not found: ${p.describe}` }],
220
+ details: {},
221
+ };
222
+ }
223
+ const schema = JSON.stringify(tool.inputSchema, null, 2);
224
+ return {
225
+ content: [
226
+ {
227
+ type: "text" as const,
228
+ text: `${tool.name} (${tool.serverName})\n${tool.description ?? ""}\n\nSchema:\n${schema}`,
229
+ },
230
+ ],
231
+ details: {},
232
+ };
233
+ }
234
+
235
+ // ── list server (server param only, no tool) ─────────────────────────
236
+ if (p.server && !p.tool) {
237
+ const defs = loadDefs();
238
+ manager.sync(defs);
239
+ const config = loadCfg();
240
+ let serverRef = p.server;
241
+ let client = manager.getClient(serverRef);
242
+ if (!client) {
243
+ // Accept a prefixed tool-name reference instead of a bare server
244
+ // name (e.g. "github" for server "github-mcp" under "short" mode)
245
+ const servers = Object.entries(defs).map(([name, def]) => ({
246
+ name,
247
+ prefix: resolveServerSettings(def, config).toolPrefix,
248
+ }));
249
+ const resolved = resolveServerRef(serverRef, servers);
250
+ if (resolved) {
251
+ serverRef = resolved;
252
+ client = manager.getClient(serverRef);
253
+ }
254
+ }
255
+ if (!client) {
256
+ return {
257
+ content: [{ type: "text" as const, text: `Unknown server: ${p.server}` }],
258
+ details: {},
259
+ };
260
+ }
261
+ const def = defs[serverRef];
262
+ // Live tools if connected, else valid cache
263
+ const tools = def ? manager.getToolsForServer(serverRef, def) : client.tools;
264
+ const lines = tools.map(
265
+ (t) => `${t.name}: ${t.description ?? "(no description)"}`,
266
+ );
267
+ return {
268
+ content: [{ type: "text" as const, text: lines.join("\n") || "(no tools)" }],
269
+ details: { server: serverRef, toolCount: tools.length },
270
+ };
271
+ }
272
+
273
+ // ── call tool ────────────────────────────────────────────────────────
274
+ if (p.tool) {
275
+ const defs = loadDefs();
276
+ manager.sync(defs);
277
+ const config = loadCfg();
278
+
279
+ let serverName: string | undefined = p.server;
280
+ let rawToolName: string = p.tool;
281
+
282
+ if (!serverName) {
283
+ // First: resolve the final prefixed name against each server's ACTUAL
284
+ // prefix mode (per-server def, falling back to the global config).
285
+ const servers = Object.entries(defs).map(([name, def]) => ({
286
+ name,
287
+ prefix: resolveServerSettings(def, config).toolPrefix,
288
+ }));
289
+ const resolved = resolveServerFromToolName(p.tool, servers);
290
+ const resolvedDef = resolved ? defs[resolved] : undefined;
291
+ if (resolved && resolvedDef) {
292
+ const prefixMode = resolveServerSettings(resolvedDef, config).toolPrefix;
293
+ serverName = resolved;
294
+ // Resolve the RAW tool name by format-matching against the
295
+ // owning server's tool list — never by slicing the prefixed
296
+ // name. Slicing cannot reverse the `.`→`_` sanitization, so
297
+ // tool "a.b" would be called as "a_b" and miss on the server.
298
+ const tools = manager.getToolsForServer(resolved, resolvedDef);
299
+ const raw = matchRawToolName(p.tool, resolved, prefixMode, tools);
300
+ if (raw !== undefined) {
301
+ rawToolName = raw;
302
+ } else {
303
+ // Last resort (tool missing from the live list/cache, e.g.
304
+ // the server added it since the cache was written): strip
305
+ // the prefix. Lossless only for dot-free tool names.
306
+ const prefixStr = getServerPrefix(resolved, prefixMode);
307
+ rawToolName = p.tool.slice(prefixStr.length + 1);
308
+ }
309
+ }
310
+ // Fallback: raw (unprefixed) tool-name lookup across live + cached
311
+ // metadata, so existing raw-name calls keep working.
312
+ if (!serverName) {
313
+ const toolDef = manager.getAllToolsWithCache(defs).find((t) => t.name === p.tool);
314
+ serverName = toolDef?.serverName;
315
+ }
316
+ } else {
317
+ // An explicit server was given — but the tool may STILL be a final
318
+ // prefixed name (e.g. mcp({ tool: "srv_a_b", server: "srv" })). Try
319
+ // format-matching against that server's own tool list first; if no
320
+ // tool formats to it, use p.tool as the raw name (existing behavior).
321
+ const def = defs[serverName];
322
+ if (def) {
323
+ const prefixMode = resolveServerSettings(def, config).toolPrefix;
324
+ const tools = manager.getToolsForServer(serverName, def);
325
+ const raw = matchRawToolName(p.tool, serverName, prefixMode, tools);
326
+ if (raw !== undefined) rawToolName = raw;
327
+ }
328
+ }
329
+
330
+ if (!serverName) {
331
+ return {
332
+ content: [{ type: "text" as const, text: `Tool not found: ${p.tool}` }],
333
+ details: {},
334
+ };
335
+ }
336
+
337
+ const client = manager.getClient(serverName);
338
+ if (!client) {
339
+ return {
340
+ content: [{ type: "text" as const, text: `Server not found: ${serverName}` }],
341
+ details: {},
342
+ };
343
+ }
344
+
345
+ // Parse args: string → JSON (with error handling), object → use as-is, undefined → {}
346
+ let toolArgs: Record<string, unknown>;
347
+ if (typeof p.args === "string") {
348
+ try {
349
+ toolArgs = JSON.parse(p.args) as Record<string, unknown>;
350
+ } catch (e) {
351
+ return {
352
+ content: [{ type: "text" as const, text: `Invalid JSON in args: ${e instanceof Error ? e.message : String(e)}` }],
353
+ isError: true,
354
+ details: {},
355
+ };
356
+ }
357
+ } else {
358
+ toolArgs = (p.args ?? {}) as Record<string, unknown>;
359
+ }
360
+
361
+ // Connect the owning server (callTool would connect lazily anyway)
362
+ // so a 401 surfaces here as `needs-auth`: guidance by default,
363
+ // inline auto-auth + one retry when autoAuth is on.
364
+ // ADR 0004 settle point: proxy p.tool lazy connect — recorded in
365
+ // `finally` so a failed connect ("error") persists too.
366
+ try {
367
+ await client.connect();
368
+ } finally {
369
+ recordClientOutcome(client);
370
+ }
371
+ if (client.status === "needs-auth") {
372
+ if (!config.autoAuth) {
373
+ return needsAuthToolResult(serverName);
374
+ }
375
+ const outcome = await autoAuthenticate(ctx, client);
376
+ if (!outcome.proceed) {
377
+ return needsAuthToolResult(serverName, outcome.error);
378
+ }
379
+ }
380
+
381
+ // The call below is the (single) retry after a successful auto-auth
382
+ const result = await client.callTool(rawToolName, toolArgs, signal);
383
+ // Cast MCP ContentBlock[] to pi's (TextContent | ImageContent)[]
384
+ // Both are discriminated unions on `type`; we only surface text + image blocks
385
+ const content = result.content as Array<
386
+ { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
387
+ >;
388
+ return {
389
+ content,
390
+ details: { server: serverName, tool: rawToolName },
391
+ isError: result.isError,
392
+ };
393
+ }
394
+
395
+ // ── fallback ─────────────────────────────────────────────────────────
396
+ return {
397
+ content: [{ type: "text" as const, text: "Unknown action" }],
398
+ details: {},
399
+ };
400
+ };
401
+ }
402
+
403
+ // ── Session-start handler factory ─────────────────────────────────────────
404
+
405
+ /**
406
+ * Build the session_start handler for direct-tool registration.
407
+ *
408
+ * @param deps.pi - ExtensionAPI (for registerDirectTools)
409
+ * @param deps.getManager - returns the current module-level ServerManager
410
+ * @param deps.loadDefs - load enabled server definitions (seam-aware)
411
+ * @param deps.loadCfg - load the MCP config (seam-aware)
412
+ * @param deps.setIdleTimeout - callback to update the module-level idle timeout
413
+ */
414
+ export function buildSessionStartHandler(deps: {
415
+ pi: ExtensionAPI;
416
+ getManager: () => ServerManager;
417
+ loadDefs: () => Record<string, ServerDef>;
418
+ loadCfg: () => McpConfig;
419
+ setIdleTimeout: (minutes: number) => void;
420
+ startLifecycle: () => void;
421
+ }): () => Promise<void> {
422
+ const { pi, getManager, loadDefs, loadCfg, setIdleTimeout, startLifecycle } = deps;
423
+
424
+ return async function sessionStart() {
425
+ const manager = getManager();
426
+ // Re-sync server definitions on every session start (picks up config changes)
427
+ const defs = loadDefs();
428
+ manager.sync(defs);
429
+
430
+ const config = loadCfg();
431
+ setIdleTimeout(config.idleTimeout);
432
+ startLifecycle(); // idempotent — safe across /reload
433
+
434
+ // ── Cache-first direct tool registration ─────────────────────────────
435
+ // Register direct tools from the metadata cache: NO server is connected
436
+ // at startup (no connect storm). Servers without a valid cache (first run)
437
+ // are probed in the background, fire-and-forget — session_start does not
438
+ // wait for them, and their tools register once each probe settles.
439
+ const resolveClient = async (name: string): Promise<ServerClient> => {
440
+ const client = manager.getClient(name);
441
+ if (!client) throw new Error(`Server "${name}" is no longer configured`);
442
+ try {
443
+ await client.connect();
444
+ } finally {
445
+ // ADR 0004 settle point: direct-tool lazy connect.
446
+ recordClientOutcome(client);
447
+ }
448
+ return client;
449
+ };
450
+ const registerFromTools = (serverName: string, def: ServerDef, tools: CachedTool[]): void => {
451
+ const settings = resolveServerSettings(def, config);
452
+ if (settings.directTools === false) return;
453
+ registerDirectTools(pi, {
454
+ serverName,
455
+ prefix: settings.toolPrefix,
456
+ tools: filterDirectTools(tools, settings),
457
+ autoAuth: () => loadCfg().autoAuth,
458
+ resolveClient,
459
+ });
460
+ };
461
+
462
+ // Drop tracked names from servers that are no longer configured, so a
463
+ // removed server cannot keep blocking a surviving server's identical
464
+ // final name (e.g. under toolPrefix "none").
465
+ pruneRegisteredNames(new Set(Object.keys(defs)));
466
+
467
+ const probeTargets: Array<{ name: string; def: ServerDef }> = [];
468
+ for (const [name, def] of Object.entries(defs)) {
469
+ const cached = getCachedTools(name, def);
470
+ if (cached) {
471
+ registerFromTools(name, def, cached);
472
+ } else {
473
+ probeTargets.push({ name, def });
474
+ }
475
+ }
476
+
477
+ if (probeTargets.length > 0) {
478
+ void Promise.allSettled(
479
+ probeTargets.map(async ({ name, def }) => {
480
+ const client = manager.getClient(name);
481
+ if (!client) return;
482
+ try {
483
+ await client.connect();
484
+ // A concurrent session_start (e.g. /reload) may have replaced this
485
+ // client while we were connecting: sync() closed it, so the
486
+ // generation fence made connect() resolve normally with EMPTY
487
+ // tools. Registering from that stale client would run a zero-tool
488
+ // pass, evicting the names the current session's probe just
489
+ // registered (and re-registering duplicates on the next start).
490
+ if (manager.getClient(name) !== client) return; // superseded
491
+ // A 401 surfaces as status 'needs-auth' (connect() does not
492
+ // throw) — record it (ADR 0004) and warn clearly instead of
493
+ // silently registering 0 tools.
494
+ if (client.status === "needs-auth") {
495
+ recordClientOutcome(client);
496
+ console.warn(
497
+ `[mcp] server "${name}" requires authentication (${client.error ?? "token missing or rejected"}) — no tools registered`,
498
+ );
499
+ return;
500
+ }
501
+ // ADR 0004 settle point: probe success.
502
+ recordClientOutcome(client);
503
+ const tools: CachedTool[] = client.tools.map((t) => {
504
+ const cached: CachedTool = { name: t.name, inputSchema: t.inputSchema };
505
+ if (t.description !== undefined) cached.description = t.description;
506
+ return cached;
507
+ });
508
+ registerFromTools(name, def, tools);
509
+ } catch (e) {
510
+ // A connect failure settles the client into "error" — persist
511
+ // that outcome (ADR 0004 settle point: probe error) so the
512
+ // failure is visible in /mcp status across sessions.
513
+ recordClientOutcome(client);
514
+ // Probe failure is logged, not thrown — the proxy tool will still
515
+ // surface the error on first use of a tool from this server.
516
+ console.warn(
517
+ `[mcp] background probe for server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`,
518
+ );
519
+ }
520
+ }),
521
+ );
522
+ }
523
+ };
524
+ }