@fitchmultz/pi-mcp-adapter 6.0.0 → 7.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 (105) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +12 -8
  3. package/dist/app-bridge.bundle.js +14 -14
  4. package/dist/config.js +41 -16
  5. package/dist/gateway-arguments.d.ts +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/lifecycle.d.ts +2 -2
  8. package/dist/lifecycle.js +2 -2
  9. package/dist/mcp-auth-flow.js +2 -2
  10. package/dist/mcp-auth.d.ts +1 -1
  11. package/dist/mcp-auth.js +3 -3
  12. package/dist/mcp-code.d.ts +6 -6
  13. package/dist/mcp-code.js +11 -10
  14. package/dist/mcp-oauth-provider.d.ts +5 -5
  15. package/dist/mcp-oauth-provider.js +5 -5
  16. package/dist/mcp-output-guard.d.ts +1 -1
  17. package/dist/mcp-panel.d.ts +2 -2
  18. package/dist/mcp-panel.js +2 -2
  19. package/dist/mcp-setup-panel.d.ts +5 -4
  20. package/dist/mcp-setup-panel.js +15 -7
  21. package/dist/mcp-trace.d.ts +1 -1
  22. package/dist/mcp-trace.js +1 -1
  23. package/dist/npx-resolver.js +8 -4
  24. package/dist/proxy-modes.d.ts +2 -2
  25. package/dist/proxy-modes.js +9 -9
  26. package/dist/sampling-handler.js +1 -0
  27. package/dist/search-ranking.js +6 -2
  28. package/dist/server-manager.d.ts +2 -2
  29. package/dist/server-manager.js +44 -27
  30. package/dist/session-recovery.d.ts +7 -4
  31. package/dist/session-recovery.js +76 -10
  32. package/dist/tool-metadata.d.ts +43 -43
  33. package/dist/tool-result-renderer.d.ts +3 -3
  34. package/dist/tool-result-renderer.js +3 -3
  35. package/dist/ui-resource-handler.d.ts +2 -2
  36. package/dist/ui-resource-handler.js +2 -2
  37. package/dist/ui-server.js +4 -3
  38. package/dist/ui-stream-types.d.ts +8 -8
  39. package/dist/unix-socket-transport.d.ts +1 -1
  40. package/dist/unix-socket-transport.js +1 -1
  41. package/dist/utils.d.ts +1 -0
  42. package/dist/utils.js +14 -0
  43. package/package.json +34 -98
  44. package/abort.ts +0 -34
  45. package/agent-dir.ts +0 -24
  46. package/app-bridge.bundle.js +0 -109
  47. package/banner.png +0 -0
  48. package/checkpoint.ts +0 -55
  49. package/commands.ts +0 -622
  50. package/config.ts +0 -1204
  51. package/consent-manager.ts +0 -25
  52. package/direct-tools.ts +0 -75
  53. package/elicitation-handler.ts +0 -349
  54. package/error-signal.ts +0 -21
  55. package/gateway-arguments.ts +0 -73
  56. package/glimpse-ui.ts +0 -80
  57. package/host-html-template.ts +0 -456
  58. package/index.ts +0 -767
  59. package/init.ts +0 -613
  60. package/legacy-migration.ts +0 -47
  61. package/lifecycle.ts +0 -165
  62. package/logger.ts +0 -127
  63. package/mcp-auth-flow.ts +0 -975
  64. package/mcp-auth.ts +0 -793
  65. package/mcp-callback-server.ts +0 -450
  66. package/mcp-code.ts +0 -412
  67. package/mcp-keyring-helper.cjs +0 -89
  68. package/mcp-oauth-provider.ts +0 -774
  69. package/mcp-output-guard.ts +0 -530
  70. package/mcp-panel.ts +0 -907
  71. package/mcp-script-worker.mjs +0 -131
  72. package/mcp-setup-panel.ts +0 -666
  73. package/mcp-status.ts +0 -110
  74. package/mcp-trace.ts +0 -295
  75. package/metadata-cache.ts +0 -271
  76. package/npx-resolver.ts +0 -488
  77. package/onboarding-state.ts +0 -58
  78. package/panel-keys.ts +0 -37
  79. package/prompts.ts +0 -337
  80. package/proxy-modes.ts +0 -1109
  81. package/resource-tools.ts +0 -52
  82. package/runtime-owner.ts +0 -123
  83. package/sampling-handler.ts +0 -276
  84. package/scripts/build-app-bridge.mjs +0 -15
  85. package/scripts/build.mjs +0 -132
  86. package/scripts/prepare.mjs +0 -103
  87. package/search-ranking.ts +0 -132
  88. package/server-manager.ts +0 -1320
  89. package/session-recovery.ts +0 -316
  90. package/state.ts +0 -64
  91. package/tool-approval.ts +0 -78
  92. package/tool-loader.ts +0 -153
  93. package/tool-metadata.ts +0 -240
  94. package/tool-registrar.ts +0 -74
  95. package/tool-result-renderer.ts +0 -294
  96. package/tsconfig.build.json +0 -13
  97. package/tsconfig.json +0 -16
  98. package/types.ts +0 -785
  99. package/ui-resource-handler.ts +0 -249
  100. package/ui-server.ts +0 -808
  101. package/ui-session.ts +0 -568
  102. package/ui-stream-types.ts +0 -89
  103. package/ui-tool-visibility.ts +0 -25
  104. package/unix-socket-transport.ts +0 -85
  105. package/utils.ts +0 -307
package/config.ts DELETED
@@ -1,1204 +0,0 @@
1
- // config.ts - Config loading with import support
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync, renameSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { parse as parseToml } from "smol-toml";
6
- import stripJsonComments from "strip-json-comments";
7
- import { getAdapterPath } from "./agent-dir.ts";
8
- import { isServerDisabled, validateServerProtocolConfig, type HostConfigDiscovery, type McpConfig, type ServerEntry, type McpSettings, type ImportKind, type ServerProvenance } from "./types.ts";
9
- import { toStringRecord } from "./utils.ts";
10
-
11
- const GENERIC_GLOBAL_CONFIG_PATH = join(homedir(), ".config", "mcp", "mcp.json");
12
- const AGENTS_GLOBAL_CONFIG_PATHS = [
13
- join(homedir(), ".agents", "mcp.json"),
14
- join(homedir(), ".agents", "mcp", "mcp.json"),
15
- ] as const;
16
- const PROJECT_CONFIG_NAME = ".mcp.json";
17
- const PROJECT_PI_CONFIG_NAME = ".pi/fitch-mcp-adapter/mcp.json";
18
- const REPOPROMPT_BINARY_CANDIDATES = [
19
- join(homedir(), "RepoPrompt", "repoprompt_cli"),
20
- "/Applications/Repo Prompt.app/Contents/MacOS/repoprompt-mcp",
21
- ];
22
-
23
- export interface KnownServerPreset {
24
- id: string;
25
- name: string;
26
- summary: string;
27
- entry: ServerEntry;
28
- }
29
-
30
- export const KNOWN_SERVER_PRESETS: readonly KnownServerPreset[] = [
31
- {
32
- id: "deepwiki",
33
- name: "DeepWiki",
34
- summary: "Ask questions about public GitHub repositories.",
35
- entry: { url: "https://mcp.deepwiki.com/mcp" },
36
- },
37
- {
38
- id: "context7",
39
- name: "Context7",
40
- summary: "Look up current library documentation and examples.",
41
- entry: { url: "https://mcp.context7.com/mcp" },
42
- },
43
- {
44
- id: "notion",
45
- name: "Notion",
46
- summary: "Search and work with your Notion workspace.",
47
- entry: { url: "https://mcp.notion.com/mcp", auth: "oauth" },
48
- },
49
- {
50
- id: "github",
51
- name: "GitHub",
52
- summary: "Work with GitHub through your Copilot account.",
53
- entry: { url: "https://api.githubcopilot.com/mcp", auth: "oauth" },
54
- },
55
- {
56
- id: "chrome-devtools",
57
- name: "Chrome DevTools",
58
- summary: "Inspect and automate a local Chrome browser.",
59
- entry: { command: "npx", args: ["-y", "chrome-devtools-mcp@latest"] },
60
- },
61
- ];
62
-
63
- const IMPORT_PATHS: Record<ImportKind, string[]> = {
64
- cursor: [join(homedir(), ".cursor", "mcp.json")],
65
- "claude-code": [
66
- join(homedir(), ".claude", "mcp.json"),
67
- join(homedir(), ".claude.json"),
68
- join(homedir(), ".claude", "claude_desktop_config.json"),
69
- ],
70
- "claude-desktop": [join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json")],
71
- codex: [
72
- join(homedir(), ".codex", "config.toml"),
73
- join(homedir(), ".codex", "config.json"),
74
- ],
75
- opencode: [
76
- join(homedir(), ".config", "opencode", "opencode.json"),
77
- "./opencode.json",
78
- ],
79
- windsurf: [join(homedir(), ".windsurf", "mcp.json")],
80
- vscode: [".vscode/mcp.json"],
81
- };
82
-
83
- interface ConfigSourceSpec {
84
- id: "shared-global" | "agents-global" | "agents-nested-global" | "pi-global" | "shared-project" | "pi-project";
85
- label: string;
86
- readPath: string;
87
- writePath: string;
88
- kind: "user" | "project" | "import";
89
- importKind?: string;
90
- shared: boolean;
91
- scope: "global" | "project";
92
- }
93
-
94
- export interface ConfigDiscoverySource {
95
- id: ConfigSourceSpec["id"];
96
- label: string;
97
- path: string;
98
- exists: boolean;
99
- scope: ConfigSourceSpec["scope"];
100
- kind: "shared" | "pi";
101
- serverCount: number;
102
- }
103
-
104
- export interface ImportConfigSummary {
105
- kind: ImportKind;
106
- path: string;
107
- serverCount: number;
108
- }
109
-
110
- export interface HostConfigSummary extends ImportConfigSummary {
111
- active: boolean;
112
- }
113
-
114
- export interface McpConfigConflict {
115
- serverName: string;
116
- sources: Array<{ kind: "shared" | "pi" | "host"; path: string }>;
117
- winner: { kind: "shared" | "pi" | "host"; path: string };
118
- }
119
-
120
- export interface RepoPromptDiscovery {
121
- configured: boolean;
122
- configuredPath?: string;
123
- executablePath?: string;
124
- targetPath?: string;
125
- serverName?: string;
126
- entry?: ServerEntry;
127
- }
128
-
129
- export interface McpDiscoverySummary {
130
- sources: ConfigDiscoverySource[];
131
- imports: ImportConfigSummary[];
132
- hostConfigs: HostConfigSummary[];
133
- hostConfigDiscovery: HostConfigDiscovery;
134
- conflicts: McpConfigConflict[];
135
- hasAnyConfig: boolean;
136
- hasAnyDetectedPaths: boolean;
137
- hasSharedServers: boolean;
138
- hasPiOwnedServers: boolean;
139
- totalServerCount: number;
140
- repoPrompt: RepoPromptDiscovery;
141
- }
142
-
143
- export interface ConfigWritePreview {
144
- path: string;
145
- existed: boolean;
146
- changed: boolean;
147
- beforeText: string;
148
- afterText: string;
149
- diffText: string;
150
- }
151
-
152
- export function getPiGlobalConfigPath(overridePath?: string): string {
153
- return overridePath ? resolve(overridePath) : getAdapterPath("mcp.json");
154
- }
155
-
156
- export function getProjectConfigPath(cwd = process.cwd()): string {
157
- return resolve(cwd, PROJECT_CONFIG_NAME);
158
- }
159
-
160
- export function getProjectPiConfigPath(cwd = process.cwd()): string {
161
- return resolve(cwd, PROJECT_PI_CONFIG_NAME);
162
- }
163
-
164
- export function getMcpDiscoverySummary(overridePath?: string, cwd = process.cwd()): McpDiscoverySummary {
165
- const sourceSpecs = getConfigSources(overridePath, cwd);
166
- const sources = sourceSpecs.map((source) => {
167
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
168
- return {
169
- id: source.id,
170
- label: source.label,
171
- path: source.readPath,
172
- exists: existsSync(source.readPath),
173
- scope: source.scope,
174
- kind: source.shared ? "shared" : "pi",
175
- serverCount: loaded ? Object.keys(loaded.mcpServers).length : 0,
176
- } satisfies ConfigDiscoverySource;
177
- });
178
-
179
- const imports = (Object.keys(IMPORT_PATHS) as ImportKind[])
180
- .map((kind) => {
181
- const imported = loadImportedConfig(kind, cwd, `Failed to inspect imported MCP config from ${kind}:`);
182
- if (!imported) return null;
183
- return {
184
- kind,
185
- path: imported.path,
186
- serverCount: Object.keys(extractServers(imported.value, kind)).length,
187
- } satisfies ImportConfigSummary;
188
- })
189
- .filter((value): value is ImportConfigSummary => value !== null);
190
- const hostConfigDiscovery = getConfiguredHostConfigDiscovery(overridePath, cwd);
191
- const hostConfigs = imports.map((entry) => ({ ...entry, active: hostConfigDiscovery === "on" }));
192
- const totalServerCount = sources.reduce((sum, source) => sum + source.serverCount, 0);
193
- const hasSharedServers = sources.some((source) => source.kind === "shared" && source.serverCount > 0);
194
- const hasPiOwnedServers = sources.some((source) => source.kind === "pi" && source.serverCount > 0);
195
- const hasAnyDetectedPaths = sources.some((source) => source.exists) || imports.length > 0;
196
- const hasAnyConfig = totalServerCount > 0 || imports.some((entry) => entry.serverCount > 0) || hasAnyDetectedPaths;
197
-
198
- const summaryWithoutRepoPrompt = {
199
- sources,
200
- imports,
201
- hostConfigs,
202
- hostConfigDiscovery,
203
- conflicts: getConfigConflicts(sourceSpecs, imports, cwd),
204
- hasAnyConfig,
205
- hasAnyDetectedPaths,
206
- hasSharedServers,
207
- hasPiOwnedServers,
208
- totalServerCount,
209
- };
210
-
211
- return {
212
- ...summaryWithoutRepoPrompt,
213
- repoPrompt: detectRepoPrompt(summaryWithoutRepoPrompt, cwd),
214
- };
215
- }
216
-
217
- export function cloneMcpConfig(config: McpConfig): McpConfig {
218
- return structuredClone(config);
219
- }
220
-
221
- export interface LoadMcpConfigOptions {
222
- includeProject?: boolean;
223
- }
224
-
225
- export function loadMcpConfig(
226
- overridePath?: string,
227
- cwd = process.cwd(),
228
- options: LoadMcpConfigOptions = {},
229
- ): McpConfig {
230
- const includeProject = options.includeProject !== false;
231
- const sourceSpecs = getConfigSources(overridePath, cwd)
232
- .filter((source) => includeProject || !isProjectConfigSource(source, cwd));
233
- const hostConfigDiscovery = getConfiguredHostConfigDiscovery(overridePath, cwd, includeProject);
234
- // Host files are a lower-precedence fallback. This ordering means an opt-in
235
- // discovery cannot override a shared or Pi-owned definition, and all normal
236
- // URL-bound credential stripping remains in mergeServerMaps.
237
- let config: McpConfig = hostConfigDiscovery === "on"
238
- ? loadDiscoveredHostConfigs(cwd, includeProject)
239
- : { mcpServers: {} };
240
-
241
- for (const source of sourceSpecs) {
242
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
243
- if (!loaded) continue;
244
- config = mergeConfigs(config, expandImports(loaded, cwd, includeProject));
245
- }
246
-
247
- return config;
248
- }
249
-
250
- function getConfiguredHostConfigDiscovery(
251
- overridePath?: string,
252
- cwd = process.cwd(),
253
- includeProject = true,
254
- ): HostConfigDiscovery {
255
- let configured: HostConfigDiscovery = "off";
256
- for (const source of getConfigSources(overridePath, cwd)) {
257
- if (!includeProject && isProjectConfigSource(source, cwd)) continue;
258
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
259
- const value = loaded?.settings?.hostConfigDiscovery;
260
- if (value === "off" || value === "on") configured = value;
261
- }
262
- return configured;
263
- }
264
-
265
- function loadDiscoveredHostConfigs(cwd: string, includeProject: boolean): McpConfig {
266
- let config: McpConfig = { mcpServers: {} };
267
- for (const importKind of Object.keys(IMPORT_PATHS) as ImportKind[]) {
268
- const imported = loadImportedConfig(importKind, cwd, `Failed to discover imported MCP config from ${importKind}:`, includeProject);
269
- if (!imported) continue;
270
- config = mergeConfigs(config, {
271
- mcpServers: extractServers(imported.value, importKind),
272
- });
273
- }
274
- return config;
275
- }
276
-
277
- function getConfigConflicts(
278
- sourceSpecs: ConfigSourceSpec[],
279
- imports: ImportConfigSummary[],
280
- cwd: string,
281
- ): McpConfigConflict[] {
282
- const seen = new Map<string, Array<{ kind: "shared" | "pi" | "host"; path: string }>>();
283
- const record = (name: string, source: { kind: "shared" | "pi" | "host"; path: string }): void => {
284
- const entries = seen.get(name) ?? [];
285
- if (!entries.some((entry) => entry.kind === source.kind && entry.path === source.path)) entries.push(source);
286
- seen.set(name, entries);
287
- };
288
-
289
- // Host candidates are listed first because, when enabled, they are the
290
- // lowest-precedence fallback. The fixed IMPORT_PATHS order is deterministic.
291
- for (const entry of imports) {
292
- const imported = loadImportedConfig(entry.kind, cwd, `Failed to inspect imported MCP config from ${entry.kind}:`);
293
- if (!imported) continue;
294
- for (const name of Object.keys(extractServers(imported.value, entry.kind))) {
295
- record(name, { kind: "host", path: imported.path });
296
- }
297
- }
298
- for (const source of sourceSpecs) {
299
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
300
- if (!loaded) continue;
301
- if (loaded.imports?.length) {
302
- for (const importKind of loaded.imports) {
303
- const imported = loadImportedConfig(importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:`);
304
- if (!imported) continue;
305
- for (const name of Object.keys(extractServers(imported.value, importKind))) {
306
- record(name, { kind: "host", path: imported.path });
307
- }
308
- }
309
- }
310
- for (const name of Object.keys(loaded.mcpServers)) {
311
- record(name, {
312
- kind: source.shared ? "shared" : "pi",
313
- path: source.readPath,
314
- });
315
- }
316
- }
317
-
318
- return [...seen.entries()]
319
- .filter(([, sources]) => sources.length > 1)
320
- .map(([serverName, sources]) => ({ serverName, sources, winner: sources[sources.length - 1]! }))
321
- .sort((left, right) => left.serverName.localeCompare(right.serverName));
322
- }
323
-
324
- function canonicalPath(path: string): string {
325
- const resolvedPath = resolve(path);
326
- let existingPath = resolvedPath;
327
- while (!existsSync(existingPath)) {
328
- const parent = dirname(existingPath);
329
- if (parent === existingPath) return resolvedPath;
330
- existingPath = parent;
331
- }
332
-
333
- try {
334
- return resolve(realpathSync(existingPath), relative(existingPath, resolvedPath));
335
- } catch {
336
- return resolvedPath;
337
- }
338
- }
339
-
340
- export function isPathInsideProject(path: string, cwd: string): boolean {
341
- const projectRoots = findProjectRoots(cwd);
342
- const roots = projectRoots.length > 0
343
- ? projectRoots
344
- : isHomePath(cwd) ? [] : [cwd];
345
- const contains = (root: string, candidate: string): boolean => {
346
- const pathFromRoot = relative(root, candidate);
347
- return pathFromRoot === ""
348
- || (pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`) && !isAbsolute(pathFromRoot));
349
- };
350
- return roots.some((root) =>
351
- contains(resolve(root), resolve(path))
352
- || contains(canonicalPath(root), canonicalPath(path)),
353
- );
354
- }
355
-
356
- function isProjectConfigSource(source: ConfigSourceSpec, cwd: string): boolean {
357
- return source.scope === "project" || isPathInsideProject(source.readPath, cwd);
358
- }
359
-
360
- function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSourceSpec[] {
361
- const userPath = getPiGlobalConfigPath(overridePath);
362
- const projectPath = getProjectConfigPath(cwd);
363
- const projectPiPath = getProjectPiConfigPath(cwd);
364
- const sources: ConfigSourceSpec[] = [];
365
-
366
- if (GENERIC_GLOBAL_CONFIG_PATH !== userPath) {
367
- sources.push({
368
- id: "shared-global",
369
- label: "user-global standard MCP",
370
- readPath: GENERIC_GLOBAL_CONFIG_PATH,
371
- writePath: userPath,
372
- kind: "import",
373
- importKind: "global MCP config",
374
- shared: true,
375
- scope: "global",
376
- });
377
- }
378
-
379
- for (const [index, agentsPath] of AGENTS_GLOBAL_CONFIG_PATHS.entries()) {
380
- if (agentsPath === userPath || agentsPath === GENERIC_GLOBAL_CONFIG_PATH) continue;
381
- sources.push({
382
- id: index === 0 ? "agents-global" : "agents-nested-global",
383
- label: index === 0 ? "user-global .agents MCP" : "user-global .agents nested MCP",
384
- readPath: agentsPath,
385
- writePath: userPath,
386
- kind: "import",
387
- importKind: index === 0 ? ".agents MCP config" : ".agents/mcp MCP config",
388
- shared: true,
389
- scope: "global",
390
- });
391
- }
392
-
393
- sources.push({
394
- id: "pi-global",
395
- label: "Pi global override",
396
- readPath: userPath,
397
- writePath: userPath,
398
- kind: "user",
399
- shared: false,
400
- scope: "global",
401
- });
402
-
403
- if (projectPath !== userPath) {
404
- sources.push({
405
- id: "shared-project",
406
- label: "project standard MCP",
407
- readPath: projectPath,
408
- writePath: projectPath,
409
- kind: "project",
410
- shared: true,
411
- scope: "project",
412
- });
413
- }
414
-
415
- if (projectPiPath !== userPath && projectPiPath !== projectPath) {
416
- sources.push({
417
- id: "pi-project",
418
- label: "project Pi override",
419
- readPath: projectPiPath,
420
- writePath: projectPiPath,
421
- kind: "project",
422
- shared: false,
423
- scope: "project",
424
- });
425
- }
426
-
427
- return sources;
428
- }
429
-
430
- function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig {
431
- const imports = mergeImports(base.imports, next.imports);
432
- const settings = next.settings ? { ...base.settings, ...next.settings } : base.settings;
433
- return {
434
- mcpServers: mergeServerMaps(base.mcpServers, next.mcpServers),
435
- ...(imports !== undefined ? { imports } : {}),
436
- ...(settings !== undefined ? { settings } : {}),
437
- };
438
- }
439
-
440
- // Credential-bearing fields whose value is bound to a specific server `url`.
441
- // When a higher-precedence config source repoints an existing server at a
442
- // different url, these MUST NOT be inherited from the lower-precedence entry —
443
- // otherwise the original endpoint's credentials would be shipped to the new
444
- // url. See the SECURITY note in mergeServerMaps.
445
- const URL_BOUND_AUTH_FIELDS = ["headers", "bearerToken", "bearerTokenEnv"] as const;
446
-
447
- function mergeServerMaps(
448
- base: Record<string, ServerEntry>,
449
- next: Record<string, ServerEntry>,
450
- ): Record<string, ServerEntry> {
451
- const merged = { ...base };
452
- for (const [name, definition] of Object.entries(next)) {
453
- const existing = merged[name];
454
- // SECURITY (credential/url binding): the merge is per-field, so a
455
- // higher-precedence source that supplies only a new `url` for an existing
456
- // server would otherwise retain the lower-precedence entry's auth material
457
- // (Authorization header, bearer token, OAuth config) and send it to the new
458
- // url — a credential-exfiltration vector when the higher-precedence source
459
- // is less trusted than the one that first defined the server. Bind auth to
460
- // the url that supplied it: when the url changes, drop inherited auth
461
- // material before merging. Auth explicitly re-supplied by `definition` still
462
- // applies (it is spread last). Behaviour is unchanged when the url is
463
- // identical or the override omits `url` (partial overrides still inherit).
464
- let baseEntry: ServerEntry = existing ?? {};
465
- if (existing && typeof definition.socket === "string") {
466
- baseEntry = { ...existing };
467
- for (const field of [
468
- "command", "args", "env", "cwd", "url", "headers", "auth",
469
- "bearerToken", "bearerTokenEnv", "oauth",
470
- ] as const) {
471
- delete baseEntry[field];
472
- }
473
- } else if (existing?.socket && (typeof definition.command === "string" || typeof definition.url === "string")) {
474
- baseEntry = { ...existing };
475
- delete baseEntry.socket;
476
- }
477
- if (existing && typeof definition.url === "string" && definition.url !== existing.url) {
478
- if (baseEntry === existing) baseEntry = { ...existing };
479
- for (const field of URL_BOUND_AUTH_FIELDS) {
480
- delete baseEntry[field];
481
- }
482
- if (baseEntry.oauth !== false) {
483
- delete baseEntry.oauth;
484
- }
485
- }
486
- merged[name] = { ...baseEntry, ...definition };
487
- }
488
- return merged;
489
- }
490
-
491
- function mergeImports(left: ImportKind[] | undefined, right: ImportKind[] | undefined): ImportKind[] | undefined {
492
- const merged = [...(left ?? []), ...(right ?? [])];
493
- if (merged.length === 0) return undefined;
494
- return [...new Set(merged)];
495
- }
496
-
497
- function expandImports(config: McpConfig, cwd: string, includeProject: boolean): McpConfig {
498
- if (!config.imports?.length) return config;
499
-
500
- const importedServers: Record<string, ServerEntry> = {};
501
- for (const importKind of config.imports) {
502
- const imported = loadImportedConfig(importKind, cwd, `Failed to import MCP config from ${importKind}:`, includeProject);
503
- if (!imported) continue;
504
-
505
- const servers = extractServers(imported.value, importKind);
506
- for (const [name, definition] of Object.entries(servers)) {
507
- if (!importedServers[name]) {
508
- importedServers[name] = definition;
509
- }
510
- }
511
- }
512
-
513
- return {
514
- imports: config.imports,
515
- ...(config.settings !== undefined ? { settings: config.settings } : {}),
516
- mcpServers: mergeServerMaps(importedServers, config.mcpServers),
517
- };
518
- }
519
-
520
- function resolveImportCandidates(importKind: ImportKind, cwd: string, includeProject: boolean): string[] {
521
- return (IMPORT_PATHS[importKind] ?? [])
522
- .map((candidate) => {
523
- if (importKind === "opencode" && candidate === "./opencode.json") {
524
- const start = resolve(cwd);
525
- let gitRoot: string | undefined;
526
- let current = start;
527
- while (true) {
528
- if (existsSync(join(current, ".git"))) {
529
- gitRoot = current;
530
- break;
531
- }
532
- const parent = dirname(current);
533
- if (parent === current) break;
534
- current = parent;
535
- }
536
-
537
- if (!gitRoot) return join(start, "opencode.json");
538
- current = start;
539
- while (true) {
540
- const projectConfig = join(current, "opencode.json");
541
- if (existsSync(projectConfig) || current === gitRoot) return projectConfig;
542
- current = dirname(current);
543
- }
544
- }
545
- return candidate.startsWith(".") ? resolve(cwd, candidate) : candidate;
546
- })
547
- .filter((candidate) => includeProject || !isPathInsideProject(candidate, cwd));
548
- }
549
-
550
- function parseJsonConfig(raw: string): unknown {
551
- return JSON.parse(stripJsonComments(raw, { trailingCommas: true }));
552
- }
553
-
554
- function readImportedConfig(path: string): unknown {
555
- const raw = readFileSync(path, "utf-8");
556
- return path.endsWith(".toml") ? parseToml(raw) : parseJsonConfig(raw);
557
- }
558
-
559
- function loadImportedConfig(
560
- importKind: ImportKind,
561
- cwd: string,
562
- warningPrefix: string,
563
- includeProject = true,
564
- ): { path: string; value: unknown } | null {
565
- if (importKind === "opencode") {
566
- let merged: Record<string, unknown> = {};
567
- let highestPrecedencePath: string | undefined;
568
-
569
- for (const path of resolveImportCandidates(importKind, cwd, includeProject)) {
570
- if (!existsSync(path)) continue;
571
-
572
- try {
573
- const value = readImportedConfig(path);
574
- if (value && typeof value === "object" && !Array.isArray(value)) {
575
- merged = mergeOpenCodeConfigs(merged, value as Record<string, unknown>);
576
- highestPrecedencePath = path;
577
- }
578
- } catch (error) {
579
- console.warn(warningPrefix, error);
580
- }
581
- }
582
-
583
- return highestPrecedencePath ? { path: highestPrecedencePath, value: merged } : null;
584
- }
585
-
586
- for (const path of resolveImportCandidates(importKind, cwd, includeProject)) {
587
- if (!existsSync(path)) continue;
588
-
589
- try {
590
- return { path, value: readImportedConfig(path) };
591
- } catch (error) {
592
- console.warn(warningPrefix, error);
593
- }
594
- }
595
-
596
- return null;
597
- }
598
-
599
- function readValidatedConfig(path: string, label: string): McpConfig | null {
600
- if (!existsSync(path)) return null;
601
-
602
- try {
603
- return validateConfig(parseJsonConfig(readFileSync(path, "utf-8")));
604
- } catch (error) {
605
- console.warn(`Failed to load ${label}:`, error);
606
- return null;
607
- }
608
- }
609
-
610
- function validateConfig(raw: unknown): McpConfig {
611
- if (!raw || typeof raw !== "object") {
612
- return { mcpServers: {} };
613
- }
614
-
615
- const obj = raw as Record<string, unknown>;
616
- const servers = obj.mcpServers ?? obj["mcp-servers"] ?? {};
617
- const rawSettings = obj.settings as Record<string, unknown> | undefined;
618
- const settings = rawSettings?.hostConfigDiscovery === "prompt"
619
- ? { ...rawSettings, hostConfigDiscovery: "off" as const }
620
- : rawSettings;
621
-
622
- if (typeof servers !== "object" || servers === null || Array.isArray(servers)) {
623
- return { mcpServers: {} };
624
- }
625
-
626
- for (const entry of Object.values(servers)) {
627
- if (entry && typeof entry === "object") validateServerProtocolConfig(entry);
628
- }
629
- return {
630
- mcpServers: servers as Record<string, ServerEntry>,
631
- ...(Array.isArray(obj.imports) ? { imports: obj.imports as ImportKind[] } : {}),
632
- ...(settings !== undefined ? { settings: settings as McpSettings } : {}),
633
- };
634
- }
635
-
636
- function mergeOpenCodeConfigs(base: Record<string, unknown>, next: Record<string, unknown>): Record<string, unknown> {
637
- const baseMcp = base.mcp;
638
- const nextMcp = next.mcp;
639
- const mergedMcp: Record<string, unknown> = {
640
- ...(baseMcp && typeof baseMcp === "object" && !Array.isArray(baseMcp) ? baseMcp : {}),
641
- };
642
-
643
- if (nextMcp && typeof nextMcp === "object" && !Array.isArray(nextMcp)) {
644
- for (const [name, nextEntry] of Object.entries(nextMcp)) {
645
- const baseEntry = mergedMcp[name];
646
- if (
647
- baseEntry && typeof baseEntry === "object" && !Array.isArray(baseEntry)
648
- && nextEntry && typeof nextEntry === "object" && !Array.isArray(nextEntry)
649
- ) {
650
- const safeBase = { ...(baseEntry as Record<string, unknown>) };
651
- const override = nextEntry as Record<string, unknown>;
652
- if (typeof override.type === "string" && override.type !== safeBase.type) {
653
- for (const field of ["command", "environment", "cwd", "url", "headers", "oauth"]) delete safeBase[field];
654
- }
655
- if (typeof override.url === "string" && override.url !== safeBase.url) {
656
- delete safeBase.headers;
657
- delete safeBase.oauth;
658
- }
659
- if (Array.isArray(override.command)) {
660
- const baseCommand = safeBase.command;
661
- const commandChanged = !Array.isArray(baseCommand)
662
- || override.command.length !== baseCommand.length
663
- || override.command.some((value, index) => value !== baseCommand[index]);
664
- if (commandChanged) {
665
- delete safeBase.environment;
666
- delete safeBase.cwd;
667
- }
668
- }
669
-
670
- const mergedEntry = { ...safeBase, ...override };
671
- for (const field of ["environment", "headers", "oauth"]) {
672
- const baseField = safeBase[field];
673
- const nextField = override[field];
674
- if (
675
- baseField && typeof baseField === "object" && !Array.isArray(baseField)
676
- && nextField && typeof nextField === "object" && !Array.isArray(nextField)
677
- ) {
678
- mergedEntry[field] = { ...(baseField as Record<string, unknown>), ...(nextField as Record<string, unknown>) };
679
- }
680
- }
681
- mergedMcp[name] = mergedEntry;
682
- } else {
683
- mergedMcp[name] = nextEntry;
684
- }
685
- }
686
- }
687
-
688
- return { ...base, ...next, mcp: mergedMcp };
689
- }
690
-
691
- function extractServers(config: unknown, kind: ImportKind): Record<string, ServerEntry> {
692
- if (!config || typeof config !== "object") return {};
693
-
694
- const obj = config as Record<string, unknown>;
695
-
696
- let servers: unknown;
697
- switch (kind) {
698
- case "claude-desktop":
699
- case "claude-code":
700
- servers = obj.mcpServers;
701
- break;
702
- case "codex":
703
- servers = obj.mcp_servers ?? obj.mcpServers;
704
- break;
705
- case "cursor":
706
- case "windsurf":
707
- case "vscode":
708
- servers = obj.mcpServers ?? obj["mcp-servers"];
709
- break;
710
- case "opencode":
711
- servers = obj.mcp;
712
- break;
713
- default:
714
- return {};
715
- }
716
-
717
- if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
718
- return {};
719
- }
720
-
721
- const mappedServers: Record<string, ServerEntry> = {};
722
- for (const [name, entry] of Object.entries(servers)) {
723
- if (kind === "opencode") {
724
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
725
- const raw = entry as Record<string, unknown>;
726
- if (raw.enabled === false) continue;
727
-
728
- if (raw.type === "local" && Array.isArray(raw.command) && raw.command.length > 0 && raw.command.every((value): value is string => typeof value === "string")) {
729
- const env = toStringRecord(raw.environment);
730
- const command = raw.command[0];
731
- if (command === undefined) continue;
732
- const mapped: ServerEntry = {
733
- command,
734
- args: raw.command.slice(1),
735
- ...(env ? { env } : {}),
736
- ...(typeof raw.cwd === "string" ? { cwd: raw.cwd } : {}),
737
- };
738
- mappedServers[name] = mapped;
739
- continue;
740
- }
741
-
742
- if (raw.type === "remote" && typeof raw.url === "string") {
743
- const headers = toStringRecord(raw.headers);
744
- const mapped: ServerEntry = {
745
- url: raw.url,
746
- ...(headers ? { headers } : {}),
747
- };
748
- if (raw.oauth === false) {
749
- mapped.oauth = false;
750
- } else if (raw.oauth && typeof raw.oauth === "object" && !Array.isArray(raw.oauth)) {
751
- const oauth = raw.oauth as Record<string, unknown>;
752
- mapped.auth = "oauth";
753
- mapped.oauth = {
754
- ...(typeof oauth.clientId === "string" ? { clientId: oauth.clientId } : {}),
755
- ...(typeof oauth.clientSecret === "string" ? { clientSecret: oauth.clientSecret } : {}),
756
- ...(typeof oauth.scope === "string" ? { scope: oauth.scope } : {}),
757
- };
758
- }
759
- mappedServers[name] = mapped;
760
- }
761
- continue;
762
- }
763
-
764
- if (kind !== "codex" || !entry || typeof entry !== "object" || Array.isArray(entry)) {
765
- mappedServers[name] = entry as ServerEntry;
766
- continue;
767
- }
768
-
769
- const mapped = { ...(entry as Record<string, unknown>) };
770
- const bearerTokenEnv = mapped.bearer_token_env_var;
771
- const httpHeaders = mapped.http_headers;
772
- const envHttpHeaders = mapped.env_http_headers;
773
-
774
- if (typeof bearerTokenEnv === "string") {
775
- mapped.bearerTokenEnv = bearerTokenEnv;
776
- if (mapped.auth === undefined) mapped.auth = "bearer";
777
- }
778
- if (httpHeaders && typeof httpHeaders === "object" && !Array.isArray(httpHeaders)) {
779
- mapped.headers = { ...(mapped.headers as Record<string, string> | undefined), ...(httpHeaders as Record<string, string>) };
780
- }
781
- if (envHttpHeaders && typeof envHttpHeaders === "object" && !Array.isArray(envHttpHeaders)) {
782
- const headers = { ...(mapped.headers as Record<string, string> | undefined) };
783
- for (const [header, envVar] of Object.entries(envHttpHeaders)) {
784
- if (typeof envVar === "string" && headers[header] === undefined) headers[header] = `$env:${envVar}`;
785
- }
786
- mapped.headers = headers;
787
- }
788
-
789
- delete mapped.bearer_token_env_var;
790
- delete mapped.http_headers;
791
- delete mapped.env_http_headers;
792
- mappedServers[name] = mapped as ServerEntry;
793
- }
794
-
795
- return mappedServers;
796
- }
797
-
798
- function serializeRawConfig(raw: Record<string, unknown>): string {
799
- return `${JSON.stringify(raw, null, 2)}\n`;
800
- }
801
-
802
- function buildUnifiedDiff(beforeText: string, afterText: string): string {
803
- if (beforeText === afterText) return "(no changes)";
804
-
805
- const before = beforeText.split("\n");
806
- const after = afterText.split("\n");
807
- const rows = before.length;
808
- const cols = after.length;
809
- const lcs = Array.from({ length: rows + 1 }, () => Array<number>(cols + 1).fill(0));
810
-
811
- for (let i = rows - 1; i >= 0; i--) {
812
- for (let j = cols - 1; j >= 0; j--) {
813
- const row = lcs[i];
814
- const nextRow = lcs[i + 1];
815
- if (!row || !nextRow) continue;
816
- row[j] = before[i] === after[j]
817
- ? (nextRow[j + 1] ?? 0) + 1
818
- : Math.max(nextRow[j] ?? 0, row[j + 1] ?? 0);
819
- }
820
- }
821
-
822
- const lines: string[] = ["--- before", "+++ after"];
823
- let i = 0;
824
- let j = 0;
825
- while (i < rows || j < cols) {
826
- if (i < rows && j < cols && before[i] === after[j]) {
827
- lines.push(` ${before[i]}`);
828
- i++;
829
- j++;
830
- continue;
831
- }
832
- if (j < cols && (i === rows || (lcs[i]?.[j + 1] ?? 0) >= (lcs[i + 1]?.[j] ?? 0))) {
833
- lines.push(`+ ${after[j]}`);
834
- j++;
835
- continue;
836
- }
837
- if (i < rows) {
838
- lines.push(`- ${before[i]}`);
839
- i++;
840
- }
841
- }
842
-
843
- return lines.join("\n");
844
- }
845
-
846
- function buildConfigWritePreview(filePath: string, nextRaw: Record<string, unknown>): ConfigWritePreview {
847
- const existed = existsSync(filePath);
848
- const beforeRaw = readRawConfigObject(filePath);
849
- const beforeText = existed ? serializeRawConfig(beforeRaw) : "";
850
- const afterText = serializeRawConfig(nextRaw);
851
- return {
852
- path: filePath,
853
- existed,
854
- changed: beforeText !== afterText,
855
- beforeText,
856
- afterText,
857
- diffText: buildUnifiedDiff(beforeText, afterText),
858
- };
859
- }
860
-
861
- function readRawConfigObject(filePath: string): Record<string, unknown> {
862
- if (!existsSync(filePath)) return {};
863
-
864
- try {
865
- const raw = parseJsonConfig(readFileSync(filePath, "utf-8"));
866
- return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
867
- } catch {
868
- return {};
869
- }
870
- }
871
-
872
- function writeRawConfigObject(filePath: string, raw: Record<string, unknown>): void {
873
- mkdirSync(dirname(filePath), { recursive: true });
874
- const tmpPath = `${filePath}.${process.pid}.tmp`;
875
- writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
876
- renameSync(tmpPath, filePath);
877
- }
878
-
879
- function getServersObject(raw: Record<string, unknown>): Record<string, ServerEntry> {
880
- const existing = raw.mcpServers ?? raw["mcp-servers"] ?? {};
881
- if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
882
- return {};
883
- }
884
- return existing as Record<string, ServerEntry>;
885
- }
886
-
887
- function setServersObject(raw: Record<string, unknown>, servers: Record<string, ServerEntry>): void {
888
- delete raw["mcp-servers"];
889
- raw.mcpServers = servers;
890
- }
891
-
892
- export interface ServerDisabledOverrideResult {
893
- path: string;
894
- changed: boolean;
895
- }
896
-
897
- /**
898
- * Persist only the disabled field in the project Pi layer. Enabling writes an
899
- * explicit false only when a lower-precedence source is itself disabled; this
900
- * writer never copies a server definition or its credentials into the file.
901
- */
902
- export function writeProjectServerDisabledOverride(
903
- overridePath: string | undefined,
904
- cwd: string,
905
- serverName: string,
906
- disabled: boolean,
907
- ): ServerDisabledOverrideResult {
908
- const filePath = getProjectPiConfigPath(cwd);
909
- let raw: Record<string, unknown> = {};
910
- if (existsSync(filePath)) {
911
- try {
912
- const parsed = parseJsonConfig(readFileSync(filePath, "utf-8"));
913
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
914
- throw new Error("root value must be an object");
915
- }
916
- raw = parsed as Record<string, unknown>;
917
- } catch (error) {
918
- throw new Error(`Failed to read project MCP override at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
919
- }
920
- }
921
-
922
- const serverKey = raw.mcpServers !== undefined ? "mcpServers" : raw["mcp-servers"] !== undefined ? "mcp-servers" : "mcpServers";
923
- const rawServers = raw[serverKey];
924
- if (rawServers !== undefined && (!rawServers || typeof rawServers !== "object" || Array.isArray(rawServers))) {
925
- throw new Error(`Failed to update project MCP override at ${filePath}: ${serverKey} must be an object`);
926
- }
927
- const servers = (rawServers ?? {}) as Record<string, unknown>;
928
- const previous = servers[serverName];
929
- if (previous !== undefined && (!previous || typeof previous !== "object" || Array.isArray(previous))) {
930
- throw new Error(`Failed to update project MCP override at ${filePath}: server "${serverName}" must be an object`);
931
- }
932
- const existing = previous as Record<string, unknown> | undefined;
933
-
934
- let next: Record<string, unknown>;
935
- if (disabled) {
936
- next = { ...existing, disabled: true };
937
- } else {
938
- next = Object.fromEntries(Object.entries(existing ?? {}).filter(([key]) => key !== "disabled"));
939
- let lowerConfig: McpConfig = { mcpServers: {} };
940
- for (const source of getConfigSources(overridePath, cwd)) {
941
- if (source.readPath === filePath) continue;
942
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
943
- if (loaded) lowerConfig = mergeConfigs(lowerConfig, expandImports(loaded, cwd, true));
944
- }
945
- if (raw.imports !== undefined) {
946
- if (!Array.isArray(raw.imports) || raw.imports.some((kind) => typeof kind !== "string" || !Object.hasOwn(IMPORT_PATHS, kind))) {
947
- throw new Error(`Failed to update project MCP override at ${filePath}: imports contains an unsupported config kind`);
948
- }
949
- lowerConfig = mergeConfigs(lowerConfig, expandImports({ mcpServers: {}, imports: raw.imports as ImportKind[] }, cwd, true));
950
- }
951
- if (isServerDisabled(lowerConfig.mcpServers[serverName])) next.disabled = false;
952
- }
953
-
954
- if ((!existing && Object.keys(next).length === 0) || JSON.stringify(existing) === JSON.stringify(next)) {
955
- return { path: filePath, changed: false };
956
- }
957
- if (Object.keys(next).length === 0) delete servers[serverName];
958
- else servers[serverName] = next;
959
-
960
- raw[serverKey] = servers;
961
- writeRawConfigObject(filePath, raw);
962
- return { path: filePath, changed: true };
963
- }
964
-
965
- function isRepoPromptServer(name: string, entry: ServerEntry): boolean {
966
- const normalizedName = name.toLowerCase();
967
- if (normalizedName.includes("repoprompt") || normalizedName === "rp") {
968
- return true;
969
- }
970
-
971
- const command = entry.command?.toLowerCase() ?? "";
972
- if (command.includes("repoprompt") || command.includes("rp-mcp") || command.endsWith("repoprompt_cli")) {
973
- return true;
974
- }
975
-
976
- return (entry.args ?? []).some((arg) => typeof arg === "string" && arg.toLowerCase().includes("repoprompt"));
977
- }
978
-
979
- function isHomePath(path: string): boolean {
980
- const home = resolve(homedir());
981
- return resolve(path) === home || canonicalPath(path) === canonicalPath(home);
982
- }
983
-
984
- function findProjectRoots(cwd = process.cwd()): string[] {
985
- const roots: string[] = [];
986
- const seen = new Set<string>();
987
- const startPaths = new Set([resolve(cwd), canonicalPath(cwd)]);
988
-
989
- for (const startPath of startPaths) {
990
- let current = startPath;
991
- while (true) {
992
- if (
993
- !isHomePath(current)
994
- && (
995
- existsSync(join(current, ".git"))
996
- || existsSync(join(current, "package.json"))
997
- || existsSync(join(current, PROJECT_CONFIG_NAME))
998
- || existsSync(join(current, ".pi"))
999
- )
1000
- && !seen.has(current)
1001
- ) {
1002
- roots.push(current);
1003
- seen.add(current);
1004
- }
1005
-
1006
- const parent = dirname(current);
1007
- if (parent === current) break;
1008
- current = parent;
1009
- }
1010
- }
1011
-
1012
- return roots;
1013
- }
1014
-
1015
- function findProjectRoot(cwd = process.cwd()): string | null {
1016
- return findProjectRoots(cwd)[0] ?? null;
1017
- }
1018
-
1019
- function buildRepoPromptEntry(executablePath: string): ServerEntry {
1020
- return {
1021
- command: executablePath,
1022
- args: [],
1023
- lifecycle: "lazy",
1024
- };
1025
- }
1026
-
1027
- function detectRepoPrompt(summary: Omit<McpDiscoverySummary, "repoPrompt">, cwd = process.cwd()): RepoPromptDiscovery {
1028
- for (const source of summary.sources) {
1029
- if (source.kind !== "shared" || source.serverCount === 0) continue;
1030
- const config = readValidatedConfig(source.path, `MCP config from ${source.path}`);
1031
- if (!config) continue;
1032
- for (const [name, entry] of Object.entries(config.mcpServers)) {
1033
- if (isRepoPromptServer(name, entry)) {
1034
- return { configured: true, configuredPath: source.path };
1035
- }
1036
- }
1037
- }
1038
-
1039
- const executablePath = REPOPROMPT_BINARY_CANDIDATES.find((candidate) => existsSync(candidate));
1040
- if (!executablePath) {
1041
- return { configured: false };
1042
- }
1043
-
1044
- const projectRoot = findProjectRoot(cwd);
1045
- const targetPath = projectRoot ? join(projectRoot, PROJECT_CONFIG_NAME) : GENERIC_GLOBAL_CONFIG_PATH;
1046
- return {
1047
- configured: false,
1048
- executablePath,
1049
- targetPath,
1050
- serverName: "repoprompt",
1051
- entry: buildRepoPromptEntry(executablePath),
1052
- };
1053
- }
1054
-
1055
- export function previewCompatibilityImports(importKinds: ImportKind[], overridePath?: string): ConfigWritePreview {
1056
- const targetPath = getPiGlobalConfigPath(overridePath);
1057
- const raw = readRawConfigObject(targetPath);
1058
- const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value): value is ImportKind => typeof value === "string") : [];
1059
- const merged = [...new Set([...currentImports, ...importKinds])];
1060
- const nextRaw = { ...raw, imports: merged };
1061
- setServersObject(nextRaw, getServersObject(nextRaw));
1062
- return buildConfigWritePreview(targetPath, nextRaw);
1063
- }
1064
-
1065
- export function ensureCompatibilityImports(importKinds: ImportKind[], overridePath?: string): { path: string; added: ImportKind[] } {
1066
- const targetPath = getPiGlobalConfigPath(overridePath);
1067
- const raw = readRawConfigObject(targetPath);
1068
- const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value): value is ImportKind => typeof value === "string") : [];
1069
- const merged = [...new Set([...currentImports, ...importKinds])];
1070
- const added = merged.filter((kind) => !currentImports.includes(kind));
1071
- if (added.length === 0) {
1072
- return { path: targetPath, added: [] };
1073
- }
1074
-
1075
- raw.imports = merged;
1076
- const servers = getServersObject(raw);
1077
- setServersObject(raw, servers);
1078
- writeRawConfigObject(targetPath, raw);
1079
- return { path: targetPath, added };
1080
- }
1081
-
1082
- export function buildStarterProjectConfig(): McpConfig {
1083
- return {
1084
- mcpServers: {},
1085
- };
1086
- }
1087
-
1088
- export function previewStarterProjectConfig(cwd = process.cwd()): ConfigWritePreview {
1089
- const targetPath = getProjectConfigPath(cwd);
1090
- const nextRaw = { mcpServers: buildStarterProjectConfig().mcpServers };
1091
- return buildConfigWritePreview(targetPath, nextRaw);
1092
- }
1093
-
1094
- export function writeStarterProjectConfig(cwd = process.cwd()): string {
1095
- const targetPath = getProjectConfigPath(cwd);
1096
- const raw = { mcpServers: buildStarterProjectConfig().mcpServers };
1097
- writeRawConfigObject(targetPath, raw);
1098
- return targetPath;
1099
- }
1100
-
1101
- export function previewSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): ConfigWritePreview {
1102
- const raw = readRawConfigObject(filePath);
1103
- const nextRaw = { ...raw };
1104
- const servers = getServersObject(nextRaw);
1105
- servers[serverName] = entry;
1106
- setServersObject(nextRaw, servers);
1107
- return buildConfigWritePreview(filePath, nextRaw);
1108
- }
1109
-
1110
- export function writeSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): string {
1111
- const raw = readRawConfigObject(filePath);
1112
- const servers = getServersObject(raw);
1113
- servers[serverName] = entry;
1114
- setServersObject(raw, servers);
1115
- writeRawConfigObject(filePath, raw);
1116
- return filePath;
1117
- }
1118
-
1119
- export function getServerProvenance(overridePath?: string, cwd = process.cwd()): Map<string, ServerProvenance> {
1120
- const provenance = new Map<string, ServerProvenance>();
1121
- const userPath = getPiGlobalConfigPath(overridePath);
1122
-
1123
- if (getConfiguredHostConfigDiscovery(overridePath, cwd) === "on") {
1124
- for (const importKind of Object.keys(IMPORT_PATHS) as ImportKind[]) {
1125
- const imported = loadImportedConfig(importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:`);
1126
- if (!imported) continue;
1127
- for (const name of Object.keys(extractServers(imported.value, importKind))) {
1128
- // Keep writes inside Pi-owned storage even though the source is external.
1129
- // Later import kinds win in the same deterministic order as loadDiscoveredHostConfigs.
1130
- provenance.set(name, { path: userPath, kind: "import", importKind });
1131
- }
1132
- }
1133
- }
1134
-
1135
- for (const source of getConfigSources(overridePath, cwd)) {
1136
- const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
1137
- if (!loaded) continue;
1138
-
1139
- if (loaded.imports?.length) {
1140
- for (const importKind of loaded.imports) {
1141
- const imported = loadImportedConfig(importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:`);
1142
- if (!imported) continue;
1143
-
1144
- const servers = extractServers(imported.value, importKind);
1145
- for (const name of Object.keys(servers)) {
1146
- if (!provenance.has(name)) {
1147
- provenance.set(name, { path: userPath, kind: "import", importKind });
1148
- }
1149
- }
1150
- }
1151
- }
1152
-
1153
- for (const name of Object.keys(loaded.mcpServers)) {
1154
- provenance.set(name, {
1155
- path: source.writePath,
1156
- kind: source.kind,
1157
- ...(source.importKind !== undefined ? { importKind: source.importKind } : {}),
1158
- });
1159
- }
1160
- }
1161
-
1162
- return provenance;
1163
- }
1164
-
1165
- export function writeDirectToolsConfig(
1166
- changes: Map<string, true | string[] | false>,
1167
- provenance: Map<string, ServerProvenance>,
1168
- ): void {
1169
- const byPath = new Map<string, { name: string; value: true | string[] | false }[]>();
1170
-
1171
- for (const [serverName, value] of changes) {
1172
- const prov = provenance.get(serverName);
1173
- if (!prov) continue;
1174
-
1175
- const targetPath = prov.path;
1176
-
1177
- if (!byPath.has(targetPath)) byPath.set(targetPath, []);
1178
- byPath.get(targetPath)!.push({ name: serverName, value });
1179
- }
1180
-
1181
- for (const [filePath, entries] of byPath) {
1182
- const raw = readRawConfigObject(filePath);
1183
- const servers = getServersObject(raw);
1184
-
1185
- for (const { name, value } of entries) {
1186
- // Keep inherited connection details in their source file.
1187
- servers[name] = { ...servers[name], directTools: value };
1188
- }
1189
-
1190
- setServersObject(raw, servers);
1191
- writeRawConfigObject(filePath, raw);
1192
- }
1193
- }
1194
-
1195
- export function resolveConfiguredOAuthDir(raw: unknown, cwd = process.cwd()): string | undefined {
1196
- if (raw === undefined || raw === null) return undefined;
1197
- if (typeof raw !== "string") {
1198
- throw new Error("settings.oauthDir must be a string");
1199
- }
1200
-
1201
- const trimmed = raw.trim();
1202
- if (!trimmed) return undefined;
1203
- return resolve(cwd, trimmed);
1204
- }