pi-mcp-adapter 3.3.0 → 5.0.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 (77) hide show
  1. package/CHANGELOG.md +95 -1
  2. package/README.md +78 -974
  3. package/cli.js +210 -0
  4. package/commands.ts +55 -4
  5. package/config.ts +308 -33
  6. package/direct-tool-surface.ts +9 -7
  7. package/direct-tools.ts +43 -29
  8. package/dist/config.d.ts +15 -4
  9. package/dist/config.js +317 -30
  10. package/dist/config.js.map +1 -1
  11. package/dist/elicitation-handler.d.ts +4 -0
  12. package/dist/elicitation-handler.js +14 -1
  13. package/dist/elicitation-handler.js.map +1 -1
  14. package/dist/mcp-auth-flow.d.ts +2 -0
  15. package/dist/mcp-auth-flow.js +12 -3
  16. package/dist/mcp-auth-flow.js.map +1 -1
  17. package/dist/mcp-auth.js +3 -1
  18. package/dist/mcp-auth.js.map +1 -1
  19. package/dist/metadata-cache.d.ts +15 -1
  20. package/dist/metadata-cache.js +76 -8
  21. package/dist/metadata-cache.js.map +1 -1
  22. package/dist/output-shape.d.ts +18 -5
  23. package/dist/output-shape.js +162 -18
  24. package/dist/output-shape.js.map +1 -1
  25. package/dist/package-mcp-loader.js +9 -5
  26. package/dist/package-mcp-loader.js.map +1 -1
  27. package/dist/project-server-trust.d.ts +17 -0
  28. package/dist/project-server-trust.js +209 -0
  29. package/dist/project-server-trust.js.map +1 -0
  30. package/dist/server-manager.d.ts +5 -0
  31. package/dist/server-manager.js +98 -26
  32. package/dist/server-manager.js.map +1 -1
  33. package/dist/session-recovery.js +4 -3
  34. package/dist/session-recovery.js.map +1 -1
  35. package/dist/state.d.ts +2 -0
  36. package/dist/types.d.ts +29 -2
  37. package/dist/types.js.map +1 -1
  38. package/dist/utils.d.ts +5 -1
  39. package/dist/utils.js +16 -0
  40. package/dist/utils.js.map +1 -1
  41. package/{OAUTH.md → docs/auth.md} +66 -11
  42. package/docs/configuration.md +221 -0
  43. package/docs/extension-api.md +246 -0
  44. package/docs/pi-builtin-comparison.md +113 -0
  45. package/docs/prompts-and-ui.md +84 -0
  46. package/docs/scripting.md +131 -0
  47. package/docs/servers.md +142 -0
  48. package/docs/tools.md +259 -0
  49. package/elicitation-handler.ts +13 -1
  50. package/index.ts +268 -37
  51. package/init.ts +97 -46
  52. package/mcp-auth-flow.ts +13 -2
  53. package/mcp-auth.ts +3 -1
  54. package/mcp-code.ts +125 -77
  55. package/mcp-output-guard.ts +24 -5
  56. package/mcp-panel.ts +52 -4
  57. package/mcp-script-worker.mjs +6 -2
  58. package/mcp-setup-panel.ts +587 -303
  59. package/metadata-cache.ts +79 -8
  60. package/onboarding-state.ts +20 -0
  61. package/output-shape.ts +161 -22
  62. package/package-mcp-loader.ts +8 -5
  63. package/package.json +8 -6
  64. package/pi-builtin-mcp.ts +32 -0
  65. package/pi-signin-import.ts +147 -0
  66. package/project-server-trust.ts +8 -4
  67. package/prompts.ts +11 -3
  68. package/proxy-modes.ts +163 -138
  69. package/search-ranking.ts +12 -3
  70. package/server-manager.ts +101 -24
  71. package/session-recovery.ts +4 -3
  72. package/skills/mcp-scripting/SKILL.md +24 -30
  73. package/skills/mcp-scripting/references/jev.md +3 -0
  74. package/state.ts +2 -0
  75. package/types.ts +18 -2
  76. package/ui-server.ts +34 -32
  77. package/utils.ts +16 -1
package/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import os from "node:os";
6
+ import { spawnSync } from "node:child_process";
6
7
  import { pathToFileURL } from "node:url";
7
8
  import stripJsonComments from "strip-json-comments";
8
9
 
@@ -74,6 +75,10 @@ function printHelp(log = console.log) {
74
75
  log(" pi-mcp-adapter init --dry-run");
75
76
  log(" pi-mcp-adapter init --discover-host-configs Opt in to host config fallback discovery");
76
77
  log("");
78
+ log("Check configured servers (exits 1 when an enabled server fails):");
79
+ log(" pi-mcp-adapter doctor Connect each enabled server and report its state and tool count");
80
+ log(" pi-mcp-adapter doctor --json Print the same report as a JSON array");
81
+ log("");
77
82
  log("Bearer token storage (servers configured with auth: \"bearer\" and bearerTokenStore: true):");
78
83
  log(" pi-mcp-adapter token set <server> Store a token read from stdin (masked prompt or pipe; never argv)");
79
84
  log(" pi-mcp-adapter token status <server> Report whether a stored token matches the configured URL");
@@ -413,6 +418,207 @@ async function runKey(argv, log, error, stdin) {
413
418
  return 0;
414
419
  }
415
420
 
421
+ const DOCTOR_CONNECT_TIMEOUT_MS = 15_000;
422
+
423
+ // A second layer behind describeConnectError: messages can still name configured values.
424
+ // Values under 4 characters are flags such as DEBUG=1, and redacting them would garble the text.
425
+ function redactDoctorMessage(text, definition, utils) {
426
+ const configured = [definition.bearerToken, definition.oauth?.clientSecret, ...Object.values(definition.headers ?? {}), ...Object.values(definition.env ?? {})];
427
+ if (definition.bearerTokenEnv) configured.push(process.env[definition.bearerTokenEnv]);
428
+ if (typeof definition.url === "string") {
429
+ try {
430
+ configured.push(...new URL(utils.interpolateEnvVars(definition.url)).searchParams.values());
431
+ } catch {
432
+ // An invalid URL is reported without its value.
433
+ }
434
+ }
435
+ const secrets = configured
436
+ .filter((value) => typeof value === "string")
437
+ .flatMap((value) => [value, utils.interpolateEnvVars(value)])
438
+ .flatMap((value) => [value, ...value.split(/\s+/)])
439
+ .filter((value) => value.length >= 4)
440
+ .sort((left, right) => right.length - left.length);
441
+ let redacted = text;
442
+ for (const secret of secrets) redacted = redacted.replaceAll(secret, "***");
443
+ return redacted;
444
+ }
445
+
446
+ // Doctor prints only facts the adapter composes, never server-originated text such as HTTP
447
+ // response bodies or child stderr: those can carry secrets that no configured value reveals.
448
+ function describeConnectError(err, definition, url) {
449
+ if (!definition.url) {
450
+ // server-manager appends the child's stderr as `${message} (${stderr})`, keeping the original as cause.
451
+ const base = err.cause instanceof Error && err.message.startsWith(`${err.cause.message} (`) ? err.cause : err;
452
+ const message = typeof base.code === "number" ? `server returned JSON-RPC error ${base.code}` : base.message;
453
+ return definition.command ? `${message} — run the command directly to see its output` : message;
454
+ }
455
+ // Adapter-composed failures of secret commands and CA files, thrown before any response.
456
+ if (/^(Failed to resolve MCP server|HTTP request headers command|MCP caFile|Missing environment variable in MCP caFile)/.test(err.message)) {
457
+ return err.message;
458
+ }
459
+ let status;
460
+ const codes = new Set();
461
+ const pending = [err];
462
+ const seen = new Set();
463
+ while (pending.length > 0) {
464
+ const current = pending.pop();
465
+ if (typeof current !== "object" || current === null || seen.has(current)) continue;
466
+ seen.add(current);
467
+ if (typeof current.status === "number") status ??= current.status;
468
+ status ??= /\(HTTP (\d{3})\)/.exec(current.message ?? "")?.[1];
469
+ if (typeof current.code === "string" && /^E[A-Z]+$/.test(current.code)) codes.add(current.code);
470
+ pending.push(current.cause, current.data?.cause, ...(current instanceof AggregateError ? current.errors : []));
471
+ }
472
+ const parts = [...(status ? [`HTTP ${status}`] : []), ...codes];
473
+ // server-manager appends the probe classification to the original message, keeping it as cause.
474
+ const suffix = err.cause instanceof Error && err.message.startsWith(err.cause.message) ? err.message.slice(err.cause.message.length) : "";
475
+ if (suffix.startsWith(" — probe: ")) parts.push(suffix.slice(3));
476
+ if (codes.has("ECONNREFUSED") && ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname)) {
477
+ const configured = definition.url.replace(/[?#].*$/s, "").replace(/^([a-z][a-z\d+.-]*:\/\/)[^/]*@/i, "$1");
478
+ parts.push(`Nothing is listening at ${configured}. Start the app or local process that serves this MCP server.`);
479
+ }
480
+ return parts.length > 0 ? parts.join(" — ") : "connection failed";
481
+ }
482
+
483
+ async function runDoctor(argv, log, error) {
484
+ const json = argv[0] === "--json";
485
+ if (argv.length > (json ? 1 : 0)) {
486
+ error("Usage: pi-mcp-adapter doctor [--json]");
487
+ return 1;
488
+ }
489
+ let config, trust, manager, auth, authFlow, utils, agentDir;
490
+ try {
491
+ [config, trust, manager, auth, authFlow, utils, agentDir] = await Promise.all([
492
+ import("./dist/config.js"),
493
+ import("./dist/project-server-trust.js"),
494
+ import("./dist/server-manager.js"),
495
+ import("./dist/mcp-auth.js"),
496
+ import("./dist/mcp-auth-flow.js"),
497
+ import("./dist/utils.js"),
498
+ import("./dist/agent-dir.js"),
499
+ ]);
500
+ } catch (err) {
501
+ error("Unable to load doctor command modules.");
502
+ error(`Import failed: ${err instanceof Error ? err.message : String(err)}`);
503
+ return 1;
504
+ }
505
+
506
+ const cwd = process.cwd();
507
+ // Pi 0.99+ owns ~/.pi/agent/mcp.json and .pi/mcp.json; older Pi used those names for adapter config.
508
+ const piVersion = spawnSync("pi", ["--version"], { encoding: "utf8", timeout: 5_000, shell: process.platform === "win32" }).stdout ?? "";
509
+ const [major, minor] = piVersion.trim().split(".").map(Number);
510
+ const piSupportsMcp = major > 0 || minor >= 99;
511
+ config.setPiMcpConfigEnabled(piSupportsMcp);
512
+ if (!piSupportsMcp && [config.getPiMcpGlobalConfigPath(), config.getProjectPiMcpConfigPath(cwd)].some((file) => fs.existsSync(file))) {
513
+ error("Pi 0.99 or later wasn't found on PATH, so Pi's own mcp.json files were not checked.");
514
+ }
515
+ const loaded = config.loadMcpConfigWithSources(undefined, cwd);
516
+ let projectTrusted = false;
517
+ if (loaded.projectServers.size > 0) {
518
+ try {
519
+ // Pi is a peer dependency and may not resolve from where this CLI is installed.
520
+ const { ProjectTrustStore } = await import("@earendil-works/pi-coding-agent");
521
+ projectTrusted = new ProjectTrustStore(agentDir.getAgentDir()).get(cwd) === true;
522
+ } catch (err) {
523
+ error(`Could not read Pi's project trust, so project servers are treated as untrusted: ${utils.formatTerminalError(err)}`);
524
+ }
525
+ }
526
+ const { config: effective, blockedServers } = await trust.applyProjectServerTrust(loaded, {
527
+ cwd,
528
+ hasUI: false,
529
+ isProjectTrusted: () => projectTrusted,
530
+ });
531
+ const authOptions = auth.getAuthStorageOptions(effective.settings?.oauthDir, cwd, effective.settings?.oauthCredentialStore);
532
+ const serverManager = new manager.McpServerManager(cwd);
533
+ serverManager.setDefaultRequestTimeoutMs(effective.settings?.requestTimeoutMs);
534
+ serverManager.setAuthStorageOptions(authOptions);
535
+
536
+ const check = async (name, definition, signal) => {
537
+ const block = blockedServers.get(name);
538
+ if (block) return { state: "blocked", message: trust.describeProjectServerBlock(block.reason) };
539
+ if (definition.disabled === true) return { state: "disabled" };
540
+ // The doctor has no Pi model registry to read the provider token from, so it never connects these servers.
541
+ if (definition.url && typeof definition.auth === "object") {
542
+ return { state: "needs-auth", message: `signs in with a Pi provider: run /login ${definition.auth.provider} in Pi` };
543
+ }
544
+ const needsSignIn = { state: "needs-auth", message: `sign-in required: run /mcp-auth ${name} in Pi` };
545
+ let anonymous = false;
546
+ let url;
547
+ if (definition.url) {
548
+ try {
549
+ url = utils.resolveServerUrl(definition);
550
+ } catch {
551
+ const missing = typeof definition.url === "string" ? utils.getMissingEnvVars(definition.url) : [];
552
+ return { state: "failed", message: missing.length > 0 ? `URL uses unset environment variables: ${missing.join(", ")}` : "URL is invalid" };
553
+ }
554
+ // Report a missing sign-in from stored credentials instead of starting OAuth.
555
+ if (authFlow.supportsOAuth(definition)) {
556
+ const stored = auth.inspectAuthForUrl(name, url, authOptions);
557
+ if (stored.status === "unavailable") return { state: "failed", message: stored.message };
558
+ // A record without tokens is an unfinished sign-in.
559
+ if (stored.status === "absent" || !(stored.entry.tokens?.accessToken || stored.entry.tokens?.refreshToken)) {
560
+ if (definition.auth === "oauth") return needsSignIn;
561
+ anonymous = true;
562
+ }
563
+ }
564
+ }
565
+ // Anonymous attempts get no OAuth provider, and debug stderr would bypass redaction.
566
+ const connectDefinition = { ...definition, debug: false, ...(anonymous ? { oauth: false } : {}) };
567
+ try {
568
+ const connection = await serverManager.connect(name, connectDefinition, signal);
569
+ return connection.status === "needs-auth" ? needsSignIn : { state: "ok", tools: connection.tools.length };
570
+ } catch (err) {
571
+ if (anonymous && [err, err?.cause].some(manager.isUnauthorizedHttpError)) return needsSignIn;
572
+ return { state: "failed", message: describeConnectError(err, definition, url) };
573
+ }
574
+ };
575
+
576
+ const results = await Promise.all(Object.entries(effective.mcpServers).map(async ([name, definition]) => {
577
+ // The deadline covers the whole check, including server-manager's follow-up probe.
578
+ const controller = new AbortController();
579
+ let timer;
580
+ const timedOut = new Promise((resolve) => {
581
+ timer = setTimeout(() => {
582
+ controller.abort();
583
+ resolve({ state: "failed", message: `no response within ${DOCTOR_CONNECT_TIMEOUT_MS / 1000}s` });
584
+ }, DOCTOR_CONNECT_TIMEOUT_MS);
585
+ });
586
+ const result = await Promise.race([
587
+ check(name, definition, controller.signal).catch((err) => ({ state: "failed", message: utils.formatTerminalError(err) })),
588
+ timedOut,
589
+ ]);
590
+ clearTimeout(timer);
591
+ return {
592
+ name,
593
+ state: result.state,
594
+ tools: result.tools ?? null,
595
+ message: result.message ? utils.sanitizeTerminalText(redactDoctorMessage(result.message, definition, utils)) : null,
596
+ };
597
+ }));
598
+
599
+ // Report first: closing waits for timed-out attempts to settle and can fail.
600
+ let closed = true;
601
+ try {
602
+ if (json) {
603
+ log(JSON.stringify(results, null, 2));
604
+ } else {
605
+ if (results.length === 0) log("No MCP servers configured.");
606
+ for (const result of results) {
607
+ const tools = result.tools === null ? "" : `, ${result.tools} tool${result.tools === 1 ? "" : "s"}`;
608
+ log(`${utils.sanitizeTerminalText(result.name)}: ${result.state}${tools}${result.message ? ` — ${result.message}` : ""}`);
609
+ }
610
+ }
611
+ } finally {
612
+ try {
613
+ await serverManager.closeAll();
614
+ } catch (err) {
615
+ closed = false;
616
+ error(`Some MCP connections did not close cleanly: ${utils.formatTerminalError(err)}`);
617
+ }
618
+ }
619
+ return !closed || results.some((result) => result.state === "failed" || result.state === "needs-auth") ? 1 : 0;
620
+ }
621
+
416
622
  export async function main(argv = process.argv.slice(2), log = console.log, error = console.error, stdin = process.stdin) {
417
623
  const [command, ...rest] = argv;
418
624
 
@@ -429,6 +635,10 @@ export async function main(argv = process.argv.slice(2), log = console.log, erro
429
635
  return runKey(rest, log, error, stdin);
430
636
  }
431
637
 
638
+ if (command === "doctor") {
639
+ return runDoctor(rest, log, error);
640
+ }
641
+
432
642
  if (command === "install") {
433
643
  error("The custom downloader has been retired.");
434
644
  error("Use `pi install npm:pi-mcp-adapter` instead, then optionally run `pi-mcp-adapter init`.");
package/commands.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { createConnection } from "node:net";
3
+ import { isDeepStrictEqual } from "node:util";
2
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
5
  import type { OverlayHandle } from "@earendil-works/pi-tui";
4
6
  import type { McpExtensionState } from "./state.ts";
@@ -11,6 +13,7 @@ import {
11
13
  type KnownServerPreset,
12
14
  type SharedConfigTarget,
13
15
  getServerProvenance,
16
+ loadMcpConfig,
14
17
  previewCompatibilityImports,
15
18
  previewSharedServerEntry,
16
19
  previewStarterSharedConfig,
@@ -29,7 +32,8 @@ import { supportsOAuth, authenticate, removeAuth, type McpOAuthRuntime } from ".
29
32
  import { getAuthStorageOptions, inspectAuthForUrl } from "./mcp-auth.ts";
30
33
  import { inspectBearerTokenForUrl, removeBearerToken } from "./mcp-bearer-store.ts";
31
34
  import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markSharedConfigHintShown } from "./onboarding-state.ts";
32
- import { formatTerminalError, openPath, resolveServerUrl, sanitizeTerminalText } from "./utils.ts";
35
+ import { findPiSignInImports, importPiSignIn } from "./pi-signin-import.ts";
36
+ import { formatTerminalError, openPath, providerSignInMessage, resolveServerUrl, sanitizeTerminalText } from "./utils.ts";
33
37
  import { isAbortError } from "./runtime-owner.ts";
34
38
  import { resolveJevCredential } from "./jev-key-store.ts";
35
39
  import { describeProjectServerBlock } from "./project-server-trust.ts";
@@ -302,7 +306,9 @@ export async function reconnectServer(
302
306
  state.owner?.throwIfInactive();
303
307
  if (connection.status === "needs-auth") {
304
308
  if (ui) {
305
- ui.notify(`MCP: ${name} requires OAuth. Run /mcp-auth ${name} first.`, "warning");
309
+ ui.notify(typeof definition.auth === "object"
310
+ ? `MCP: ${providerSignInMessage(name, definition.auth.provider)}`
311
+ : `MCP: ${name} requires OAuth. Run /mcp-auth ${name} first.`, "warning");
306
312
  }
307
313
  updateStatusBar(state);
308
314
  return false;
@@ -392,6 +398,11 @@ export async function authenticateServer(
392
398
  return { ok: false, message };
393
399
  }
394
400
 
401
+ if (typeof definition.auth === "object") {
402
+ const message = providerSignInMessage(serverName, definition.auth.provider);
403
+ ui.notify(message, "info");
404
+ return { ok: false, message };
405
+ }
395
406
  if (!supportsOAuth(definition)) {
396
407
  const message = `Server "${serverName}" does not use OAuth authentication. Set "auth": "oauth" or omit auth for auto-detection.`;
397
408
  ui.notify(
@@ -587,6 +598,21 @@ function buildSharedConfigNoticeLines(configOverridePath: string | undefined, cw
587
598
  };
588
599
  }
589
600
 
601
+ // Any accepted TCP connection counts as reachable; failures resolve false so the add still succeeds.
602
+ function isLocalServerReachable(url: string, timeoutMs = 1_500): Promise<boolean> {
603
+ const { hostname, port } = new URL(url);
604
+ return new Promise((resolve) => {
605
+ const socket = createConnection({ host: hostname, port: Number(port) });
606
+ const finish = (reachable: boolean) => {
607
+ socket.destroy();
608
+ resolve(reachable);
609
+ };
610
+ socket.setTimeout(timeoutMs, () => finish(false));
611
+ socket.once("connect", () => finish(true));
612
+ socket.once("error", () => finish(false));
613
+ });
614
+ }
615
+
590
616
  export async function openMcpSetup(
591
617
  state: McpExtensionState,
592
618
  pi: ExtensionAPI,
@@ -641,7 +667,16 @@ export async function openMcpSetup(
641
667
  addKnownServer: async (preset: KnownServerPreset, target: SharedConfigTarget) => {
642
668
  const path = writeSharedServerEntry(getSharedConfigPath(target, ctx.cwd), preset.id, preset.entry);
643
669
  configChanged = true;
644
- return { path, serverName: preset.name };
670
+ // Merging is per field, so the entry is in effect when every preset field survives it and nothing disables it.
671
+ const active = loadMcpConfig(configOverridePath, ctx.cwd).mcpServers[preset.id];
672
+ const ignoredBecause = !active
673
+ ? `the current config mode doesn't read ${path}`
674
+ : Object.entries(preset.entry).some(([field, value]) => !isDeepStrictEqual(active[field as keyof typeof active], value))
675
+ ? `another config file also defines ${preset.id} and takes precedence`
676
+ : isServerDisabled(active) ? `another config file disables ${preset.id}` : undefined;
677
+ const result = { path, serverName: preset.name, ...(ignoredBecause ? { ignoredBecause } : {}) };
678
+ if (!preset.desktopApp) return result;
679
+ return { ...result, reachable: await isLocalServerReachable(preset.entry.url!) };
645
680
  },
646
681
  openPath: async (targetPath: string) => {
647
682
  await openPath(pi, targetPath);
@@ -659,7 +694,8 @@ export async function openMcpSetup(
659
694
  resolve({ configChanged });
660
695
  });
661
696
  },
662
- { overlay: true, overlayOptions: { anchor: "center", width: 92 } },
697
+ // The panel sizes its height from tui.terminal.rows minus this margin, so it always fits.
698
+ { overlay: true, overlayOptions: { anchor: "center", width: 92, maxHeight: "100%", margin: { top: 1, bottom: 1 } } },
663
699
  );
664
700
  });
665
701
  }
@@ -766,6 +802,21 @@ export async function openMcpPanel(
766
802
 
767
803
  const { createMcpPanel } = await import("./mcp-panel.ts");
768
804
  let configChanged = false;
805
+ const authStorageOptions = state.authStorageOptions ?? {};
806
+ if (findPiSignInImports(config, authStorageOptions).length > 0) {
807
+ callbacks.importPiSignIns = () => {
808
+ const imported: string[] = [];
809
+ const failed: { server: string; error: string }[] = [];
810
+ for (const candidate of findPiSignInImports(config, authStorageOptions)) {
811
+ try {
812
+ if (importPiSignIn(candidate, authStorageOptions)) imported.push(candidate.serverName);
813
+ } catch (error) {
814
+ failed.push({ server: candidate.serverName, error: error instanceof Error ? error.message : String(error) });
815
+ }
816
+ }
817
+ return { imported, failed };
818
+ };
819
+ }
769
820
 
770
821
  await new Promise<void>((resolve) => {
771
822
  ctx.ui.custom(