pi-mcp-adapter 3.0.0 → 3.2.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,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.2.0] - 2026-09-28
11
+
12
+ ### Highlights
13
+
14
+ - Apps that embed Pi can now run the adapter on MCP connections they manage themselves, and keep control of approvals and results.
15
+ - OAuth sign-in now works with servers that reject `localhost` callbacks, such as Lovable.
16
+ - Direct tools no longer vanish from a session when another Pi session updates the shared tool cache.
17
+ - `mcpScript` now works in Bun-compiled builds of Pi.
18
+ - Trusted projects no longer show a false "project servers blocked" warning at startup.
19
+
20
+ ### Added
21
+
22
+ - New `pi-mcp-adapter/host-managed` entry point for apps that embed Pi and manage their own MCP connections. The app supplies the connections, records each approved call before it is sent, sees the raw result first, and decides when the adapter starts and stops. The adapter never retries a call or reconnects, and never reads config, OAuth, or keyring state. See [Host-managed embedding](README.md#host-managed-embedding). Thanks to [@LeonEthan](https://github.com/LeonEthan) for the design in [issue #716](https://github.com/nicobailon/pi-mcp-adapter/issues/716).
23
+
24
+ ### Changed
25
+
26
+ - OAuth callbacks now default to `http://127.0.0.1:<port>/callback` instead of `localhost`, as RFC 8252 recommends. Servers that reject `localhost`, such as Lovable, now work without extra setup. If a server only accepts `localhost`, set `oauth.redirectUri` to that address. Thanks to [@ebysofyan](https://github.com/ebysofyan) for [issue #711](https://github.com/nicobailon/pi-mcp-adapter/issues/711) and [PR #712](https://github.com/nicobailon/pi-mcp-adapter/pull/712).
27
+
28
+ ### Fixed
29
+
30
+ - Pi no longer warns "MCP: Project servers blocked: … (blocked by project trust)" at startup in a project you already trust, including one trusted through a parent folder. `eager` and `keep-alive` project servers now wait for Pi's trust check, and the approval prompt still appears when the session starts. Thanks to [@pnym-ai](https://github.com/pnym-ai) for reporting [issue #713](https://github.com/nicobailon/pi-mcp-adapter/issues/713).
31
+ - Direct tools no longer disappear from a session when another Pi session writes different details for the same server to the shared cache, or when the cache entry expires, is deleted, or the server disconnects while idle. Each session now keeps the tools and resources it found. Changing the server's config still clears them. Thanks to [@NoahWTeng](https://github.com/NoahWTeng) for [PR #718](https://github.com/nicobailon/pi-mcp-adapter/pull/718).
32
+ - A server's `mcp__<server>` tool now comes back once the adapter can retry the server after a failed connection. Before, on Pi versions without `unregisterTool`, it stayed hidden for the rest of the session. A tool you turned off yourself stays off. Thanks to [@fer-git](https://github.com/fer-git) for [issue #717](https://github.com/nicobailon/pi-mcp-adapter/issues/717).
33
+ - `mcpScript` works when Pi runs as a Bun-compiled executable. Before, every call failed with `Cannot find package 'quickjs-wasi'` from the sandbox worker. Thanks to [@fmoda3](https://github.com/fmoda3) for [issue #720](https://github.com/nicobailon/pi-mcp-adapter/issues/720).
34
+
35
+ ## [3.1.0] - 2026-09-27
36
+
37
+ ### Highlights
38
+
39
+ - Approving a project's MCP servers once now covers every git worktree of that repository, so new worktrees stop asking again.
40
+ - 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.
41
+ - When a server can't connect, the agent now sees the actual error instead of just "failed".
42
+ - 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.
43
+ - A dependency update fixes a high-severity denial-of-service bug in TOML config parsing.
44
+
45
+ ### Changed
46
+
47
+ - 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).
48
+
49
+ ### Fixed
50
+
51
+ - 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).
52
+ - 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).
53
+ - 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).
54
+ - 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.
55
+ - 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).
56
+ - 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).
57
+ - 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).
58
+ - 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).
59
+ - 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).
60
+ - 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).
61
+
62
+ ### Security
63
+
64
+ - `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).
65
+
10
66
  ## [3.0.0] - 2026-09-26
11
67
 
12
68
  ### Highlights
package/OAUTH.md CHANGED
@@ -88,7 +88,7 @@ You can optionally provide a pre-registered client:
88
88
  - `oauth.authServerMetadataUrl` - HTTPS OAuth/OIDC authorization-server metadata document to use authoritatively when MCP protected-resource discovery is unavailable (optional; issuer validation remains enabled)
89
89
  - `oauth.skipIssuerMetadataValidation` - Set `true` only for a known-misconfigured authorization server whose metadata issuer cannot be fixed immediately. This weakens OAuth issuer validation.
90
90
 
91
- Dynamic fallback clients normally omit `oauth.redirectUri`; the adapter starts the callback server lazily on the default loopback host (`localhost`) and asks the OS for an available local port when auth begins. Use `oauth.redirectUri` when the provider requires a pre-registered callback, such as Slack MCP's Claude-compatible `http://localhost:3118/callback`. A loopback URI must use `http://` with `localhost`, `127.0.0.1`, or `[::1]`. It may contain an explicit port, which is bound exactly, or `{port}`, which is replaced with the OS-assigned port in the authorization and token requests.
91
+ Dynamic fallback clients normally omit `oauth.redirectUri`; the adapter starts the callback server lazily on the default loopback host (`127.0.0.1`, as RFC 8252 recommends) and asks the OS for an available local port when auth begins. Use `oauth.redirectUri` when the provider requires a pre-registered callback, such as Slack MCP's Claude-compatible `http://localhost:3118/callback`, or when a server only accepts a `localhost` redirect (for example `http://localhost:{port}/callback`). A loopback URI must use `http://` with `localhost`, `127.0.0.1`, or `[::1]`. It may contain an explicit port, which is bound exactly, or `{port}`, which is replaced with the OS-assigned port in the authorization and token requests.
92
92
 
93
93
  ### Non-Interactive `client_credentials`
94
94
 
@@ -142,13 +142,13 @@ When Pi runs over SSH or in a headless environment, use the proxy tool to retrie
142
142
  mcp({ action: "auth-start", server: "my-oauth-server" })
143
143
  ```
144
144
 
145
- Open the returned URL in your local browser. After approval, copy the full redirected localhost URL from the browser address bar (the page may fail to load locally) and complete the same pending auth flow:
145
+ Open the returned URL in your local browser. After approval, copy the full redirected callback URL from the browser address bar (the page may fail to load locally) and complete the same pending auth flow:
146
146
 
147
147
  ```
148
148
  mcp({
149
149
  action: "auth-complete",
150
150
  server: "my-oauth-server",
151
- args: { redirectUrl: "http://localhost:19876/callback?code=...&state=..." }
151
+ args: { redirectUrl: "http://127.0.0.1:19876/callback?code=...&state=..." }
152
152
  })
153
153
  ```
154
154
 
@@ -220,7 +220,7 @@ The adapter does not provide a default URL or host the public document: operator
220
220
  2. Registers a new client with:
221
221
  - `client_name`: configured `oauth.clientName` or "Pi Coding Agent"
222
222
  - `client_uri`: configured `oauth.clientUri` or the adapter repository URL
223
- - `redirect_uris`: `["http://localhost:<active-callback-port>/callback"]`, or the configured `oauth.redirectUri`
223
+ - `redirect_uris`: `["http://127.0.0.1:<active-callback-port>/callback"]`, or the configured `oauth.redirectUri`
224
224
  - `grant_types`: `["authorization_code", "refresh_token"]`
225
225
  3. Stores the registered client credentials and the redirect URIs returned by the authorization server
226
226
 
@@ -230,8 +230,8 @@ When a fresh browser auth starts, cached dynamic fallback client info with token
230
230
 
231
231
  A Node.js HTTP server runs on a loopback callback endpoint and handles the active callback path:
232
232
 
233
- - Dynamic registration starts the callback server only when auth begins, binds the default host `localhost`, and asks the OS for an available local port
234
- - Pre-registered clients (`oauth.clientId`) without `oauth.redirectUri` require the exact configured callback port from `MCP_OAUTH_CALLBACK_PORT` or the default `19876` on `localhost`
233
+ - Dynamic registration starts the callback server only when auth begins, binds the default host `127.0.0.1`, and asks the OS for an available local port
234
+ - Pre-registered clients (`oauth.clientId`) without `oauth.redirectUri` require the exact configured callback port from `MCP_OAUTH_CALLBACK_PORT` or the default `19876` on `127.0.0.1`
235
235
  - `oauth.redirectUri` binds the exact loopback host and path. An explicit port is bound exactly; `{port}` asks the OS for a port and is replaced with that assigned value before the URI is advertised to the provider
236
236
 
237
237
  - Handles `code`, `state`, and `error` parameters
@@ -320,7 +320,7 @@ Some servers require pre-registered clients. Obtain a client ID from your OAuth
320
320
 
321
321
  ### Callback server already in use
322
322
 
323
- Dynamic fallback browser OAuth uses a lazy OS-assigned port on the default loopback host (`localhost`), so the configured default port being busy should not block fallback registration.
323
+ Dynamic fallback browser OAuth uses a lazy OS-assigned port on the default loopback host (`127.0.0.1`), so the configured default port being busy should not block fallback registration.
324
324
 
325
325
  For pre-registered OAuth clients (`oauth.clientId`), the callback redirect URI must match the provider registration policy. Set `oauth.redirectUri` to the full fixed callback, such as Slack MCP's Claude-compatible `http://localhost:3118/callback`, use a loopback `{port}` URI when the provider explicitly permits dynamic RFC 8252 ports, or free/set `MCP_OAUTH_CALLBACK_PORT` when you rely on the default `/callback` path without an explicit redirect URI.
326
326
 
package/README.md CHANGED
@@ -263,6 +263,52 @@ await updateMcpOAuthTokensForUrl("jira", "https://jira.example.com/mcp", { acces
263
263
 
264
264
  The public subpath exposes only token read/update helpers plus a status helper. The async read path uses the adapter's refresh logic before it returns tokens. For a service-protected endpoint or a pre-registered OAuth client, pass the explicit refresh configuration as `getMcpOAuthTokensForUrl(name, url, { definition: { headers, oauth } })`. This optional configuration is never loaded from ambient config or stored with the tokens; headers are bound to the supplied MCP URL's origin. The helpers keep secure-store storage, URL binding, refresh persistence, chunk handling, legacy import, and fail-closed credential-store errors. They do not expose client registration secrets, PKCE verifiers, or OAuth state.
265
265
 
266
+ ### Host-managed embedding
267
+
268
+ Use `pi-mcp-adapter/host-managed` when an application embeds Pi and owns the MCP connection itself: it builds the transports, holds the credentials, records each call before it runs, and decides when the adapter starts and stops. For an ordinary integration that only supplies MCP configuration, use `createMcpAdapter` instead.
269
+
270
+ ```ts
271
+ import { createAgentSession, DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
272
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
273
+ import { createHostManagedMcpAdapter } from "pi-mcp-adapter/host-managed";
274
+
275
+ const adapter = createHostManagedMcpAdapter({
276
+ servers: {
277
+ github: {
278
+ createTransport: () => new StreamableHTTPClientTransport(new URL(url), { fetch: guardedFetch }),
279
+ tools: ["search_issues", "get_issue"], // omit to expose every listed tool
280
+ },
281
+ },
282
+ async onToolCall(call) {
283
+ await journal.record(call.toolCallId, call.server, call.tool, call.arguments);
284
+ return await call.dispatch();
285
+ },
286
+ });
287
+
288
+ await adapter.ready(); // connects, lists tools, and freezes the catalog
289
+ const loader = new DefaultResourceLoader({ extensionFactories: [adapter.extensionFactory] });
290
+ await loader.reload();
291
+ const { session } = await createAgentSession({ resourceLoader: loader });
292
+ await adapter.close(); // after the session settles
293
+ ```
294
+
295
+ Nothing connects until `ready()`, which calls each `createTransport` once. It rejects and closes the adapter if a server fails to start, a requested tool is missing, or two tools map to the same Pi name (`<server>_<tool>`). `extensionFactory` registers only the selected tools and throws before `ready()` resolves or after `close()`. `close()` refuses new calls, aborts in-flight ones, and closes every transport within five seconds. A dropped connection is never reopened.
296
+
297
+ Every call goes through the approval broker (`pi-mcp-adapter:tool-approval-request` on `pi.events`, origin `direct`). Only `allow_once` or `allow_for_session` lets it run. A denial, an `abstain`, or no handler at all refuses the call before `onToolCall` sees it. `onToolCall` receives the frozen arguments and input schema, the Pi tool-call id, an opaque `connectionId`, and a `dispatch()` that sends `tools/call` at most once. The result it returns goes through the normal output guard, whether raw or changed (for example after importing images). An `isError: true` result reaches the model as a tool error. `dispatch()` rejects with `HostManagedMcpError`. The raw error is kept only as its `cause`, and the model sees fixed text.
298
+
299
+ | `delivery` | Meaning |
300
+ |---|---|
301
+ | `not_sent` | The request never left the adapter; the tool did not run. |
302
+ | `may_have_run` | The request was sent and the outcome is unknown (timeout, cancellation, lost connection, HTTP error). The agent is told not to retry automatically. |
303
+ | `server_error` | The server answered with a JSON-RPC error (`protocolCode`). |
304
+ | `invalid_result` | The server answered, but the result failed validation, including the tool's `outputSchema`. The tool may have run. |
305
+
306
+ On `notifications/tools/list_changed` the adapter lists the server's tools again. A selected tool that changed or disappeared is retired for the life of the adapter; if the re-list fails, every selected tool on that server is retired. Calls wait for a running re-list and are refused as `not_sent` both before `onToolCall` and inside `dispatch()`. The model is told to start a new session.
307
+
308
+ After `dispatch()` resolves, `call.linkedResource(uri)` prepares a read of a `resource_link` from that result. The read needs its own broker approval (origin `resource`, args `{ uri }`) and uses the same connection. It returns a `readId` and a single-use `dispatch()`, so you can record the read before it happens. A URI that is not linked throws, and a read is refused as `not_sent` once the parent call has settled.
309
+
310
+ Supplied transports must not carry an OAuth `authProvider` or any retry or replay behavior. The adapter sends each `tools/call` at most once at its own layer. It turns off the SDK's multi-round-trip auto-fulfilment, passes the tool definition so the SDK cannot resend on a header mismatch, and never recovers a session or reconnects. This mode does not read config files, use OAuth or the credential store, register commands, UI, or the `mcp` proxy tool, expose resource tools or prompts, or answer sampling or elicitation requests.
311
+
266
312
  ### Runtime status snapshots
267
313
 
268
314
  Extensions can subscribe to the adapter's versioned shared event-bus channel instead of parsing `/mcp-adapter` or `mcp({})` output:
@@ -315,7 +361,7 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
315
361
  | `oauth.clientSecret` | OAuth client secret for confidential clients; a value beginning with `!` runs a command when OAuth authenticates, while `!!` escapes a literal leading `!`. Combining it with `oauth.clientMetadataUrl` requires an explicit `oauth.clientId`. |
316
362
  | `oauth.clientMetadataUrl` | Advanced opt-in for an operator-supplied public HTTPS Client ID Metadata Document (CIMD) URL with a non-root path. Used as the `client_id` when the authorization server advertises CIMD support; otherwise the adapter falls back to Dynamic Client Registration. The adapter does not provide or host a default document. |
317
363
  | `oauth.scope` | Requested OAuth scopes |
318
- | `oauth.redirectUri` | Redirect URI for browser OAuth. Dynamic clients normally omit it and use an OS-assigned localhost callback port. Local `http://` loopback URIs accept an explicit port or `{port}` for an OS-assigned port (for example, `http://127.0.0.1:{port}/callback`). Pre-registered `https://` callbacks use manual completion by pasting the full callback URL. |
364
+ | `oauth.redirectUri` | Redirect URI for browser OAuth. Dynamic clients normally omit it and use an OS-assigned port on the default `http://127.0.0.1:<port>/callback`. Local `http://` loopback URIs accept an explicit port or `{port}` for an OS-assigned port (for example, `http://localhost:{port}/callback` for a server registered only for `localhost`). Pre-registered `https://` callbacks use manual completion by pasting the full callback URL. |
319
365
  | `oauth.clientName` | Client display name advertised during Dynamic Client Registration fallback |
320
366
  | `oauth.clientUri` | Client homepage URI advertised during Dynamic Client Registration fallback. Defaults to `piConfig.clientUri` from the host's manifest when set, and is omitted rather than guessed under a rebranded host |
321
367
  | `oauth.logoUri` | Client logo URL advertised during Dynamic Client Registration fallback (RFC 7591 `logo_uri`). Must be an absolute `http(s)` URL — consent screens fetch it server-side, so local paths render nothing. Omitted from the registration request when unset |
@@ -437,7 +483,7 @@ Public servers are ready immediately. For OAuth servers, the same action opens t
437
483
 
438
484
  ### Remote/headless OAuth
439
485
 
440
- If Pi is running on a remote server, `/mcp-auth <server>` shows a clickable authorization URL first. Open it in your local browser and approve access, then select **Yes** in Pi to open the callback input. The browser may fail to load the localhost callback page because localhost refers to your workstation; copy the full URL from its address bar and paste it into Pi. The authorization screen closes automatically instead when the browser can reach Pi's callback directly.
486
+ If Pi is running on a remote server, `/mcp-auth <server>` shows a clickable authorization URL first. Open it in your local browser and approve access, then select **Yes** in Pi to open the callback input. The browser may fail to load the loopback callback page because `127.0.0.1` refers to your workstation; copy the full URL from its address bar and paste it into Pi. The authorization screen closes automatically instead when the browser can reach Pi's callback directly.
441
487
 
442
488
  The same flow is available through the proxy tool for non-interactive clients. By default, persistent OAuth requires an available OS credential store; on headless Linux that usually means an unlocked Secret Service/libsecret keyring. The adapter fails closed instead of falling back to plaintext credentials when the secure store is unavailable.
443
489
 
@@ -449,13 +495,13 @@ On Linux, if credential access fails because Pi inherited a revoked session keyr
449
495
  mcp({ action: "auth-start", server: "linear-server" })
450
496
  ```
451
497
 
452
- For a loopback redirect, the adapter attempts to open the returned authorization URL, watches the callback, completes token exchange, and sends an `mcp-oauth-status` event when authentication finishes. If Pi is remote or cannot open a browser, open the returned URL locally. If the browser cannot reach Pi's callback, copy the full localhost URL from the address bar and complete the flow in the same Pi session:
498
+ For a loopback redirect, the adapter attempts to open the returned authorization URL, watches the callback, completes token exchange, and sends an `mcp-oauth-status` event when authentication finishes. If Pi is remote or cannot open a browser, open the returned URL locally. If the browser cannot reach Pi's callback, copy the full callback URL from the address bar and complete the flow in the same Pi session:
453
499
 
454
500
  ```js
455
501
  mcp({
456
502
  action: "auth-complete",
457
503
  server: "linear-server",
458
- args: { redirectUrl: "http://localhost:19876/callback?code=...&state=..." }
504
+ args: { redirectUrl: "http://127.0.0.1:19876/callback?code=...&state=..." }
459
505
  })
460
506
  ```
461
507
 
@@ -463,7 +509,7 @@ You can also pass only the `code` query parameter with `args: { code: "..." }`.
463
509
 
464
510
  ### Project server trust
465
511
 
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.
512
+ 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
513
 
468
514
  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
515
 
@@ -837,6 +883,8 @@ Direct tools register from the metadata cache in the Pi agent dir (`~/.pi/agent/
837
883
 
838
884
  For faster startup, set `settings.deferWithMissingMetadata` to `true`. Servers with missing or invalid metadata (expired, mismatched, or non-cacheable) then contribute no tools, prompts, resources, or search entries until the first MCP operation starts the runtime and loads live metadata; the `mcp` gateway stays available. Because Pi cannot unregister slash commands, cached prompt commands also wait for live metadata under this setting. `eager`/`keep-alive` servers and cold `MCP_DIRECT_TOOLS` selections still start immediately.
839
885
 
886
+ After discovery, each runtime retains its own schema-bearing tool and resource catalogue. Another session overwriting the shared disk cache, disk-cache expiry (including `ttlMs: 0`), cache deletion, or an idle disconnect does not revoke that runtime's tools. Disk TTLs still govern reuse at startup; session catalogues are not persisted with extended TTLs. A failed resource discovery preserves that runtime's previously discovered resources for the same server configuration, whereas a successful empty resource list removes them. Current server identity, disabled state, tool filters and failure backoff still apply, and an authoritative empty discovery removes the tools. Replacing the runtime requires fresh discovery or valid disk metadata.
887
+
840
888
  Models sometimes encode an object or array argument as a JSON string. Set `settings.strictDirectToolArguments` to `true` to recover one such layer for schema-declared object and array properties, then validate the complete input against the advertised schema before execution.
841
889
 
842
890
  Set `settings.directToolResultDetails` to `"bounded"` when an extension needs structured MCP result fields in Pi's direct-tool result details. The same output guard limits apply. Small leading structured fields stay available, while large fields receive bounded summaries and the complete guarded result follows the output guard's spill-file policy. The default `"lean"` mode keeps the existing server and tool metadata only.
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 {};