pi-mcp-adapter 3.0.0 → 3.1.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.1.0] - 2026-09-27
11
+
12
+ ### Highlights
13
+
14
+ - Approving a project's MCP servers once now covers every git worktree of that repository, so new worktrees stop asking again.
15
+ - The project-server approval prompt no longer disappears after `/reload` or when another extension takes over the editor. Before, that could leave MCP stuck starting and add up to 30 seconds to every prompt.
16
+ - When a server can't connect, the agent now sees the actual error instead of just "failed".
17
+ - Config files are safer: files saved with a Windows byte order mark now load, and a config file the adapter can't parse is no longer overwritten.
18
+ - A dependency update fixes a high-severity denial-of-service bug in TOML config parsing.
19
+
20
+ ### Changed
21
+
22
+ - Project MCP server approvals are now shared across a repository's git worktrees. Approving a server in one checkout covers the same folder in the others, so a new worktree no longer asks again unless the server definition differs. This works for regular, bare, and `--separate-git-dir` repositories; only the main checkout of a `--separate-git-dir` repository asks once on its own. Thanks to [@MauricioRobayo](https://github.com/MauricioRobayo) for [issue #708](https://github.com/nicobailon/pi-mcp-adapter/issues/708).
23
+
24
+ ### Fixed
25
+
26
+ - The project MCP server approval prompt now stays on screen until you answer it, including after `/reload` and panel saves. Before, `/reload` or another extension replacing the editor could hide it, leave MCP stuck starting, and make each prompt wait up to 30 seconds. Thanks to [@Gybra](https://github.com/Gybra) for [issue #690](https://github.com/nicobailon/pi-mcp-adapter/issues/690) and [PR #691](https://github.com/nicobailon/pi-mcp-adapter/pull/691), and to [@nazerim](https://github.com/nazerim) for [issue #695](https://github.com/nicobailon/pi-mcp-adapter/issues/695).
27
+ - Disabled project MCP servers no longer ask for approval or delay interactive startup. Thanks to [@ismailokta](https://github.com/ismailokta) for reporting [issue #685](https://github.com/nicobailon/pi-mcp-adapter/issues/685).
28
+ - The approval prompt now labels its path as the project config that needs approval, instead of calling it the server's source. A project file that only enables a server you defined globally no longer looks like it defines the command. Thanks to [@nazerim](https://github.com/nazerim) for reporting [issue #695](https://github.com/nicobailon/pi-mcp-adapter/issues/695).
29
+ - When a server fails to connect, the agent now sees why. `mcp({})` status, the `mcp` tool's "not available" replies, and direct tools show the connection error (for example `spawn demo ENOENT`) next to "failed 12s ago", shortened to one line. Before, only `/mcp-adapter status` and the panel showed it.
30
+ - MCP config files saved with a UTF-8 byte order mark, as some Windows editors do, now load instead of failing to parse. This covers adapter, shared, and imported JSON configs and imported TOML configs, and `pi-mcp-adapter init` keeps the servers in such a file. Thanks to [@quifox](https://github.com/quifox) for [PR #697](https://github.com/nicobailon/pi-mcp-adapter/pull/697).
31
+ - Adding servers, imports, or direct tools no longer overwrites an existing MCP config file that fails to parse or has the wrong shape. The adapter reports the error and leaves the file alone; empty files can still be set up. Thanks to [@quifox](https://github.com/quifox) for [PR #693](https://github.com/nicobailon/pi-mcp-adapter/pull/693).
32
+ - Repeating a word in an MCP tool search no longer hides tools that match it. Thanks to [@kojizada490-wq](https://github.com/kojizada490-wq) for [PR #678](https://github.com/nicobailon/pi-mcp-adapter/pull/678).
33
+ - Searching the `/mcp-adapter` panel by server name now finds disabled or unconnected servers, so you can select and enable them. Thanks to [@nazerim](https://github.com/nazerim) for reporting [issue #696](https://github.com/nicobailon/pi-mcp-adapter/issues/696).
34
+ - When Jev semantic search times out, is rate limited, or is unavailable, the lexical fallback now returns only tools from `settings.jev.allowedServers`, as the semantic results already did. Thanks to [@quifox](https://github.com/quifox) for [PR #698](https://github.com/nicobailon/pi-mcp-adapter/pull/698).
35
+ - Changing a stdio server's `inheritEnv` or `literalEnv` setting now refreshes its cached tools, so search and direct tools no longer show stale entries. Thanks to [@kojizada490-wq](https://github.com/kojizada490-wq) for [PR #683](https://github.com/nicobailon/pi-mcp-adapter/pull/683).
36
+
37
+ ### Security
38
+
39
+ - `smol-toml` now requires 1.9.0 or later, which fixes a high-severity denial of service on malformed TOML. The lockfile also moves `hono`, which comes in through the MCP SDK, to a release with its moderate advisories fixed, so `npm audit` on a lockfile install reports no production vulnerabilities. Thanks to [@jvpacini-CW](https://github.com/jvpacini-CW) for [PR #694](https://github.com/nicobailon/pi-mcp-adapter/pull/694).
40
+
10
41
  ## [3.0.0] - 2026-09-26
11
42
 
12
43
  ### Highlights
package/README.md CHANGED
@@ -463,7 +463,7 @@ You can also pass only the `code` query parameter with `args: { code: "..." }`.
463
463
 
464
464
  ### Project server trust
465
465
 
466
- Servers defined or changed by project-scoped MCP files (`.mcp.json`, `.pi/mcp-adapter.json`, and opted-in ancestor project files) do not run merely because a repository was opened. This includes servers brought in through project `imports`, `claudePlugins`, `settings.agentPluginPaths`, repo-local host config files (even when imports or discovery are enabled globally), or Pi packages listed in project `.pi/settings.json`. If Pi reports the project as untrusted, the adapter blocks them. In a trusted interactive session, the adapter shows the source file and command or URL and asks once before the first connection. The approval is stored under the Pi agent directory and is tied to the canonical project path, server name, and complete effective definition; changing the definition requires a new approval. Blocked servers are identified in the panel, while MCP status includes the required trust or approval action.
466
+ Servers defined or changed by project-scoped MCP files (`.mcp.json`, `.pi/mcp-adapter.json`, and opted-in ancestor project files) do not run merely because a repository was opened. This includes servers brought in through project `imports`, `claudePlugins`, `settings.agentPluginPaths`, repo-local host config files (even when imports or discovery are enabled globally), or Pi packages listed in project `.pi/settings.json`. If Pi reports the project as untrusted, the adapter blocks them. In a trusted interactive session, the adapter shows the source file and command or URL and asks once before the first connection. The approval is stored under the Pi agent directory and is tied to the canonical project path, server name, and complete effective definition; changing the definition requires a new approval. Git worktrees of one repository share approvals for the same folder, so a new worktree only asks when its definition differs. This covers regular, bare, and `--separate-git-dir` repositories; the one exception is the main checkout of a `--separate-git-dir` repository, which git records no link to, so it asks once on its own. Blocked servers are identified in the panel, while MCP status includes the required trust or approval action.
467
467
 
468
468
  In print, JSON, and RPC sessions, an unapproved project server is skipped. To intentionally allow project servers in trusted headless sessions, set `"projectServers": "allow"` in the **user-global** `settings` object. The default is `"ask"`; project files cannot change this policy. Explicit config files, programmatic configuration, global/import/plugin servers, and runtime registrations retain their existing behavior. Project servers are always excluded from extension-load initialization and are admitted only after `session_start` supplies Pi's trust context.
469
469
 
package/cli.js CHANGED
@@ -86,29 +86,31 @@ function printHelp(log = console.log) {
86
86
  }
87
87
 
88
88
  function readJsonFile(filePath) {
89
- return JSON.parse(stripJsonComments(fs.readFileSync(filePath, "utf-8"), { trailingCommas: true }));
89
+ const raw = fs.readFileSync(filePath, "utf-8");
90
+ const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
91
+ return text.trim() === "" ? {} : JSON.parse(stripJsonComments(text, { trailingCommas: true }));
90
92
  }
91
93
 
92
94
  function loadPiConfig() {
93
- if (!fs.existsSync(PI_CONFIG_PATH)) {
95
+ if (!fs.lstatSync(PI_CONFIG_PATH, { throwIfNoEntry: false })) {
94
96
  return { mcpServers: {} };
95
97
  }
96
98
 
97
99
  const raw = readJsonFile(PI_CONFIG_PATH);
98
- const mcpServers = raw.mcpServers ?? raw["mcp-servers"] ?? {};
99
- if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
100
- throw new Error(`Invalid MCP config at ${PI_CONFIG_PATH}: expected \"mcpServers\" to be an object`);
100
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
101
+ throw new Error(`Invalid MCP config at ${PI_CONFIG_PATH}: top-level value must be an object`);
101
102
  }
102
-
103
- const normalized = { ...raw };
104
- delete normalized["mcp-servers"];
105
-
106
- const imports = Array.isArray(raw.imports) ? raw.imports.filter((value) => typeof value === "string") : undefined;
107
- return {
108
- ...normalized,
109
- mcpServers,
110
- imports,
111
- };
103
+ for (const key of ["mcpServers", "mcp-servers", "settings"]) {
104
+ const value = raw[key];
105
+ if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value))) {
106
+ throw new Error(`Invalid MCP config at ${PI_CONFIG_PATH}: ${key} must be an object`);
107
+ }
108
+ }
109
+ if (raw.imports !== undefined && (!Array.isArray(raw.imports) || raw.imports.some((value) => typeof value !== "string"))) {
110
+ throw new Error(`Invalid MCP config at ${PI_CONFIG_PATH}: imports must be an array of strings`);
111
+ }
112
+ const { "mcp-servers": legacyServers, ...normalized } = raw;
113
+ return { ...normalized, mcpServers: raw.mcpServers ?? legacyServers ?? {} };
112
114
  }
113
115
 
114
116
  function findAvailableImports() {
package/commands.ts CHANGED
@@ -771,6 +771,8 @@ export async function openMcpPanel(
771
771
  ctx.ui.custom(
772
772
  (tui, theme, keybindings, done) => {
773
773
  return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => {
774
+ let directToolsSaved = false;
775
+ let directToolsFileWritten = false;
774
776
  void (async () => {
775
777
  if (!result.cancelled && result.disabledChanges.size > 0) {
776
778
  for (const [serverName, disabled] of result.disabledChanges) {
@@ -785,7 +787,10 @@ export async function openMcpPanel(
785
787
  }
786
788
  }
787
789
  if (!result.cancelled && result.changes.size > 0) {
788
- writeDirectToolsConfig(result.changes, provenanceMap, config);
790
+ writeDirectToolsConfig(result.changes, provenanceMap, config, () => {
791
+ directToolsFileWritten = true;
792
+ });
793
+ directToolsSaved = true;
789
794
  await onDirectToolsConfigChanged?.(result.changes);
790
795
  ctx.ui.notify("Direct tools updated for this session.", "info");
791
796
  }
@@ -793,8 +798,15 @@ export async function openMcpPanel(
793
798
  resolve();
794
799
  })().catch((error) => {
795
800
  const message = error instanceof Error ? error.message : String(error);
796
- ctx.ui.notify(`Direct tools updated, but live refresh failed: ${message}`, "error");
797
- configChanged = true;
801
+ if (directToolsSaved) {
802
+ ctx.ui.notify(`Direct tools updated, but live refresh failed: ${message}`, "error");
803
+ configChanged = true;
804
+ } else if (directToolsFileWritten) {
805
+ ctx.ui.notify(`Direct tools were partially saved before another write failed: ${message}`, "error");
806
+ configChanged = true;
807
+ } else {
808
+ ctx.ui.notify(`Failed to save direct tools: ${message}`, "error");
809
+ }
798
810
  done(undefined);
799
811
  resolve();
800
812
  });
package/config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // config.ts - Config loading with import support
2
- import { chmodSync, existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
2
+ import { chmodSync, existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { parse as parseToml } from "smol-toml";
@@ -11,7 +11,7 @@ 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
13
  import { formatServerNamespace, isServerDisabled, type ClaudePluginConfig, type HostConfigDiscovery, type McpConfig, type ServerEntry, type McpSettings, type ImportKind, type ServerProvenance } from "./types.ts";
14
- import { parseJsonWithComments, toStringRecord } from "./utils.ts";
14
+ import { parseJsonWithComments, 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 = [
@@ -932,7 +932,7 @@ function resolveImportCandidates(importKind: ImportKind, cwd: string): ImportedS
932
932
 
933
933
  function readImportedConfig(path: string): unknown {
934
934
  const raw = readFileSync(path, "utf-8");
935
- return path.endsWith(".toml") ? parseToml(raw) : parseJsonWithComments(raw);
935
+ return path.endsWith(".toml") ? parseToml(stripUtf8Bom(raw)) : parseJsonWithComments(raw);
936
936
  }
937
937
 
938
938
  function loadImportedConfig(
@@ -1335,22 +1335,30 @@ function buildConfigWritePreview(filePath: string, nextRaw: Record<string, unkno
1335
1335
  }
1336
1336
 
1337
1337
  function readRawConfigObject(filePath: string): Record<string, unknown> {
1338
- if (!existsSync(filePath)) return {};
1338
+ if (!lstatSync(filePath, { throwIfNoEntry: false })) return {};
1339
1339
 
1340
1340
  try {
1341
- const raw = parseJsonWithComments(readFileSync(filePath, "utf-8"));
1342
- return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
1343
- } catch {
1344
- return {};
1341
+ const text = readFileSync(filePath, "utf-8");
1342
+ if (text.trim() === "") return {};
1343
+ const raw = parseJsonWithComments(text);
1344
+ if (!isRecord(raw)) throw new Error("top-level value must be an object");
1345
+ return raw;
1346
+ } catch (error) {
1347
+ throw new Error(`Failed to read MCP config at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1345
1348
  }
1346
1349
  }
1347
1350
 
1348
1351
  function writeConfigText(writePath: string, text: string): void {
1352
+ const originalPath = writePath;
1349
1353
  let mode: number | undefined;
1350
1354
  try {
1351
1355
  writePath = realpathSync(writePath);
1352
1356
  mode = statSync(writePath).mode & 0o777;
1353
- } catch {}
1357
+ } catch (error) {
1358
+ if (lstatSync(originalPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
1359
+ throw new Error(`Cannot write MCP config at ${originalPath}: symbolic link target is unavailable`, { cause: error });
1360
+ }
1361
+ }
1354
1362
  mkdirSync(dirname(writePath), { recursive: true });
1355
1363
  const tmpPath = `${writePath}.${process.pid}.tmp`;
1356
1364
  rmSync(tmpPath, { force: true });
@@ -1380,16 +1388,7 @@ export function writeJevSemanticSearchConfig(
1380
1388
  effectiveJev?: unknown,
1381
1389
  ): { path: string; changed: boolean } {
1382
1390
  const filePath = overridePath ? getPiGlobalConfigPath(overridePath) : getProjectPiConfigPath(cwd);
1383
- let raw: Record<string, unknown> = {};
1384
- if (existsSync(filePath)) {
1385
- try {
1386
- const parsed = parseJsonWithComments(readFileSync(filePath, "utf8"));
1387
- if (!isRecord(parsed)) throw new Error("top-level value must be an object");
1388
- raw = parsed;
1389
- } catch (error) {
1390
- throw new Error(`Failed to update Jev settings at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1391
- }
1392
- }
1391
+ const raw = readRawConfigObject(filePath);
1393
1392
  if (raw.settings !== undefined && !isRecord(raw.settings)) {
1394
1393
  throw new Error(`Failed to update Jev settings at ${filePath}: settings must be an object`);
1395
1394
  }
@@ -1408,12 +1407,21 @@ export function writeJevSemanticSearchConfig(
1408
1407
  return { path: filePath, changed: true };
1409
1408
  }
1410
1409
 
1411
- function getServersObject(raw: Record<string, unknown>): Record<string, ServerEntry> {
1412
- const existing = raw.mcpServers ?? raw["mcp-servers"] ?? {};
1413
- if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
1414
- return {};
1410
+ function getServersObject(raw: Record<string, unknown>, filePath: string): Record<string, ServerEntry> {
1411
+ for (const key of ["mcpServers", "mcp-servers"]) {
1412
+ if (Object.hasOwn(raw, key) && !isRecord(raw[key])) {
1413
+ throw new Error(`Failed to update MCP config at ${filePath}: ${key} must be an object`);
1414
+ }
1415
1415
  }
1416
- return existing as Record<string, ServerEntry>;
1416
+ return (raw.mcpServers ?? raw["mcp-servers"] ?? {}) as Record<string, ServerEntry>;
1417
+ }
1418
+
1419
+ function getConfigImports(raw: Record<string, unknown>, filePath: string): ImportKind[] {
1420
+ if (raw.imports === undefined) return [];
1421
+ if (!Array.isArray(raw.imports) || raw.imports.some((value) => typeof value !== "string")) {
1422
+ throw new Error(`Failed to update MCP config at ${filePath}: imports must be an array of strings`);
1423
+ }
1424
+ return raw.imports as ImportKind[];
1417
1425
  }
1418
1426
 
1419
1427
  function setServersObject(raw: Record<string, unknown>, servers: Record<string, ServerEntry>): void {
@@ -1438,18 +1446,7 @@ export function writeProjectServerDisabledOverride(
1438
1446
  disabled: boolean,
1439
1447
  ): ServerDisabledOverrideResult {
1440
1448
  const filePath = getProjectPiConfigPath(cwd);
1441
- let raw: Record<string, unknown> = {};
1442
- if (existsSync(filePath)) {
1443
- try {
1444
- const parsed = parseJsonWithComments(readFileSync(filePath, "utf-8"));
1445
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1446
- throw new Error("root value must be an object");
1447
- }
1448
- raw = parsed as Record<string, unknown>;
1449
- } catch (error) {
1450
- throw new Error(`Failed to read project MCP override at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1451
- }
1452
- }
1449
+ const raw = readRawConfigObject(filePath);
1453
1450
 
1454
1451
  const serverKey = raw.mcpServers !== undefined ? "mcpServers" : raw["mcp-servers"] !== undefined ? "mcp-servers" : "mcpServers";
1455
1452
  const rawServers = raw[serverKey];
@@ -1565,17 +1562,17 @@ function detectRepoPrompt(summary: Omit<McpDiscoverySummary, "fingerprint" | "re
1565
1562
  export function previewCompatibilityImports(importKinds: ImportKind[], overridePath?: string): ConfigWritePreview {
1566
1563
  const targetPath = getPiGlobalConfigPath(overridePath);
1567
1564
  const raw = readRawConfigObject(targetPath);
1568
- const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value): value is ImportKind => typeof value === "string") : [];
1565
+ const currentImports = getConfigImports(raw, targetPath);
1569
1566
  const merged = [...new Set([...currentImports, ...importKinds])];
1570
1567
  const nextRaw = { ...raw, imports: merged };
1571
- setServersObject(nextRaw, getServersObject(nextRaw));
1568
+ setServersObject(nextRaw, getServersObject(nextRaw, targetPath));
1572
1569
  return buildConfigWritePreview(targetPath, nextRaw);
1573
1570
  }
1574
1571
 
1575
1572
  export function ensureCompatibilityImports(importKinds: ImportKind[], overridePath?: string): { path: string; added: ImportKind[] } {
1576
1573
  const targetPath = getPiGlobalConfigPath(overridePath);
1577
1574
  const raw = readRawConfigObject(targetPath);
1578
- const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value): value is ImportKind => typeof value === "string") : [];
1575
+ const currentImports = getConfigImports(raw, targetPath);
1579
1576
  const merged = [...new Set([...currentImports, ...importKinds])];
1580
1577
  const added = merged.filter((kind) => !currentImports.includes(kind));
1581
1578
  if (added.length === 0) {
@@ -1583,7 +1580,7 @@ export function ensureCompatibilityImports(importKinds: ImportKind[], overridePa
1583
1580
  }
1584
1581
 
1585
1582
  raw.imports = merged;
1586
- const servers = getServersObject(raw);
1583
+ const servers = getServersObject(raw, targetPath);
1587
1584
  setServersObject(raw, servers);
1588
1585
  writeRawConfigObject(targetPath, raw);
1589
1586
  return { path: targetPath, added };
@@ -1595,14 +1592,20 @@ export function buildStarterProjectConfig(): McpConfig {
1595
1592
  };
1596
1593
  }
1597
1594
 
1595
+ function assertScaffoldTargetAbsent(filePath: string): void {
1596
+ if (lstatSync(filePath, { throwIfNoEntry: false })) throw new Error(`Cannot scaffold MCP config at ${filePath}: file already exists`);
1597
+ }
1598
+
1598
1599
  export function previewStarterSharedConfig(target: SharedConfigTarget, cwd = process.cwd()): ConfigWritePreview {
1599
1600
  const targetPath = getSharedConfigPath(target, cwd);
1601
+ assertScaffoldTargetAbsent(targetPath);
1600
1602
  const nextRaw = { mcpServers: buildStarterProjectConfig().mcpServers };
1601
1603
  return buildConfigWritePreview(targetPath, nextRaw);
1602
1604
  }
1603
1605
 
1604
1606
  export function writeStarterSharedConfig(target: SharedConfigTarget, cwd = process.cwd()): string {
1605
1607
  const targetPath = getSharedConfigPath(target, cwd);
1608
+ assertScaffoldTargetAbsent(targetPath);
1606
1609
  const raw = { mcpServers: buildStarterProjectConfig().mcpServers };
1607
1610
  writeRawConfigObject(targetPath, raw);
1608
1611
  return targetPath;
@@ -1619,7 +1622,7 @@ export function writeStarterProjectConfig(cwd = process.cwd()): string {
1619
1622
  export function previewSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): ConfigWritePreview {
1620
1623
  const raw = readRawConfigObject(filePath);
1621
1624
  const nextRaw = { ...raw };
1622
- const servers = getServersObject(nextRaw);
1625
+ const servers = getServersObject(nextRaw, filePath);
1623
1626
  servers[serverName] = entry;
1624
1627
  setServersObject(nextRaw, servers);
1625
1628
  return buildConfigWritePreview(filePath, nextRaw);
@@ -1627,7 +1630,7 @@ export function previewSharedServerEntry(filePath: string, serverName: string, e
1627
1630
 
1628
1631
  export function writeSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): string {
1629
1632
  const raw = readRawConfigObject(filePath);
1630
- const servers = getServersObject(raw);
1633
+ const servers = getServersObject(raw, filePath);
1631
1634
  servers[serverName] = entry;
1632
1635
  setServersObject(raw, servers);
1633
1636
  writeRawConfigObject(filePath, raw);
@@ -1684,6 +1687,7 @@ export function writeDirectToolsConfig(
1684
1687
  changes: Map<string, true | string[] | false>,
1685
1688
  provenance: Map<string, ServerProvenance>,
1686
1689
  fullConfig: McpConfig,
1690
+ onFileWritten?: () => void,
1687
1691
  ): void {
1688
1692
  const byPath = new Map<string, { name: string; value: true | string[] | false; prov: ServerProvenance }[]>();
1689
1693
 
@@ -1697,10 +1701,12 @@ export function writeDirectToolsConfig(
1697
1701
  byPath.get(targetPath)!.push({ name: serverName, value, prov });
1698
1702
  }
1699
1703
 
1704
+ // Validate every target before writing any, then re-read each one: two paths can alias one file.
1705
+ for (const filePath of byPath.keys()) getServersObject(readRawConfigObject(filePath), filePath);
1706
+
1700
1707
  for (const [filePath, entries] of byPath) {
1701
1708
  const raw = readRawConfigObject(filePath);
1702
- const servers = getServersObject(raw);
1703
-
1709
+ const servers = getServersObject(raw, filePath);
1704
1710
  for (const { name, value, prov } of entries) {
1705
1711
  if (prov.kind === "import") {
1706
1712
  const fullDef = fullConfig.mcpServers[name];
@@ -1714,6 +1720,7 @@ export function writeDirectToolsConfig(
1714
1720
 
1715
1721
  setServersObject(raw, servers);
1716
1722
  writeRawConfigObject(filePath, raw);
1723
+ onFileWritten?.();
1717
1724
  }
1718
1725
  }
1719
1726
 
package/direct-tools.ts CHANGED
@@ -3,7 +3,8 @@ import { UrlElicitationRequiredError, type Client } from "@modelcontextprotocol/
3
3
  import type { McpExtensionState } from "./state.ts";
4
4
  import { disabledServerReason } from "./project-server-trust.ts";
5
5
  import type { DirectToolSpec, McpContent } from "./types.ts";
6
- import { lazyConnect, getFailureAgeSeconds, clearFailure } from "./init.ts";
6
+ import { lazyConnect, clearFailure } from "./init.ts";
7
+ import { describeFailure } from "./failure-backoff.ts";
7
8
  import { abortable, throwIfAborted } from "./abort.ts";
8
9
  import { formatSchema } from "./tool-metadata.ts";
9
10
  import { resolveMcpResultContent, transformMcpResourceContents } from "./tool-registrar.ts";
@@ -190,9 +191,9 @@ export function createDirectToolExecutor(
190
191
  details: { error: "auth_required", server: spec.serverName, message, autoAuthAttempted },
191
192
  };
192
193
  }
193
- const failedAgo = getFailureAgeSeconds(state, spec.serverName);
194
+ const failure = describeFailure(state, spec.serverName);
194
195
  return {
195
- content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not available${failedAgo !== null ? ` (failed ${failedAgo}s ago)` : ""}` }],
196
+ content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not available${failure !== null ? ` (${failure})` : ""}` }],
196
197
  details: { error: "server_unavailable", server: spec.serverName },
197
198
  };
198
199
  }
package/dist/config.d.ts CHANGED
@@ -142,6 +142,6 @@ export declare function writeStarterProjectConfig(cwd?: string): string;
142
142
  export declare function previewSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): ConfigWritePreview;
143
143
  export declare function writeSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): string;
144
144
  export declare function getServerProvenance(overridePath?: string, cwd?: string): Map<string, ServerProvenance>;
145
- export declare function writeDirectToolsConfig(changes: Map<string, true | string[] | false>, provenance: Map<string, ServerProvenance>, fullConfig: McpConfig): void;
145
+ export declare function writeDirectToolsConfig(changes: Map<string, true | string[] | false>, provenance: Map<string, ServerProvenance>, fullConfig: McpConfig, onFileWritten?: () => void): void;
146
146
  export declare function resolveConfiguredOAuthDir(raw: unknown, cwd?: string): string | undefined;
147
147
  export {};
package/dist/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // config.ts - Config loading with import support
2
- import { chmodSync, existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
2
+ import { chmodSync, existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { parse as parseToml } from "smol-toml";
@@ -11,7 +11,7 @@ import { loadClaudePluginBundles } from "./claude-plugin-loader.js";
11
11
  import { loadPackageMcpConfigs } from "./package-mcp-loader.js";
12
12
  import { validateJevSettings } from "./jev-client.js";
13
13
  import { formatServerNamespace, isServerDisabled } from "./types.js";
14
- import { parseJsonWithComments, toStringRecord } from "./utils.js";
14
+ import { parseJsonWithComments, stripUtf8Bom, toStringRecord } from "./utils.js";
15
15
  const GENERIC_GLOBAL_CONFIG_PATH = join(homedir(), ".config", "mcp", "mcp.json");
16
16
  const AGENTS_GLOBAL_CONFIG_PATHS = [
17
17
  join(homedir(), ".agents", "mcp.json"),
@@ -776,7 +776,7 @@ function resolveImportCandidates(importKind, cwd) {
776
776
  }
777
777
  function readImportedConfig(path) {
778
778
  const raw = readFileSync(path, "utf-8");
779
- return path.endsWith(".toml") ? parseToml(raw) : parseJsonWithComments(raw);
779
+ return path.endsWith(".toml") ? parseToml(stripUtf8Bom(raw)) : parseJsonWithComments(raw);
780
780
  }
781
781
  function loadImportedConfig(importKind, cwd, warningPrefix) {
782
782
  if (importKind === "opencode") {
@@ -1155,23 +1155,33 @@ function buildConfigWritePreview(filePath, nextRaw) {
1155
1155
  };
1156
1156
  }
1157
1157
  function readRawConfigObject(filePath) {
1158
- if (!existsSync(filePath))
1158
+ if (!lstatSync(filePath, { throwIfNoEntry: false }))
1159
1159
  return {};
1160
1160
  try {
1161
- const raw = parseJsonWithComments(readFileSync(filePath, "utf-8"));
1162
- return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
1161
+ const text = readFileSync(filePath, "utf-8");
1162
+ if (text.trim() === "")
1163
+ return {};
1164
+ const raw = parseJsonWithComments(text);
1165
+ if (!isRecord(raw))
1166
+ throw new Error("top-level value must be an object");
1167
+ return raw;
1163
1168
  }
1164
- catch {
1165
- return {};
1169
+ catch (error) {
1170
+ throw new Error(`Failed to read MCP config at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1166
1171
  }
1167
1172
  }
1168
1173
  function writeConfigText(writePath, text) {
1174
+ const originalPath = writePath;
1169
1175
  let mode;
1170
1176
  try {
1171
1177
  writePath = realpathSync(writePath);
1172
1178
  mode = statSync(writePath).mode & 0o777;
1173
1179
  }
1174
- catch { }
1180
+ catch (error) {
1181
+ if (lstatSync(originalPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
1182
+ throw new Error(`Cannot write MCP config at ${originalPath}: symbolic link target is unavailable`, { cause: error });
1183
+ }
1184
+ }
1175
1185
  mkdirSync(dirname(writePath), { recursive: true });
1176
1186
  const tmpPath = `${writePath}.${process.pid}.tmp`;
1177
1187
  rmSync(tmpPath, { force: true });
@@ -1199,18 +1209,7 @@ export function writeSharedConfigText(filePath, text) {
1199
1209
  }
1200
1210
  export function writeJevSemanticSearchConfig(overridePath, cwd, allowedServers, effectiveJev) {
1201
1211
  const filePath = overridePath ? getPiGlobalConfigPath(overridePath) : getProjectPiConfigPath(cwd);
1202
- let raw = {};
1203
- if (existsSync(filePath)) {
1204
- try {
1205
- const parsed = parseJsonWithComments(readFileSync(filePath, "utf8"));
1206
- if (!isRecord(parsed))
1207
- throw new Error("top-level value must be an object");
1208
- raw = parsed;
1209
- }
1210
- catch (error) {
1211
- throw new Error(`Failed to update Jev settings at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1212
- }
1213
- }
1212
+ const raw = readRawConfigObject(filePath);
1214
1213
  if (raw.settings !== undefined && !isRecord(raw.settings)) {
1215
1214
  throw new Error(`Failed to update Jev settings at ${filePath}: settings must be an object`);
1216
1215
  }
@@ -1229,12 +1228,21 @@ export function writeJevSemanticSearchConfig(overridePath, cwd, allowedServers,
1229
1228
  writeRawConfigObject(filePath, raw);
1230
1229
  return { path: filePath, changed: true };
1231
1230
  }
1232
- function getServersObject(raw) {
1233
- const existing = raw.mcpServers ?? raw["mcp-servers"] ?? {};
1234
- if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
1235
- return {};
1231
+ function getServersObject(raw, filePath) {
1232
+ for (const key of ["mcpServers", "mcp-servers"]) {
1233
+ if (Object.hasOwn(raw, key) && !isRecord(raw[key])) {
1234
+ throw new Error(`Failed to update MCP config at ${filePath}: ${key} must be an object`);
1235
+ }
1236
1236
  }
1237
- return existing;
1237
+ return (raw.mcpServers ?? raw["mcp-servers"] ?? {});
1238
+ }
1239
+ function getConfigImports(raw, filePath) {
1240
+ if (raw.imports === undefined)
1241
+ return [];
1242
+ if (!Array.isArray(raw.imports) || raw.imports.some((value) => typeof value !== "string")) {
1243
+ throw new Error(`Failed to update MCP config at ${filePath}: imports must be an array of strings`);
1244
+ }
1245
+ return raw.imports;
1238
1246
  }
1239
1247
  function setServersObject(raw, servers) {
1240
1248
  delete raw["mcp-servers"];
@@ -1247,19 +1255,7 @@ function setServersObject(raw, servers) {
1247
1255
  */
1248
1256
  export function writeProjectServerDisabledOverride(overridePath, cwd, serverName, disabled) {
1249
1257
  const filePath = getProjectPiConfigPath(cwd);
1250
- let raw = {};
1251
- if (existsSync(filePath)) {
1252
- try {
1253
- const parsed = parseJsonWithComments(readFileSync(filePath, "utf-8"));
1254
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1255
- throw new Error("root value must be an object");
1256
- }
1257
- raw = parsed;
1258
- }
1259
- catch (error) {
1260
- throw new Error(`Failed to read project MCP override at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1261
- }
1262
- }
1258
+ const raw = readRawConfigObject(filePath);
1263
1259
  const serverKey = raw.mcpServers !== undefined ? "mcpServers" : raw["mcp-servers"] !== undefined ? "mcp-servers" : "mcpServers";
1264
1260
  const rawServers = raw[serverKey];
1265
1261
  if (rawServers !== undefined && (!rawServers || typeof rawServers !== "object" || Array.isArray(rawServers))) {
@@ -1368,23 +1364,23 @@ function detectRepoPrompt(summary, cwd = process.cwd()) {
1368
1364
  export function previewCompatibilityImports(importKinds, overridePath) {
1369
1365
  const targetPath = getPiGlobalConfigPath(overridePath);
1370
1366
  const raw = readRawConfigObject(targetPath);
1371
- const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value) => typeof value === "string") : [];
1367
+ const currentImports = getConfigImports(raw, targetPath);
1372
1368
  const merged = [...new Set([...currentImports, ...importKinds])];
1373
1369
  const nextRaw = { ...raw, imports: merged };
1374
- setServersObject(nextRaw, getServersObject(nextRaw));
1370
+ setServersObject(nextRaw, getServersObject(nextRaw, targetPath));
1375
1371
  return buildConfigWritePreview(targetPath, nextRaw);
1376
1372
  }
1377
1373
  export function ensureCompatibilityImports(importKinds, overridePath) {
1378
1374
  const targetPath = getPiGlobalConfigPath(overridePath);
1379
1375
  const raw = readRawConfigObject(targetPath);
1380
- const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value) => typeof value === "string") : [];
1376
+ const currentImports = getConfigImports(raw, targetPath);
1381
1377
  const merged = [...new Set([...currentImports, ...importKinds])];
1382
1378
  const added = merged.filter((kind) => !currentImports.includes(kind));
1383
1379
  if (added.length === 0) {
1384
1380
  return { path: targetPath, added: [] };
1385
1381
  }
1386
1382
  raw.imports = merged;
1387
- const servers = getServersObject(raw);
1383
+ const servers = getServersObject(raw, targetPath);
1388
1384
  setServersObject(raw, servers);
1389
1385
  writeRawConfigObject(targetPath, raw);
1390
1386
  return { path: targetPath, added };
@@ -1394,13 +1390,19 @@ export function buildStarterProjectConfig() {
1394
1390
  mcpServers: {},
1395
1391
  };
1396
1392
  }
1393
+ function assertScaffoldTargetAbsent(filePath) {
1394
+ if (lstatSync(filePath, { throwIfNoEntry: false }))
1395
+ throw new Error(`Cannot scaffold MCP config at ${filePath}: file already exists`);
1396
+ }
1397
1397
  export function previewStarterSharedConfig(target, cwd = process.cwd()) {
1398
1398
  const targetPath = getSharedConfigPath(target, cwd);
1399
+ assertScaffoldTargetAbsent(targetPath);
1399
1400
  const nextRaw = { mcpServers: buildStarterProjectConfig().mcpServers };
1400
1401
  return buildConfigWritePreview(targetPath, nextRaw);
1401
1402
  }
1402
1403
  export function writeStarterSharedConfig(target, cwd = process.cwd()) {
1403
1404
  const targetPath = getSharedConfigPath(target, cwd);
1405
+ assertScaffoldTargetAbsent(targetPath);
1404
1406
  const raw = { mcpServers: buildStarterProjectConfig().mcpServers };
1405
1407
  writeRawConfigObject(targetPath, raw);
1406
1408
  return targetPath;
@@ -1414,14 +1416,14 @@ export function writeStarterProjectConfig(cwd = process.cwd()) {
1414
1416
  export function previewSharedServerEntry(filePath, serverName, entry) {
1415
1417
  const raw = readRawConfigObject(filePath);
1416
1418
  const nextRaw = { ...raw };
1417
- const servers = getServersObject(nextRaw);
1419
+ const servers = getServersObject(nextRaw, filePath);
1418
1420
  servers[serverName] = entry;
1419
1421
  setServersObject(nextRaw, servers);
1420
1422
  return buildConfigWritePreview(filePath, nextRaw);
1421
1423
  }
1422
1424
  export function writeSharedServerEntry(filePath, serverName, entry) {
1423
1425
  const raw = readRawConfigObject(filePath);
1424
- const servers = getServersObject(raw);
1426
+ const servers = getServersObject(raw, filePath);
1425
1427
  servers[serverName] = entry;
1426
1428
  setServersObject(raw, servers);
1427
1429
  writeRawConfigObject(filePath, raw);
@@ -1469,7 +1471,7 @@ export function getServerProvenance(overridePath, cwd = process.cwd()) {
1469
1471
  }
1470
1472
  return provenance;
1471
1473
  }
1472
- export function writeDirectToolsConfig(changes, provenance, fullConfig) {
1474
+ export function writeDirectToolsConfig(changes, provenance, fullConfig, onFileWritten) {
1473
1475
  const byPath = new Map();
1474
1476
  for (const [serverName, value] of changes) {
1475
1477
  const prov = provenance.get(serverName);
@@ -1480,9 +1482,12 @@ export function writeDirectToolsConfig(changes, provenance, fullConfig) {
1480
1482
  byPath.set(targetPath, []);
1481
1483
  byPath.get(targetPath).push({ name: serverName, value, prov });
1482
1484
  }
1485
+ // Validate every target before writing any, then re-read each one: two paths can alias one file.
1486
+ for (const filePath of byPath.keys())
1487
+ getServersObject(readRawConfigObject(filePath), filePath);
1483
1488
  for (const [filePath, entries] of byPath) {
1484
1489
  const raw = readRawConfigObject(filePath);
1485
- const servers = getServersObject(raw);
1490
+ const servers = getServersObject(raw, filePath);
1486
1491
  for (const { name, value, prov } of entries) {
1487
1492
  if (prov.kind === "import") {
1488
1493
  const fullDef = fullConfig.mcpServers[name];
@@ -1496,6 +1501,7 @@ export function writeDirectToolsConfig(changes, provenance, fullConfig) {
1496
1501
  }
1497
1502
  setServersObject(raw, servers);
1498
1503
  writeRawConfigObject(filePath, raw);
1504
+ onFileWritten?.();
1499
1505
  }
1500
1506
  }
1501
1507
  export function resolveConfiguredOAuthDir(raw, cwd = process.cwd()) {