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/config.ts CHANGED
@@ -10,8 +10,8 @@ import { cloneBuiltInAgentPluginEntry, isBuiltInAgentPlugin, mergeBuiltInAgentPl
10
10
  import { loadClaudePluginBundles } from "./claude-plugin-loader.ts";
11
11
  import { loadPackageMcpConfigs } from "./package-mcp-loader.ts";
12
12
  import { validateJevSettings } from "./jev-client.ts";
13
- import { formatServerNamespace, isServerDisabled, type ClaudePluginConfig, type HostConfigDiscovery, type McpConfig, type ServerEntry, type McpSettings, type ImportKind, type ServerProvenance } from "./types.ts";
14
- import { parseJsonWithComments, stripUtf8Bom, toStringRecord } from "./utils.ts";
13
+ import { formatServerNamespace, isServerDisabled, type ClaudePluginConfig, type HostConfigDiscovery, type McpConfig, type OAuthConfig, type ServerEntry, type McpSettings, type ImportKind, type ServerProvenance } from "./types.ts";
14
+ import { getMissingEnvVars, parseJsonWithComments, providerAuthUrlError, stripUtf8Bom, toStringRecord } from "./utils.ts";
15
15
 
16
16
  const GENERIC_GLOBAL_CONFIG_PATH = join(homedir(), ".config", "mcp", "mcp.json");
17
17
  const AGENTS_GLOBAL_CONFIG_PATHS = [
@@ -31,6 +31,8 @@ export interface KnownServerPreset {
31
31
  name: string;
32
32
  summary: string;
33
33
  entry: ServerEntry;
34
+ /** Offered only when one of these app paths exists; the local server is probed after adding. */
35
+ desktopApp?: { paths: readonly string[]; enableSteps: string };
34
36
  }
35
37
 
36
38
  export const KNOWN_SERVER_PRESETS: readonly KnownServerPreset[] = [
@@ -74,6 +76,20 @@ export const KNOWN_SERVER_PRESETS: readonly KnownServerPreset[] = [
74
76
  summary: "Inspect and automate a local Chrome browser.",
75
77
  entry: { command: "npx", args: ["-y", "chrome-devtools-mcp@1.6.0"] },
76
78
  },
79
+ {
80
+ id: "figma",
81
+ name: "Figma (desktop)",
82
+ summary: "Read designs through the Figma desktop app. Needs a Dev or Full seat on a paid Figma plan.",
83
+ entry: { url: "http://127.0.0.1:3845/mcp", protocolVersion: "auto" },
84
+ desktopApp: {
85
+ paths: [
86
+ "/Applications/Figma.app",
87
+ join(homedir(), "Applications", "Figma.app"),
88
+ join(homedir(), "AppData", "Local", "Figma", "Figma.exe"),
89
+ ],
90
+ enableSteps: "To enable it, open a Design file in Figma, switch to Dev Mode (Shift+D), and click 'Enable desktop MCP server' in the inspect panel.",
91
+ },
92
+ },
77
93
  ];
78
94
 
79
95
  const IMPORT_PATHS: Record<ImportKind, string[]> = {
@@ -97,7 +113,7 @@ const IMPORT_PATHS: Record<ImportKind, string[]> = {
97
113
  };
98
114
 
99
115
  interface ConfigSourceSpec {
100
- id: "shared-global" | "agents-global" | "agents-nested-global" | "pi-global" | "shared-project-ancestor" | "pi-project-ancestor" | "shared-project" | "pi-project";
116
+ id: "shared-global" | "agents-global" | "agents-nested-global" | "pi-mcp-global" | "pi-global" | "shared-project-ancestor" | "pi-project-ancestor" | "shared-project" | "pi-mcp-project" | "pi-project";
101
117
  label: string;
102
118
  readPath: string;
103
119
  writePath: string;
@@ -162,6 +178,7 @@ export interface McpDiscoverySummary {
162
178
  totalServerCount: number;
163
179
  fingerprint: string;
164
180
  repoPrompt: RepoPromptDiscovery;
181
+ knownServerPresets: readonly KnownServerPreset[];
165
182
  }
166
183
 
167
184
  export interface McpStandardConfigSummary {
@@ -185,10 +202,15 @@ export function getPiGlobalConfigPath(overridePath?: string): string {
185
202
  return overridePath ? resolve(overridePath) : getAgentPath(ADAPTER_CONFIG_NAME);
186
203
  }
187
204
 
188
- export function getLegacyPiMcpGlobalConfigPath(): string {
205
+ export function getPiMcpGlobalConfigPath(): string {
189
206
  return getAgentPath(PI_MCP_CONFIG_NAME);
190
207
  }
191
208
 
209
+ /** @internal Pi's built-in MCP stores OAuth sign-ins here, keyed by `String(new URL(serverUrl))`. */
210
+ export function getPiMcpAuthPath(): string {
211
+ return getAgentPath("mcp-auth.json");
212
+ }
213
+
192
214
  export function getGenericGlobalConfigPath(): string {
193
215
  return GENERIC_GLOBAL_CONFIG_PATH;
194
216
  }
@@ -201,22 +223,30 @@ export function getProjectPiConfigPath(cwd = process.cwd()): string {
201
223
  return resolve(cwd, getConfigDirName(), ADAPTER_CONFIG_NAME);
202
224
  }
203
225
 
204
- export function getLegacyProjectPiMcpConfigPath(cwd = process.cwd()): string {
226
+ export function getProjectPiMcpConfigPath(cwd = process.cwd()): string {
205
227
  return resolve(cwd, getConfigDirName(), PI_MCP_CONFIG_NAME);
206
228
  }
207
229
 
208
- /**
209
- * Adapter-only content in a legacy Pi `mcp.json`. Once Pi's built-in MCP owns the file, its
210
- * `mcpServers` belong to Pi; `settings`, `imports`, `claudePlugins`, and the legacy
211
- * `mcp-servers` key are read by neither, so they still need moving.
212
- */
213
- function legacyMcpConfigHasContent(filePath: string, piOwnsServers: boolean): boolean {
230
+ let piMcpConfigEnabled = false;
231
+
232
+ /** @internal Set once by the extension on Pi 0.99+, so every loader in the process agrees. */
233
+ export function setPiMcpConfigEnabled(enabled: boolean): void {
234
+ piMcpConfigEnabled = enabled;
235
+ }
236
+
237
+ /** @internal */
238
+ export function isPiMcpConfigEnabled(): boolean {
239
+ return piMcpConfigEnabled;
240
+ }
241
+
242
+ /** Adapter content in an old adapter `mcp.json` on Pi versions without their own MCP config. */
243
+ function legacyMcpConfigHasContent(filePath: string): boolean {
214
244
  if (!existsSync(filePath)) return false;
215
245
  try {
216
246
  const raw = parseJsonWithComments(readFileSync(filePath, "utf-8"));
217
247
  if (!isRecord(raw)) return false;
218
248
  const hasServers = (value: unknown) => isRecord(value) && Object.keys(value).length > 0;
219
- return (!piOwnsServers && hasServers(raw.mcpServers))
249
+ return hasServers(raw.mcpServers)
220
250
  || hasServers(raw["mcp-servers"])
221
251
  || raw.settings !== undefined
222
252
  || raw.imports !== undefined
@@ -226,18 +256,32 @@ function legacyMcpConfigHasContent(filePath: string, piOwnsServers: boolean): bo
226
256
  }
227
257
  }
228
258
 
229
- export function getLegacyMcpMigrationNotices(cwd = process.cwd(), overridePath?: string, piOwnsServers = false): string[] {
259
+ export function getLegacyMcpMigrationNotices(cwd = process.cwd(), overridePath?: string): string[] {
260
+ if (piMcpConfigEnabled) {
261
+ return getConfigSources(overridePath, cwd).flatMap((source) => {
262
+ const file = isPiMcpSource(source.id) ? readPiMcpConfig(source.readPath) : null;
263
+ if (!file) return [];
264
+ const parts: string[] = [];
265
+ if (file.adapterKeys.length > 0) {
266
+ const serversHint = file.adapterKeys.includes("mcp-servers") ? `, with "mcp-servers" entries under its "mcpServers" key` : "";
267
+ parts.push(`pi-mcp-adapter does not read ${file.adapterKeys.join(", ")} in this file; move them into ${source.writePath}${serversHint}.`);
268
+ }
269
+ if (file.skipped.length > 0) parts.push(`Skipped ${file.skipped.join("; ")}.`);
270
+ if (file.ignoredSettings.size > 0) {
271
+ const ignored = [...file.ignoredSettings].map(([name, settings]) => `"${name}": ${settings.join(", ")}`);
272
+ parts.push(`Ignored settings (details in /mcp-adapter): ${ignored.join("; ")}.`);
273
+ }
274
+ return parts.length > 0 ? [`${source.readPath}: ${parts.join(" ")}`] : [];
275
+ });
276
+ }
230
277
  const explicitPath = overridePath ? resolve(overridePath) : undefined;
231
278
  const candidates = [
232
- [getLegacyPiMcpGlobalConfigPath(), getPiGlobalConfigPath()],
233
- [getLegacyProjectPiMcpConfigPath(cwd), getProjectPiConfigPath(cwd)],
279
+ [getPiMcpGlobalConfigPath(), getPiGlobalConfigPath()],
280
+ [getProjectPiMcpConfigPath(cwd), getProjectPiConfigPath(cwd)],
234
281
  ] as const;
235
282
  return candidates.flatMap(([source, target]) => {
236
283
  // An explicitly selected config is loaded verbatim, whatever its name.
237
- if (resolve(source) === explicitPath || !legacyMcpConfigHasContent(source, piOwnsServers)) return [];
238
- if (piOwnsServers) {
239
- return [`${source} contains pi-mcp-adapter settings that neither Pi nor the adapter reads. Move settings, imports, and claudePlugins into ${target}, and put any "mcp-servers" entries under its "mcpServers" key.`];
240
- }
284
+ if (resolve(source) === explicitPath || !legacyMcpConfigHasContent(source)) return [];
241
285
  const fix = existsSync(target)
242
286
  ? `Merge ${source} into ${target}, then remove ${source}.`
243
287
  : `Move it with: mv ${JSON.stringify(source)} ${JSON.stringify(target)}`;
@@ -273,7 +317,7 @@ export function findAvailableImportConfigs(cwd = process.cwd()): DiscoveredImpor
273
317
 
274
318
  function getConfigSourceSummaries(sourceSpecs: ConfigSourceSpec[]): ConfigDiscoverySource[] {
275
319
  return sourceSpecs.map((source) => {
276
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
320
+ const loaded = readSourceConfig(source.id, source.readPath);
277
321
  return {
278
322
  id: source.id,
279
323
  label: source.label,
@@ -360,6 +404,7 @@ export function getMcpDiscoverySummary(
360
404
  ...summaryWithoutRepoPrompt,
361
405
  fingerprint,
362
406
  repoPrompt: detectRepoPrompt(summaryWithoutRepoPrompt, cwd),
407
+ knownServerPresets: KNOWN_SERVER_PRESETS.filter(({ desktopApp }) => !desktopApp || desktopApp.paths.some((path) => existsSync(path))),
363
408
  };
364
409
  }
365
410
 
@@ -422,9 +467,15 @@ export function loadMcpConfigWithSources(overridePath?: string, cwd = process.cw
422
467
  if (source.scope === "project") projectServers.set(name, { path: source.path });
423
468
  }
424
469
 
470
+ const piProjectSource = sourceSpecs.find((source) => source.id === "pi-mcp-project");
471
+ const piProjectNames = Object.keys((piProjectSource && readPiMcpConfig(piProjectSource.readPath))?.mcpServers ?? {});
425
472
  for (const source of sourceSpecs) {
426
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
473
+ const loaded = readSourceConfig(source.id, source.readPath);
427
474
  if (!loaded) continue;
475
+ // Pi replaces a global entry that the project file redefines; mergeServerMaps would merge fields.
476
+ if (source.id === "pi-mcp-global") {
477
+ for (const name of piProjectNames) delete loaded.mcpServers[name];
478
+ }
428
479
  const expandedImport = expandImports(loaded, cwd);
429
480
  const expanded = expandedImport.config;
430
481
  const sourceRef = { path: source.readPath };
@@ -452,7 +503,7 @@ export function loadMcpConfigWithSources(overridePath?: string, cwd = process.cw
452
503
  }
453
504
 
454
505
  if (isExclusiveConfigMode()) {
455
- return { config: resolveConfiguredClaudePluginMcp(config, cwd), projectServers, projectServerPolicy };
506
+ return { config: stripProjectProviderAuth(resolveConfiguredClaudePluginMcp(config, cwd), projectServers), projectServers, projectServerPolicy };
456
507
  }
457
508
 
458
509
  const packageConfig = loadPackageMcpConfigs(cwd);
@@ -477,12 +528,25 @@ export function loadMcpConfigWithSources(overridePath?: string, cwd = process.cw
477
528
  for (const name of claudePluginServers) projectServers.set(name, projectClaudePluginSource);
478
529
  }
479
530
  return {
480
- config: mergedConfig,
531
+ config: stripProjectProviderAuth(mergedConfig, projectServers),
481
532
  projectServers,
482
533
  projectServerPolicy,
483
534
  };
484
535
  }
485
536
 
537
+ /** Pi provider tokens go only to servers from user-global config; Pi forbids `auth` in project files too. */
538
+ function stripProjectProviderAuth(config: McpConfig, projectServers: Map<string, ProjectServerSource>): McpConfig {
539
+ const stripped = [...projectServers.keys()].filter((name) => typeof config.mcpServers[name]?.auth === "object");
540
+ if (stripped.length === 0) return config;
541
+ const mcpServers = { ...config.mcpServers };
542
+ for (const name of stripped) {
543
+ delete mcpServers[name];
544
+ projectServers.delete(name);
545
+ }
546
+ console.warn(`Ignoring MCP servers ${stripped.map((name) => `"${name}"`).join(", ")}: auth.provider is only allowed in user-global config, and project config defines or overrides them`);
547
+ return { ...config, mcpServers };
548
+ }
549
+
486
550
  export function resolveConfiguredClaudePluginMcp(config: McpConfig, cwd = process.cwd()): McpConfig {
487
551
  return mergeClaudePluginMcpDefaults(config.claudePlugins, config, cwd);
488
552
  }
@@ -522,7 +586,7 @@ function applySettingDefaults(config: McpConfig): McpConfig {
522
586
  function getMergedSettings(overridePath?: string, cwd = process.cwd()): McpSettings | undefined {
523
587
  let settings: McpSettings | undefined;
524
588
  for (const source of getConfigSources(overridePath, cwd)) {
525
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
589
+ const loaded = readSourceConfig(source.id, source.readPath);
526
590
  if (loaded?.settings) settings = { ...settings, ...loaded.settings };
527
591
  }
528
592
  return settings;
@@ -576,7 +640,7 @@ function getConfigConflicts(
576
640
  }
577
641
  }
578
642
  for (const source of sourceSpecs) {
579
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
643
+ const loaded = readSourceConfig(source.id, source.readPath);
580
644
  if (!loaded) continue;
581
645
  if (loaded.imports?.length) {
582
646
  for (const importKind of loaded.imports) {
@@ -646,6 +710,20 @@ function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSou
646
710
  });
647
711
  }
648
712
 
713
+ const piMcpGlobalPath = getPiMcpGlobalConfigPath();
714
+ if (piMcpConfigEnabled && piMcpGlobalPath !== userPath && !sources.some((source) => source.readPath === piMcpGlobalPath)) {
715
+ sources.push({
716
+ id: "pi-mcp-global",
717
+ label: "user-global Pi MCP",
718
+ readPath: piMcpGlobalPath,
719
+ writePath: userPath,
720
+ kind: "import",
721
+ importKind: "Pi mcp.json",
722
+ shared: true,
723
+ scope: "global",
724
+ });
725
+ }
726
+
649
727
  sources.push({
650
728
  id: "pi-global",
651
729
  label: "MCP adapter global override",
@@ -658,10 +736,12 @@ function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSou
658
736
 
659
737
  // Compare file identities so symlink aliases cannot reload a global source
660
738
  // at ancestor precedence. Keep original paths for display and writes.
739
+ const projectPiMcpPath = getProjectPiMcpConfigPath(cwd);
661
740
  const reservedPaths = new Set([
662
741
  ...sources.map((source) => getConfigPathIdentity(source.readPath)),
663
742
  getConfigPathIdentity(projectPath),
664
743
  getConfigPathIdentity(projectPiPath),
744
+ ...(piMcpConfigEnabled ? [getConfigPathIdentity(projectPiMcpPath)] : []),
665
745
  ]);
666
746
  // Only user-global files (including an explicit override) may opt in to
667
747
  // ancestor discovery. Project files cannot extend this trust boundary.
@@ -705,6 +785,20 @@ function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSou
705
785
  });
706
786
  }
707
787
 
788
+ // Pi reads `.pi/mcp.json` only in the cwd, so it is not part of ancestor discovery.
789
+ if (piMcpConfigEnabled && projectPiMcpPath !== userPath && projectPiMcpPath !== piMcpGlobalPath) {
790
+ sources.push({
791
+ id: "pi-mcp-project",
792
+ label: "project Pi MCP",
793
+ readPath: projectPiMcpPath,
794
+ writePath: projectPiPath,
795
+ kind: "import",
796
+ importKind: "Pi project mcp.json",
797
+ shared: true,
798
+ scope: "project",
799
+ });
800
+ }
801
+
708
802
  if (projectPiPath !== userPath && projectPiPath !== projectPath) {
709
803
  sources.push({
710
804
  id: "pi-project",
@@ -737,7 +831,7 @@ function isWithin(base: string, target: string): boolean {
737
831
  function getConfiguredAncestorRoot(globalSources: ConfigSourceSpec[], cwd: string): string | undefined {
738
832
  let configured: unknown;
739
833
  for (const source of globalSources) {
740
- const roots = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`)?.settings?.ancestorConfigRoots;
834
+ const roots = readSourceConfig(source.id, source.readPath)?.settings?.ancestorConfigRoots;
741
835
  if (roots !== undefined) configured = roots;
742
836
  }
743
837
  if (configured === undefined || (Array.isArray(configured) && configured.length === 0)) return undefined;
@@ -853,6 +947,8 @@ function mergeServerMaps(
853
947
  for (const field of URL_BOUND_AUTH_FIELDS) {
854
948
  delete baseEntry[field];
855
949
  }
950
+ // A provider token is bound to the url that asked for it, like the fields above.
951
+ if (typeof baseEntry.auth === "object") delete baseEntry.auth;
856
952
  if (baseEntry.oauth !== false) {
857
953
  delete baseEntry.oauth;
858
954
  }
@@ -1031,6 +1127,159 @@ function readValidatedConfig(path: string, label: string): McpConfig | null {
1031
1127
  }
1032
1128
  }
1033
1129
 
1130
+ function isPiMcpSource(id: ConfigSourceSpec["id"]): boolean {
1131
+ return id === "pi-mcp-global" || id === "pi-mcp-project";
1132
+ }
1133
+
1134
+ function readSourceConfig(id: ConfigSourceSpec["id"], path: string): (McpConfig & { ignoredSettings?: Map<string, string[]> }) | null {
1135
+ return isPiMcpSource(id) ? readPiMcpConfig(path) : readValidatedConfig(path, `MCP config from ${path}`);
1136
+ }
1137
+
1138
+ interface PiMcpConfigFile {
1139
+ mcpServers: Record<string, ServerEntry>;
1140
+ /** Top-level keys of old adapter configs, which only mcp-adapter.json reads. */
1141
+ adapterKeys: string[];
1142
+ skipped: string[];
1143
+ ignoredSettings: Map<string, string[]>;
1144
+ }
1145
+
1146
+ const PI_MCP_EXPOSURES = ["codemode", "codemode-deferred", "deferred", "direct", "hidden"];
1147
+
1148
+ /** Pi's top-level `autoEnableCodemode` has no adapter equivalent and is ignored. */
1149
+ function readPiMcpConfig(path: string): PiMcpConfigFile | null {
1150
+ if (!existsSync(path)) return null;
1151
+ let raw: Record<string, unknown>;
1152
+ try {
1153
+ const text = readFileSync(path, "utf-8");
1154
+ if (stripJsonComments(text, { trailingCommas: true }).trim() === "") return null;
1155
+ const parsed = parseJsonWithComments(text);
1156
+ if (!isRecord(parsed) || (parsed.mcpServers !== undefined && !isRecord(parsed.mcpServers))) {
1157
+ throw new Error('expected an object with an "mcpServers" object');
1158
+ }
1159
+ raw = parsed;
1160
+ } catch (error) {
1161
+ console.warn(`Failed to load Pi MCP config from ${path}:`, error);
1162
+ return null;
1163
+ }
1164
+ const file: PiMcpConfigFile = {
1165
+ mcpServers: {},
1166
+ adapterKeys: ["settings", "imports", "claudePlugins", "mcp-servers"].filter((key) => raw[key] !== undefined),
1167
+ skipped: [],
1168
+ ignoredSettings: new Map(),
1169
+ };
1170
+ for (const [name, value] of Object.entries((raw.mcpServers ?? {}) as Record<string, unknown>)) {
1171
+ const translated = translatePiMcpServer(name, value);
1172
+ if (typeof translated === "string") {
1173
+ file.skipped.push(`"${name}" (${translated})`);
1174
+ continue;
1175
+ }
1176
+ file.mcpServers[name] = translated.entry;
1177
+ if (translated.ignored.length > 0) file.ignoredSettings.set(name, translated.ignored);
1178
+ }
1179
+ return file;
1180
+ }
1181
+
1182
+ /** Returns why the entry is skipped when Pi would reject it or the adapter can't run it. */
1183
+ export function translatePiMcpServer(name: string, value: unknown): { entry: ServerEntry; ignored: string[] } | string {
1184
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) return 'invalid server name (use letters, digits, "_" and "-")';
1185
+ if (!isRecord(value)) return "must be an object";
1186
+ const { type, command, args, env, cwd, url, headers, oauth, exposure, toolExposure, enabled, timeout, auth, description, ...unknown } = value;
1187
+ const isExposure = (candidate: unknown) => typeof candidate === "string" && PI_MCP_EXPOSURES.includes(candidate);
1188
+ if (exposure !== undefined && !isExposure(exposure)) return `exposure must be one of ${PI_MCP_EXPOSURES.join(", ")}`;
1189
+ if (toolExposure !== undefined && (!isRecord(toolExposure) || !Object.values(toolExposure).every(isExposure))) {
1190
+ return `toolExposure must map tool names to one of ${PI_MCP_EXPOSURES.join(", ")}`;
1191
+ }
1192
+ if (enabled !== undefined && typeof enabled !== "boolean") return "enabled must be a boolean";
1193
+ if (timeout !== undefined && (typeof timeout !== "number" || !(timeout > 0))) return "timeout must be a positive number of seconds";
1194
+ if (description !== undefined && typeof description !== "string") return "description must be a string";
1195
+ if (type === "sse") return "legacy SSE transport is not supported; use the streamable HTTP URL";
1196
+ const ignored = Object.keys(unknown);
1197
+
1198
+ let entry: ServerEntry;
1199
+ let otherTransportKeys: string[];
1200
+ if (typeof url === "string" && (type === undefined || type === "http" || type === "streamable-http")) {
1201
+ if (!URL.canParse(url) || !/^https?:$/.test(new URL(url).protocol)) return "url must be an http or https URL";
1202
+ if (headers !== undefined && !isStringRecord(headers)) return "headers must map names to strings";
1203
+ const translatedOAuth = oauth === undefined ? undefined : translatePiOAuth(oauth);
1204
+ if (typeof translatedOAuth === "string") return translatedOAuth;
1205
+ entry = { url, ...(headers !== undefined ? { headers } : {}), ...(translatedOAuth ? { oauth: translatedOAuth.oauth } : {}) };
1206
+ if (auth !== undefined) {
1207
+ if (!isRecord(auth) || typeof auth.provider !== "string" || !auth.provider) return "auth.provider must be a provider name";
1208
+ const urlError = providerAuthUrlError(url);
1209
+ if (urlError) return urlError;
1210
+ entry.auth = { provider: auth.provider };
1211
+ }
1212
+ ignored.push(...translatedOAuth?.ignored ?? []);
1213
+ otherTransportKeys = ["command", "args", "env", "cwd"];
1214
+ } else if (typeof command === "string" && (type === undefined || type === "stdio")) {
1215
+ if (args !== undefined && !(Array.isArray(args) && args.every((arg) => typeof arg === "string"))) return "args must be an array of strings";
1216
+ if (env !== undefined && !isStringRecord(env)) return "env must map names to strings";
1217
+ if (cwd !== undefined && typeof cwd !== "string") return "cwd must be a string";
1218
+ entry = {
1219
+ command,
1220
+ ...(args !== undefined ? { args: args as string[] } : {}),
1221
+ ...(env !== undefined ? { env } : {}),
1222
+ ...(cwd !== undefined ? { cwd } : {}),
1223
+ };
1224
+ otherTransportKeys = ["url", "headers", "oauth", "auth"];
1225
+ } else {
1226
+ return 'needs either "command" (stdio) or "url" (streamable HTTP)';
1227
+ }
1228
+ ignored.push(...otherTransportKeys.filter((key) => value[key] !== undefined));
1229
+ if (description !== undefined) entry.description = description;
1230
+
1231
+ // `codemode-deferred` is an alias of `codemode` since Pi 0.99.2; both are proxy-only here.
1232
+ const serverExposure = exposure === undefined || exposure === "codemode-deferred" ? "codemode" : exposure;
1233
+ if (enabled === false || serverExposure === "hidden") entry.disabled = true;
1234
+ if (serverExposure === "direct") entry.directTools = true;
1235
+ if (serverExposure === "deferred") entry.directTools = "search";
1236
+ if (timeout !== undefined) entry.requestTimeoutMs = Math.round(timeout * 1000);
1237
+
1238
+ // excludeTools matches a superset of Pi's hidden entries (prefixed names, aliases, `?` as a
1239
+ // wildcard), so it never exposes a tool Pi hides.
1240
+ const directTools: string[] = [];
1241
+ const excludeTools: string[] = [];
1242
+ for (const [tool, rawValue] of Object.entries(toolExposure ?? {})) {
1243
+ const toolValue = rawValue === "codemode-deferred" ? "codemode" : rawValue;
1244
+ if (toolValue === serverExposure) continue;
1245
+ if (toolValue === "hidden") excludeTools.push(tool);
1246
+ else if (toolValue === "direct" && !tool.includes("*") && serverExposure === "codemode") directTools.push(tool);
1247
+ else ignored.push(`toolExposure ${JSON.stringify(tool)}: ${toolValue}`);
1248
+ }
1249
+ if (directTools.length > 0) entry.directTools = directTools;
1250
+ if (excludeTools.length > 0) entry.excludeTools = excludeTools;
1251
+ return { entry, ignored };
1252
+ }
1253
+
1254
+ function translatePiOAuth(oauth: unknown): { oauth: OAuthConfig; ignored: string[] } | string {
1255
+ if (!isRecord(oauth)) return "oauth must be an object";
1256
+ const { clientId, clientSecret, scope, clientName, callbackPort, callbackUrl, ...unknown } = oauth;
1257
+ const copied = { clientId, clientSecret, scope, clientName };
1258
+ for (const [key, field] of Object.entries(copied)) {
1259
+ if (field !== undefined && (typeof field !== "string" || (key === "clientName" && !field.trim()))) {
1260
+ return `oauth.${key} must be a ${key === "clientName" ? "non-empty " : ""}string`;
1261
+ }
1262
+ }
1263
+ if (callbackPort !== undefined && (typeof callbackPort !== "number" || !Number.isInteger(callbackPort) || callbackPort < 1 || callbackPort > 65535)) {
1264
+ return "oauth.callbackPort must be a port number";
1265
+ }
1266
+ const result = Object.fromEntries(Object.entries(copied).filter(([, field]) => field !== undefined)) as OAuthConfig;
1267
+ if (callbackUrl !== undefined) {
1268
+ const parsed = typeof callbackUrl === "string" && URL.canParse(callbackUrl) ? new URL(callbackUrl) : undefined;
1269
+ if (!parsed || parsed.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname) || parsed.search !== "" || parsed.hash !== "") {
1270
+ return "oauth.callbackUrl must be an http URI on localhost, 127.0.0.1, or [::1] without query or fragment";
1271
+ }
1272
+ if (parsed.port && callbackPort !== undefined && Number(parsed.port) !== callbackPort) {
1273
+ return "oauth.callbackUrl and oauth.callbackPort name different ports";
1274
+ }
1275
+ // Pi sends a URI with a port as written; without one it adds callbackPort or a free port.
1276
+ result.redirectUri = parsed.port ? callbackUrl as string : `http://${parsed.hostname}:${callbackPort ?? "{port}"}${parsed.pathname}`;
1277
+ } else if (callbackPort !== undefined) {
1278
+ result.redirectUri = `http://127.0.0.1:${callbackPort}/callback`;
1279
+ }
1280
+ return { oauth: result, ignored: Object.keys(unknown).map((key) => `oauth.${key}`) };
1281
+ }
1282
+
1034
1283
  function validateConfig(raw: unknown): McpConfig {
1035
1284
  if (!isRecord(raw)) {
1036
1285
  return { mcpServers: {} };
@@ -1087,7 +1336,27 @@ function toServerEntries(servers: unknown): Record<string, ServerEntry> {
1087
1336
  if (!isRecord(servers)) return {};
1088
1337
  const entries: Record<string, ServerEntry> = {};
1089
1338
  for (const [name, entry] of Object.entries(servers)) {
1090
- if (isServerEntry(entry)) entries[name] = entry;
1339
+ if (!isServerEntry(entry)) continue;
1340
+ const auth: unknown = entry.auth;
1341
+ if (typeof auth === "object") {
1342
+ const provider = isRecord(auth) ? auth.provider : undefined;
1343
+ const error = typeof provider !== "string" || !provider ? "auth.provider must be a provider name"
1344
+ : typeof entry.url !== "string" ? "auth.provider requires a url"
1345
+ // A URL with env references is checked once resolved, when it connects.
1346
+ : getMissingEnvVars(entry.url, {}).length > 0 ? undefined
1347
+ : providerAuthUrlError(entry.url);
1348
+ if (error) {
1349
+ console.warn(`Ignoring MCP server "${name}": ${error}`);
1350
+ continue;
1351
+ }
1352
+ }
1353
+ if (entry.description !== undefined && typeof entry.description !== "string") {
1354
+ console.warn(`Ignoring invalid description for MCP server "${name}": expected a string`);
1355
+ const { description: _description, ...rest } = entry;
1356
+ entries[name] = rest;
1357
+ continue;
1358
+ }
1359
+ entries[name] = entry;
1091
1360
  }
1092
1361
  return entries;
1093
1362
  }
@@ -1100,6 +1369,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
1100
1369
  return typeof value === "object" && value !== null && !Array.isArray(value);
1101
1370
  }
1102
1371
 
1372
+ function isStringRecord(value: unknown): value is Record<string, string> {
1373
+ return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
1374
+ }
1375
+
1103
1376
  function mergeOpenCodeConfigs(base: Record<string, unknown>, next: Record<string, unknown>): Record<string, unknown> {
1104
1377
  const baseMcp = base.mcp;
1105
1378
  const nextMcp = next.mcp;
@@ -1268,7 +1541,7 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
1268
1541
  mappedServers[name] = mapped as ServerEntry;
1269
1542
  }
1270
1543
 
1271
- return mappedServers;
1544
+ return toServerEntries(mappedServers);
1272
1545
  }
1273
1546
 
1274
1547
  function serializeRawConfig(raw: Record<string, unknown>): string {
@@ -1468,7 +1741,7 @@ export function writeProjectServerDisabledOverride(
1468
1741
  let lowerConfig: McpConfig = { mcpServers: {} };
1469
1742
  for (const source of getConfigSources(overridePath, cwd)) {
1470
1743
  if (source.readPath === filePath) continue;
1471
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
1744
+ const loaded = readSourceConfig(source.id, source.readPath);
1472
1745
  if (loaded) lowerConfig = mergeConfigs(lowerConfig, expandImports(loaded, cwd).config);
1473
1746
  }
1474
1747
  if (raw.imports !== undefined) {
@@ -1531,10 +1804,10 @@ function buildRepoPromptEntry(executablePath: string): ServerEntry {
1531
1804
  };
1532
1805
  }
1533
1806
 
1534
- function detectRepoPrompt(summary: Omit<McpDiscoverySummary, "fingerprint" | "repoPrompt">, cwd = process.cwd()): RepoPromptDiscovery {
1807
+ function detectRepoPrompt(summary: Omit<McpDiscoverySummary, "fingerprint" | "repoPrompt" | "knownServerPresets">, cwd = process.cwd()): RepoPromptDiscovery {
1535
1808
  for (const source of summary.sources) {
1536
1809
  if (source.kind !== "shared" || source.serverCount === 0) continue;
1537
- const config = readValidatedConfig(source.path, `MCP config from ${source.path}`);
1810
+ const config = readSourceConfig(source.id, source.path);
1538
1811
  if (!config) continue;
1539
1812
  for (const [name, entry] of Object.entries(config.mcpServers)) {
1540
1813
  if (isRepoPromptServer(name, entry)) {
@@ -1654,9 +1927,8 @@ export function getServerProvenance(overridePath?: string, cwd = process.cwd()):
1654
1927
  }
1655
1928
 
1656
1929
  for (const source of getConfigSources(overridePath, cwd)) {
1657
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
1930
+ const loaded = readSourceConfig(source.id, source.readPath);
1658
1931
  if (!loaded) continue;
1659
-
1660
1932
  if (loaded.imports?.length) {
1661
1933
  for (const importKind of loaded.imports) {
1662
1934
  const imported = loadImportedConfig(importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:`);
@@ -1672,10 +1944,13 @@ export function getServerProvenance(overridePath?: string, cwd = process.cwd()):
1672
1944
  }
1673
1945
 
1674
1946
  for (const name of Object.keys(loaded.mcpServers)) {
1947
+ // A Pi entry brings its own list; other sources keep the one from the Pi entry below them.
1948
+ const ignoredSettings = loaded.ignoredSettings ? loaded.ignoredSettings.get(name) : provenance.get(name)?.ignoredSettings;
1675
1949
  provenance.set(name, {
1676
1950
  path: source.writePath,
1677
1951
  kind: source.kind,
1678
1952
  ...(source.importKind !== undefined ? { importKind: source.importKind } : {}),
1953
+ ...(ignoredSettings !== undefined ? { ignoredSettings } : {}),
1679
1954
  });
1680
1955
  }
1681
1956
  }
@@ -1,5 +1,5 @@
1
1
  import { Check, Errors } from "typebox/value";
2
- import type { DirectToolSpec, McpConfig, ToolPrefix } from "./types.ts";
2
+ import type { DirectToolSpec, McpConfig, ToolPrefix, ToolSelectorCandidateIndex } from "./types.ts";
3
3
  import { createToolSelectorCandidateIndex, formatToolName, getToolNameCandidates, isServerDisabled, isToolAllowed, resolveToolPrefix, resolveUniqueNameOwnership } from "./types.ts";
4
4
  import type { MetadataCache } from "./metadata-cache.ts";
5
5
  import { isServerCacheValid, parseDirectToolSelectors } from "./metadata-cache.ts";
@@ -14,7 +14,7 @@ export function getLargeDirectToolsAdvisory(config: McpConfig, specs: readonly D
14
14
  if (config.settings?.warnOnLargeDirectTools === false) return undefined;
15
15
  const eagerCount = specs.filter((spec) => !spec.lazy).length;
16
16
  if (eagerCount < DIRECT_TOOLS_ADVISORY_THRESHOLD) return undefined;
17
- return `MCP: ${eagerCount} direct tools resolved. Each direct tool adds prompt context; README guidance recommends targeted sets of 5-20 tools and using the proxy or an explicit string[] when 75+ direct tools would be registered. Set settings.warnOnLargeDirectTools to false to hide this advisory.`;
17
+ return `MCP: ${eagerCount} direct tools resolved. Each direct tool adds prompt context; the direct tools guide in docs/tools.md recommends targeted sets of 5-20 tools and using the proxy or an explicit string[] when 75+ direct tools would be registered. Set settings.warnOnLargeDirectTools to false to hide this advisory.`;
18
18
  }
19
19
 
20
20
  /**
@@ -82,6 +82,7 @@ export function resolveDirectTools(
82
82
 
83
83
  const envSelection = envOverride ? parseDirectToolSelectors(envOverride) : null;
84
84
  const globalDirect = config.settings?.directTools;
85
+ let selectorCandidateIndex: ToolSelectorCandidateIndex | undefined;
85
86
 
86
87
  for (const [serverName, definition] of Object.entries(config.mcpServers)) {
87
88
  if (isServerDisabled(definition)) continue;
@@ -115,7 +116,7 @@ export function resolveDirectTools(
115
116
  const hasToolFilters =
116
117
  (Array.isArray(definition.includeTools) && definition.includeTools.length > 0) ||
117
118
  (Array.isArray(definition.excludeTools) && definition.excludeTools.length > 0);
118
- const selectorCandidateIndex = hasToolFilters ? (() => {
119
+ if (hasToolFilters && !selectorCandidateIndex) {
119
120
  const candidates = new Set<string>();
120
121
  for (const [otherServerName, otherDefinition] of Object.entries(config.mcpServers)) {
121
122
  const otherCache = cache.servers[otherServerName];
@@ -132,8 +133,8 @@ export function resolveDirectTools(
132
133
  }
133
134
  }
134
135
  }
135
- return createToolSelectorCandidateIndex(candidates);
136
- })() : undefined;
136
+ selectorCandidateIndex = createToolSelectorCandidateIndex(candidates);
137
+ }
137
138
 
138
139
  for (const tool of serverCache.tools ?? []) {
139
140
  if (!isUiToolVisibleToModel(tool.uiVisibility)) continue;
@@ -199,8 +200,9 @@ export function resolveDirectTools(
199
200
  * Live counts/status belong to `mcp({ })`, full instructions to
200
201
  * `mcp({ instructions })`.
201
202
  */
202
- export function buildProxyDescription(config: McpConfig): string {
203
- let desc = `MCP gateway — URL installation, server status, tool search/describe, auth, and single MCP tool calls. When a user supplies an MCP endpoint URL, install it with the install action. When one request needs several MCP calls with logic between them, use mcpScript. Non-MCP Pi tools should be called directly, not through mcp.\n`;
203
+ export function buildProxyDescription(config: McpConfig, scriptTool = false): string {
204
+ const scriptHint = scriptTool ? " Use mcpScript for several MCP calls with logic between them." : "";
205
+ let desc = `MCP gateway — URL installation, server status, tool search/describe, auth, and single MCP tool calls. When a user supplies an MCP endpoint URL, install it with the install action.${scriptHint} Non-MCP Pi tools should be called directly, not through mcp.\n`;
204
206
 
205
207
  const serverNames = Object.keys(config.mcpServers)
206
208
  .filter((serverName) => !isServerDisabled(config.mcpServers[serverName]));