pi-mcp-adapter 2.33.0 → 2.34.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 (49) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +14 -3
  3. package/agent-plugin-loader.ts +69 -30
  4. package/agent-plugin-provenance.ts +36 -0
  5. package/commands.ts +15 -4
  6. package/config.ts +121 -8
  7. package/direct-tool-surface.ts +250 -0
  8. package/direct-tools.ts +9 -249
  9. package/dist/agent-plugin-loader.js +74 -32
  10. package/dist/agent-plugin-loader.js.map +1 -1
  11. package/dist/agent-plugin-provenance.d.ts +1 -0
  12. package/dist/agent-plugin-provenance.js +31 -0
  13. package/dist/agent-plugin-provenance.js.map +1 -0
  14. package/dist/config.d.ts +1 -1
  15. package/dist/config.js +125 -7
  16. package/dist/config.js.map +1 -1
  17. package/dist/metadata-cache.js +4 -3
  18. package/dist/metadata-cache.js.map +1 -1
  19. package/dist/package-mcp-loader.js +4 -16
  20. package/dist/package-mcp-loader.js.map +1 -1
  21. package/dist/types.d.ts +7 -1
  22. package/dist/types.js.map +1 -1
  23. package/dist/utils.d.ts +3 -0
  24. package/dist/utils.js +29 -1
  25. package/dist/utils.js.map +1 -1
  26. package/host-html-template.ts +20 -19
  27. package/http-ca.ts +40 -5
  28. package/index.ts +444 -168
  29. package/init.ts +10 -7
  30. package/lazy-loader.ts +7 -0
  31. package/mcp-auth-fetch.ts +7 -19
  32. package/mcp-auth-flow.ts +189 -89
  33. package/mcp-auth.ts +322 -75
  34. package/mcp-oauth-provider.ts +54 -59
  35. package/mcp-setup-panel.ts +4 -2
  36. package/metadata-cache.ts +4 -3
  37. package/namespace-tools.ts +60 -2
  38. package/oauth.ts +1 -4
  39. package/package-mcp-loader.ts +4 -16
  40. package/package.json +7 -8
  41. package/prompts.ts +35 -3
  42. package/sandbox-proxy-template.ts +20 -93
  43. package/server-manager.ts +101 -26
  44. package/types.ts +7 -1
  45. package/ui-server.ts +28 -27
  46. package/utils.ts +28 -1
  47. package/OAUTH.md +0 -367
  48. package/mcp-refresh-lock.ts +0 -62
  49. package/oauth-diagnostics.ts +0 -31
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
+ ## [2.34.0] - 2026-09-14
11
+
12
+ ### Highlights
13
+
14
+ - Start Pi faster while keeping cached MCP tools, prompts, and commands immediately available.
15
+ - Connect to more OAuth servers with Client ID Metadata Documents.
16
+ - Store OAuth credentials securely in encrypted files when Windows OpenSSH or a headless session cannot use the OS credential store.
17
+ - Load shared configuration and Agent Plugin MCP servers more reliably.
18
+ - Use MCP Apps, private HTTPS servers, and frequently changing tool catalogs with fewer connection problems.
19
+
20
+ ### Added
21
+
22
+ - Windows OpenSSH/network logons can explicitly select externally keyed AES-256-GCM OAuth credential files with `settings.oauthCredentialStore: "encrypted-file"`; error 1312 now points to this option, while the OS store remains the default with no automatic fallback. Thanks to [@pierreh](https://github.com/pierreh) for issue #574.
23
+ - OAuth servers can explicitly opt into operator-hosted Client ID Metadata Documents (SEP-991) with `oauth.clientMetadataUrl`; URL-only/default configurations continue using Dynamic Client Registration. Existing DCR refresh credentials get their normal refresh attempt before migration to CIMD after invalidation. Thanks to [@dsluo](https://github.com/dsluo) for PR #571.
24
+ - User-global or explicitly selected config can opt in to bounded ancestor `.mcp.json` and `<configDir>/mcp.json` discovery with `settings.ancestorConfigRoots`. Discovery is off by default; project files cannot enable or widen it, and the deepest matching existing directory under `$HOME` bounds farthest-first loading. Thanks to [@johnhenaot](https://github.com/johnhenaot) for PR #555.
25
+
26
+ ### Changed
27
+
28
+ - Runtime-heavy MCP modules now load only when first needed, while cached tools, prompts, and commands remain immediately available. Thanks to [@thefakepaulgg](https://github.com/thefakepaulgg) for PR #576.
29
+ - OAuth dependencies now use published MCP SDK releases again, restoring normal npm installs and removing the standalone native-addon dependency. Cross-process OAuth transaction serialization remains unavailable until the official SDK exposes the required support.
30
+
31
+ ### Fixed
32
+
33
+ - Built-in Agent Plugin MCP definitions now preserve literal values through connection and cache handling, validate manifest field types, resolve contained paths through symlinks, and expand plugin placeholders once. Thanks to [@cheetahbyte](https://github.com/cheetahbyte) for #570.
34
+ - Empty, whitespace-only, or comments-only optional MCP config layers are now treated as absent, allowing other precedence layers to load without a warning. Thanks to [@RobertoNegro](https://github.com/RobertoNegro) for PR #567.
35
+ - macOS Keychain and Linux Secret Service now keep ordinary OAuth records in one credential item and compact existing chunks on ordinary reads; Windows Credential Manager retains chunking. Thanks to [@jploskonka](https://github.com/jploskonka) for PR #560.
36
+ - Configured direct tools now hot-load from fresh live catalogs even when a server advertises `ttlMs: 0`, while persisted zero-TTL metadata remains non-cacheable. Thanks to [@dsluo](https://github.com/dsluo) for PR #562. (#561)
37
+ - Switching a server between transports (HTTP to stdio command or socket) now drops an inherited `bearerTokenStore` flag alongside the other URL-bound credential fields. Thanks to [@zhulinchng](https://github.com/zhulinchng) for PR #552.
38
+ - Per-origin `caFile` trust now routes same-origin requests through the bundled undici fetch so the custom CA dispatcher matches the fetch implementation on newer Node releases (Node 26 ships undici v8 while the dependency pins undici v6); previously every `caFile` connection failed with `UND_ERR_INVALID_ARG`. Thanks to [@zhulinchng](https://github.com/zhulinchng) for PR #550.
39
+ - MCP Apps now load provider-declared asset, connection, and frame domains through a session-bound sandbox resource navigation with response-level CSP enforcement. Thanks to [@tekumara](https://github.com/tekumara) for #548.
40
+
10
41
  ## [2.33.0] - 2026-09-10
11
42
 
12
43
  ### Highlights
package/README.md CHANGED
@@ -71,6 +71,10 @@ Precedence is (later entries win):
71
71
  5. `.mcp.json`
72
72
  6. `.pi/mcp.json`
73
73
 
74
+ Ancestor discovery is off by default. To opt in, set `settings.ancestorConfigRoots` in a user-global config above, or in the explicitly selected `--mcp-config`/`configPath` file, for example `"ancestorConfigRoots": ["~/work/team"]`. Each root must be an explicit absolute path or `~/...`, resolve to an existing directory under `$HOME`, and contain the canonical cwd. If several roots match, only the nearest (deepest) is used. Project `.mcp.json` and `.pi/mcp.json` files cannot enable discovery or extend the boundary.
75
+
76
+ Within the selected root, existing `.mcp.json` and `<configDir>/mcp.json` (normally `.pi/mcp.json`) files load between steps 4 and 5, from the root through parent(cwd), farthest first. Nearer directories override farther ones, Pi overrides shared config within each directory, and cwd files win over ancestors. Search never goes above the configured root or `$HOME`; the boundary limits discovery but is not a file-ownership or symlink-target sandbox. Only configure roots whose project files you trust. `/mcp setup` write targets and project-local `/mcp disable` and `/mcp enable` overrides are unchanged.
77
+
74
78
  `/mcp disable <server>` and `/mcp enable <server>` persist only the `disabled` field in the project-local `.pi/mcp.json`, which is the highest-precedence Pi layer. Enabling removes the project flag when lower layers are enabled, or writes `false` when needed to override a disabled lower source. This applies even when the effective server came from a shared global/project file, an imported host config, or `configPath`; the source file is never rewritten and credentials are never copied. Run `/reload` after changing the flag so registered tool surfaces are refreshed. The manual equivalent is to add `{ "disabled": true }` to a server in any normal MCP config. Supplied in-memory `createMcpAdapter({ config })` configurations are isolated and do not read or write this project override; the commands are unavailable in that mode.
75
79
 
76
80
  Servers are **lazy by default** — they won't connect until you actually call one of their tools. The adapter caches tool metadata so search and describe work without live connections.
@@ -300,8 +304,9 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
300
304
  | `caFile` | HTTPS HTTP servers only: local PEM CA certificate/bundle, e.g. `"caFile": "~/certs/local-ca.pem"`. Replaces (does not add to) default roots for the resolved MCP origin. Supports environment interpolation and `~`; relative paths use the process working directory. Unreadable/invalid files fail closed; hostname and certificate-expiry verification remain enabled. |
301
305
  | `auth` | `"bearer"` or `"oauth"` |
302
306
  | `oauth.grantType` | `"authorization_code"` (default) or `"client_credentials"` for non-interactive machine auth |
303
- | `oauth.clientId` | Pre-registered OAuth client ID. MCP 2026 prefers pre-registered clients or Client ID Metadata Documents; this adapter falls back to Dynamic Client Registration when the ID is omitted and the server supports it. |
304
- | `oauth.clientSecret` | OAuth client secret for confidential clients; a value beginning with `!` runs a command when OAuth authenticates, while `!!` escapes a literal leading `!` |
307
+ | `oauth.clientId` | Pre-registered OAuth client ID. Takes precedence over `oauth.clientMetadataUrl` when both are set. |
308
+ | `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`. |
309
+ | `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. |
305
310
  | `oauth.scope` | Requested OAuth scopes |
306
311
  | `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. |
307
312
  | `oauth.clientName` | Client display name advertised during Dynamic Client Registration fallback |
@@ -349,6 +354,8 @@ If an internal authorization server publishes mismatched OAuth metadata and cann
349
354
 
350
355
  If an MCP server does not publish usable protected-resource metadata, set `oauth.authServerMetadataUrl` to its HTTPS OAuth/OIDC authorization-server metadata document. The configured document is used authoritatively, while issuer validation remains enabled by default. This is trusted configuration; use it only for a metadata endpoint you control or explicitly trust.
351
356
 
357
+ URL-only/default Pi OAuth continues to use Dynamic Client Registration; there is no project-hosted default Client ID Metadata Document. To explicitly opt into CIMD as an advanced operator setting, publish the OAuth client metadata at a stable public HTTPS URL and set `oauth.clientMetadataUrl` to that exact URL. The adapter uses it as the URL-based `client_id` only when discovered authorization-server metadata contains `client_id_metadata_document_supported: true`; servers without CIMD support continue through Dynamic Client Registration. An explicit `oauth.clientId` always wins, and `oauth.clientSecret` without that explicit ID cannot be combined with `oauth.clientMetadataUrl`.
358
+
352
359
  #### Stdio environment boundaries
353
360
 
354
361
  `inheritEnv: false` applies only to the actual MCP stdio server process and, for `protocolVersion: "auto"` or `"2026-07-28"`, its disposable SDK negotiation sibling. It does not change the default for other servers: omitting the field or setting it to `true` preserves the existing full host-environment inheritance. With `false`, the SDK still supplies its platform defaults and configured `env` values remain explicit overlays; the result is not a literally empty environment and is not an OS sandbox.
@@ -405,7 +412,9 @@ Public servers are ready immediately. For OAuth servers, the same action opens t
405
412
 
406
413
  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.
407
414
 
408
- The same flow is available through the proxy tool for non-interactive clients. Persistent OAuth still 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.
415
+ 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.
416
+
417
+ Windows OpenSSH network logons can return `ERROR_NO_SUCH_LOGON_SESSION` (1312) because Credential Manager is unavailable to that logon. For this case, explicitly set `settings.oauthCredentialStore` to `"encrypted-file"` and inject `PI_MCP_ADAPTER_OAUTH_FILE_KEY` as canonical base64 for 32 random bytes (`node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`). Encrypted entries live under the Pi agent directory's `mcp-oauth-encrypted/`; keep the key separately and reauthenticate after loss or rotation. This backend never falls back to the OS store or imports legacy plaintext; see [OAuth](OAUTH.md#token-storage) for its security model.
409
418
 
410
419
  On Linux, if credential access fails because Pi inherited a revoked session keyring, the adapter uses a best-effort recovery path through `keyctl session - node <packaged helper>` so explicit re-authentication can write fresh credentials without killing a long-lived tmux server. This path requires `keyctl` and `node` on `PATH`; missing, locked, or otherwise unavailable credential stores still fail closed.
411
420
 
@@ -475,9 +484,11 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
475
484
  | `collapsedResultLines` | Number of result text lines to show before expansion: `1`, `2`, or `3`. Defaults to `1` in compact mode and `3` in boxed mode. |
476
485
  | `notifyOnStartupConnect` | Show successful startup connection notices (default: `true`). Set to `false` to suppress routine `MCP: N servers connected (M tools)` notices. Connection errors and authentication warnings remain visible. |
477
486
  | `hostConfigDiscovery` | Host-specific config policy: `"off"` (default), `"prompt"` (detect/report only), or `"on"` (explicitly load detected host configs as the lowest-precedence fallback) |
487
+ | `ancestorConfigRoots` | Trusted absolute or `~/...` roots for opt-in ancestor config discovery. Only user-global or explicitly selected config may set it; the deepest root containing cwd is used. |
478
488
  | `agentPluginPaths` | Agent Plugins package directories to load MCP servers from. Relative paths resolve from the active project cwd. |
479
489
  | `approveTools` | `true` to require approval before every MCP tool call, or an array of glob patterns such as `["github_delete_*", "notion_update_*"]`. Per-server `approveTools` overrides this. |
480
490
  | `oauthDir` | Legacy OAuth `tokens.json` import directory for this MCP config. Relative paths resolve from the active project cwd. `MCP_OAUTH_DIR` still wins when set. Persistent OAuth credentials are stored in the OS credential store, not this directory. |
491
+ | `oauthCredentialStore` | Set explicitly to `"encrypted-file"` for externally keyed AES-256-GCM storage (notably Windows OpenSSH network logons). Requires `PI_MCP_ADAPTER_OAUTH_FILE_KEY`; absent uses the OS credential store. |
481
492
  | `mcpServers.<name>.oauth.authorizationParams` | Extra authorization URL parameters for provider-specific OAuth extensions. Flow-owned parameters such as `client_id`, `redirect_uri`, `scope`, `state`, `code_challenge`, `response_type`, and `resource` cannot be overridden. |
482
493
  | `directTools` | Global default for all servers (default: false). `true`, `false`, or `"search"`. Per-server overrides this. |
483
494
  | `strictDirectToolArguments` | Validate direct-tool inputs against their advertised schemas and recover one JSON string layer for object and array properties (default: false). |
@@ -1,7 +1,9 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { isAbsolute, relative, resolve, sep } from "node:path";
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, resolve } from "node:path";
3
3
  import { getAgentPath } from "./agent-dir.ts";
4
+ import { markBuiltInAgentPlugin } from "./agent-plugin-provenance.ts";
4
5
  import type { McpConfig, ServerEntry } from "./types.ts";
6
+ import { resolveRealContainedPath } from "./utils.ts";
5
7
 
6
8
  const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
7
9
  const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
@@ -21,6 +23,8 @@ const PLUGIN_MANIFEST_FIELDS = new Set([
21
23
  const MCP_CONFIG_FIELDS = new Set(["$schema", "mcpServers"]);
22
24
  const STDIO_FIELDS = new Set(["type", "command", "args", "env", "cwd"]);
23
25
  const HTTP_FIELDS = new Set(["type", "url", "headers"]);
26
+ const STRING_MANIFEST_FIELDS = ["version", "description", "homepage", "repository", "license"] as const;
27
+ const AUTHOR_FIELDS = new Set(["name", "email", "url"]);
24
28
 
25
29
  interface AgentPluginManifest {
26
30
  name: string;
@@ -50,7 +54,7 @@ export function loadAgentPluginConfigs(paths: unknown, cwd = process.cwd()): Mcp
50
54
 
51
55
  export function getAgentPluginSummaries(paths: unknown, cwd = process.cwd()): AgentPluginSummary[] {
52
56
  return getPluginPaths(paths).map(path => {
53
- const pluginRoot = resolvePluginPath(path, cwd);
57
+ const pluginRoot = resolvePluginRoot(path, cwd);
54
58
  const loaded = loadAgentPluginMcpConfig(path, cwd);
55
59
  const manifest = loaded ? readPluginManifest(pluginRoot, false) : null;
56
60
  return {
@@ -66,7 +70,7 @@ function getPluginPaths(paths: unknown): string[] {
66
70
  }
67
71
 
68
72
  function loadAgentPluginMcpConfig(path: string, cwd: string): McpConfig | null {
69
- const pluginRoot = resolvePluginPath(path, cwd);
73
+ const pluginRoot = resolvePluginRoot(path, cwd);
70
74
  const manifest = readPluginManifest(pluginRoot, true);
71
75
  if (!manifest) return null;
72
76
 
@@ -76,10 +80,15 @@ function loadAgentPluginMcpConfig(path: string, cwd: string): McpConfig | null {
76
80
  console.warn(`Agent Plugin ${manifest.name} has invalid MCP config: mcp.json is not a regular file`);
77
81
  return { mcpServers: {} };
78
82
  }
83
+ const resolvedMcpPath = resolveRealContainedPath(pluginRoot, mcpPath);
84
+ if (!resolvedMcpPath) {
85
+ console.warn(`Agent Plugin ${manifest.name} has invalid MCP config: mcp.json must stay inside the plugin directory`);
86
+ return { mcpServers: {} };
87
+ }
79
88
 
80
89
  let raw: unknown;
81
90
  try {
82
- raw = JSON.parse(readFileSync(mcpPath, "utf8"));
91
+ raw = JSON.parse(readFileSync(resolvedMcpPath, "utf8"));
83
92
  } catch (error) {
84
93
  console.warn(`Agent Plugin ${manifest.name} has invalid MCP config: failed to parse mcp.json`, error);
85
94
  return { mcpServers: {} };
@@ -98,10 +107,15 @@ function readPluginManifest(pluginRoot: string, report: boolean): AgentPluginMan
98
107
  if (report) console.warn(`Agent Plugin at ${pluginRoot} is invalid: plugin.json is not a regular file`);
99
108
  return null;
100
109
  }
110
+ const resolvedManifestPath = resolveRealContainedPath(pluginRoot, manifestPath);
111
+ if (!resolvedManifestPath) {
112
+ if (report) console.warn(`Agent Plugin at ${pluginRoot} is invalid: plugin.json must stay inside the plugin directory`);
113
+ return null;
114
+ }
101
115
 
102
116
  let raw: unknown;
103
117
  try {
104
- raw = JSON.parse(readFileSync(manifestPath, "utf8"));
118
+ raw = JSON.parse(readFileSync(resolvedManifestPath, "utf8"));
105
119
  } catch (error) {
106
120
  if (report) console.warn(`Agent Plugin at ${pluginRoot} is invalid: failed to parse plugin.json`, error);
107
121
  return null;
@@ -125,6 +139,20 @@ function readPluginManifest(pluginRoot: string, report: boolean): AgentPluginMan
125
139
  if (report) console.warn(`Agent Plugin at ${pluginRoot} is invalid: plugin.json name is invalid`);
126
140
  return null;
127
141
  }
142
+ for (const field of STRING_MANIFEST_FIELDS) {
143
+ if (manifest[field] !== undefined && typeof manifest[field] !== "string") {
144
+ if (report) console.warn(`Agent Plugin ${manifest.name} is invalid: plugin.json ${field} must be a string`);
145
+ return null;
146
+ }
147
+ }
148
+ if (manifest.keywords !== undefined && (!Array.isArray(manifest.keywords) || manifest.keywords.some(value => typeof value !== "string"))) {
149
+ if (report) console.warn(`Agent Plugin ${manifest.name} is invalid: plugin.json keywords must be an array of strings`);
150
+ return null;
151
+ }
152
+ if (manifest.author !== undefined && !isValidManifestAuthor(manifest.author)) {
153
+ if (report) console.warn(`Agent Plugin ${manifest.name} is invalid: plugin.json author is invalid`);
154
+ return null;
155
+ }
128
156
  if (manifest.extensions !== undefined && (!manifest.extensions || typeof manifest.extensions !== "object" || Array.isArray(manifest.extensions))) {
129
157
  if (report) console.warn(`Agent Plugin ${manifest.name} ignores non-object plugin.json extensions`);
130
158
  }
@@ -205,14 +233,14 @@ function translateStdioServer(
205
233
  const env = translateEnv(raw.env, manifest, serverName);
206
234
  if (env === null) return null;
207
235
 
208
- const command = raw.command.startsWith("./") ? resolveContainedPath(pluginRoot, raw.command, pluginRoot) : raw.command;
209
- if (command === null) return skipServer(manifest, serverName, "command must stay inside the plugin directory");
236
+ const command = raw.command.startsWith("./") ? resolveRealContainedPath(pluginRoot, resolve(pluginRoot, raw.command)) : raw.command;
237
+ if (command === null) return skipServer(manifest, serverName, "command must resolve to an accessible path inside the plugin directory");
210
238
 
211
239
  const pluginDataDir = getAgentPath("agent-plugin-data", manifest.name);
212
240
  const cwd = resolvePluginCwd(raw.cwd, pluginRoot, pluginDataDir);
213
- if (cwd === null) return skipServer(manifest, serverName, "cwd must be plugin-relative, PLUGIN_ROOT-rooted, or PLUGIN_DATA-rooted");
241
+ if (cwd === null) return skipServer(manifest, serverName, "cwd must resolve from an allowed root and stay contained");
214
242
 
215
- return {
243
+ return markBuiltInAgentPlugin({
216
244
  command,
217
245
  args: args.map(value => expandPluginPlaceholders(value, pluginRoot, pluginDataDir)),
218
246
  env: {
@@ -223,7 +251,7 @@ function translateStdioServer(
223
251
  cwd,
224
252
  pluginDataDir,
225
253
  literalEnv: true,
226
- };
254
+ }, ["args", "env", "cwd"]);
227
255
  }
228
256
 
229
257
  function translateHttpServer(
@@ -240,11 +268,11 @@ function translateHttpServer(
240
268
  const headers = translateHeaders(raw.headers, manifest, serverName);
241
269
  if (headers === null) return null;
242
270
 
243
- return {
271
+ return markBuiltInAgentPlugin({
244
272
  url: raw.url,
245
273
  httpTransport: type,
246
274
  ...(headers ? { headers } : {}),
247
- };
275
+ }, headers ? ["headers"] : []);
248
276
  }
249
277
 
250
278
  function formatAgentPluginServerName(pluginName: string, serverName: string): string {
@@ -273,7 +301,7 @@ function translateEnv(value: unknown, manifest: AgentPluginManifest, serverName:
273
301
  console.warn(`Agent Plugin ${manifest.name} skips invalid MCP server ${serverName}: env must be an object of strings`);
274
302
  return null;
275
303
  }
276
- const env: Record<string, string> = {};
304
+ const entries: Array<[string, string]> = [];
277
305
  for (const [key, entry] of Object.entries(value)) {
278
306
  if (key === "PLUGIN_ROOT" || key === "PLUGIN_DATA") {
279
307
  console.warn(`Agent Plugin ${manifest.name} skips invalid MCP server ${serverName}: env must not define ${key}`);
@@ -283,9 +311,9 @@ function translateEnv(value: unknown, manifest: AgentPluginManifest, serverName:
283
311
  console.warn(`Agent Plugin ${manifest.name} skips invalid MCP server ${serverName}: env values must be strings`);
284
312
  return null;
285
313
  }
286
- env[key] = entry;
314
+ entries.push([key, entry]);
287
315
  }
288
- return env;
316
+ return Object.fromEntries(entries);
289
317
  }
290
318
 
291
319
  function translateHeaders(value: unknown, manifest: AgentPluginManifest, serverName: string): Record<string, string> | undefined | null {
@@ -294,7 +322,7 @@ function translateHeaders(value: unknown, manifest: AgentPluginManifest, serverN
294
322
  console.warn(`Agent Plugin ${manifest.name} skips invalid MCP server ${serverName}: headers must be an object of strings`);
295
323
  return null;
296
324
  }
297
- const headers: Record<string, string> = {};
325
+ const entries: Array<[string, string]> = [];
298
326
  const seen = new Set<string>();
299
327
  for (const [key, entry] of Object.entries(value)) {
300
328
  if (typeof entry !== "string") {
@@ -307,8 +335,9 @@ function translateHeaders(value: unknown, manifest: AgentPluginManifest, serverN
307
335
  return null;
308
336
  }
309
337
  seen.add(normalized);
310
- headers[key] = entry;
338
+ entries.push([key, entry]);
311
339
  }
340
+ const headers = Object.fromEntries(entries);
312
341
  try {
313
342
  new Headers(headers);
314
343
  } catch {
@@ -324,6 +353,20 @@ function resolvePluginPath(path: string, cwd: string): string {
324
353
  return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
325
354
  }
326
355
 
356
+ function resolvePluginRoot(path: string, cwd: string): string {
357
+ const resolved = resolvePluginPath(path, cwd);
358
+ try {
359
+ return realpathSync(resolved);
360
+ } catch {
361
+ return resolved;
362
+ }
363
+ }
364
+
365
+ function isValidManifestAuthor(value: unknown): boolean {
366
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
367
+ return Object.entries(value).every(([key, entry]) => AUTHOR_FIELDS.has(key) && typeof entry === "string");
368
+ }
369
+
327
370
  function isBareCommand(command: string): boolean {
328
371
  return !command.includes("/") && !command.includes("\\") && !command.includes("${PLUGIN_ROOT}") && !command.includes("${PLUGIN_DATA}");
329
372
  }
@@ -331,27 +374,23 @@ function isBareCommand(command: string): boolean {
331
374
  function resolvePluginCwd(value: unknown, pluginRoot: string, pluginDataDir: string): string | null {
332
375
  if (value === undefined) return pluginRoot;
333
376
  if (typeof value !== "string") return null;
334
- if (value.startsWith("./")) return resolveContainedPath(pluginRoot, value, pluginRoot);
335
- if (value === "${PLUGIN_ROOT}" || value.startsWith("${PLUGIN_ROOT}/")) {
336
- return resolveContainedPath(pluginRoot, value.replace("${PLUGIN_ROOT}", "."), pluginRoot);
377
+ const expanded = expandPluginPlaceholders(value, pluginRoot, pluginDataDir);
378
+ if (value.startsWith("./") || value === "${PLUGIN_ROOT}" || value.startsWith("${PLUGIN_ROOT}/")) {
379
+ return resolveRealContainedPath(pluginRoot, resolve(pluginRoot, expanded));
337
380
  }
338
381
  if (value === "${PLUGIN_DATA}" || value.startsWith("${PLUGIN_DATA}/")) {
339
- return resolveContainedPath(pluginDataDir, value.replace("${PLUGIN_DATA}", "."), pluginDataDir);
382
+ return resolvePluginDataCwd(pluginDataDir, expanded);
340
383
  }
341
384
  return null;
342
385
  }
343
386
 
344
- function resolveContainedPath(root: string, value: string, containmentRoot: string): string | null {
345
- const resolved = resolve(root, value);
346
- const rel = relative(containmentRoot, resolved);
347
- if (rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep) && !isAbsolute(rel))) return resolved;
348
- return null;
387
+ function resolvePluginDataCwd(pluginDataDir: string, expanded: string): string | null {
388
+ if (existsSync(pluginDataDir) && !resolveRealContainedPath(dirname(pluginDataDir), pluginDataDir)) return null;
389
+ return resolveRealContainedPath(pluginDataDir, resolve(pluginDataDir, expanded), true);
349
390
  }
350
391
 
351
392
  function expandPluginPlaceholders(value: string, pluginRoot: string, pluginDataDir: string): string {
352
- return value
353
- .replaceAll("${PLUGIN_ROOT}", pluginRoot)
354
- .replaceAll("${PLUGIN_DATA}", pluginDataDir);
393
+ return value.replace(/\$\{PLUGIN_(ROOT|DATA)\}/g, (_, name: string) => name === "ROOT" ? pluginRoot : pluginDataDir);
355
394
  }
356
395
 
357
396
  function isValidAgentPluginUrl(value: string): boolean {
@@ -0,0 +1,36 @@
1
+ import type { ServerEntry } from "./types.ts";
2
+
3
+ const LITERAL_PLUGIN_FIELDS = ["args", "env", "cwd", "headers"] as const;
4
+ type LiteralPluginField = typeof LITERAL_PLUGIN_FIELDS[number];
5
+ const BUILT_IN_AGENT_PLUGIN = Symbol("built-in-agent-plugin");
6
+ type BuiltInAgentPluginEntry = ServerEntry & { [BUILT_IN_AGENT_PLUGIN]?: ReadonlySet<LiteralPluginField> };
7
+
8
+ /** @internal */
9
+ export function markBuiltInAgentPlugin(definition: ServerEntry, fields: LiteralPluginField[]): ServerEntry {
10
+ (definition as BuiltInAgentPluginEntry)[BUILT_IN_AGENT_PLUGIN] = new Set(fields);
11
+ return definition;
12
+ }
13
+
14
+ /** @internal */
15
+ export function isBuiltInAgentPlugin(definition: ServerEntry, field: LiteralPluginField): boolean {
16
+ return (definition as BuiltInAgentPluginEntry)[BUILT_IN_AGENT_PLUGIN]?.has(field) === true;
17
+ }
18
+
19
+ /** @internal */
20
+ export function cloneBuiltInAgentPluginEntry(source: ServerEntry): ServerEntry | undefined {
21
+ const fields = LITERAL_PLUGIN_FIELDS.filter(field => Object.hasOwn(source, field) && isBuiltInAgentPlugin(source, field));
22
+ if (fields.length === 0) return undefined;
23
+ return markBuiltInAgentPlugin(structuredClone(source), fields);
24
+ }
25
+
26
+ /** @internal */
27
+ export function mergeBuiltInAgentPluginEntries(base: ServerEntry, next: ServerEntry): ServerEntry {
28
+ const merged = { ...base, ...next };
29
+ delete (merged as BuiltInAgentPluginEntry)[BUILT_IN_AGENT_PLUGIN];
30
+ const fields = LITERAL_PLUGIN_FIELDS.filter(field => {
31
+ const owner = Object.hasOwn(next, field) ? next : base;
32
+ return Object.hasOwn(owner, field) && isBuiltInAgentPlugin(owner, field);
33
+ });
34
+ if (fields.length > 0) markBuiltInAgentPlugin(merged, fields);
35
+ return merged;
36
+ }
package/commands.ts CHANGED
@@ -311,9 +311,9 @@ export async function authenticateServer(
311
311
  }
312
312
 
313
313
  ui.setStatus("mcp-auth", `Authenticating ${serverName}...`);
314
- const authStorageOptions = getAuthStorageOptions(config.settings?.oauthDir, cwd);
314
+ const authStorageOptions = getAuthStorageOptions(config.settings?.oauthDir, cwd, config.settings?.oauthCredentialStore);
315
315
  const status = await authenticate(serverName, serverUrl, definition, {
316
- ...(authStorageOptions.baseDir ? { authStorageOptions } : {}),
316
+ ...(Object.keys(authStorageOptions).length > 0 ? { authStorageOptions } : {}),
317
317
  onAuthorizationUrl: () => {},
318
318
  onAuthorizationInput: async (authorizationUrl, inputSignal) => {
319
319
  if (inputSignal.aborted) return undefined;
@@ -365,12 +365,23 @@ export async function logoutServer(
365
365
  const signal = state.owner?.signal;
366
366
  try {
367
367
  await state.manager.close(serverName);
368
+ } catch (error) {
369
+ if (isAbortError(error, signal)) throw error;
370
+ const message = error instanceof Error ? error.message : String(error);
371
+ if (ui) {
372
+ ui.notify(`Failed to close OAuth server "${serverName}"; credentials were not cleared: ${sanitizeTerminalText(message)}`, "error");
373
+ }
374
+ return { ok: false, message };
375
+ }
376
+
377
+ state.owner?.throwIfInactive();
378
+ try {
368
379
  await removeAuth(serverName, { authStorageOptions: state.authStorageOptions, signal, runtime: state.oauthRuntime });
369
380
  } catch (error) {
370
381
  if (isAbortError(error, signal)) throw error;
371
382
  const message = error instanceof Error ? error.message : String(error);
372
383
  if (ui) {
373
- ui.notify(`Failed to disconnect or clear OAuth credentials for "${serverName}": ${sanitizeTerminalText(message)}`, "error");
384
+ ui.notify(`Failed to clear OAuth credentials for "${serverName}": ${sanitizeTerminalText(message)}`, "error");
374
385
  }
375
386
  return { ok: false, message };
376
387
  }
@@ -460,7 +471,7 @@ function buildSharedConfigNoticeLines(configOverridePath: string | undefined, cw
460
471
  const discovery = getMcpStandardConfigSummary(configOverridePath, cwd);
461
472
  const onboardingState = loadOnboardingState();
462
473
  const sharedSources = discovery.sources.filter((source) =>
463
- (source.id === "shared-project" || source.id === "shared-global") && source.serverCount > 0,
474
+ (source.id === "shared-project" || source.id === "shared-project-ancestor" || source.id === "shared-global") && source.serverCount > 0,
464
475
  );
465
476
  if (sharedSources.length === 0 || onboardingState.sharedConfigHintShown) {
466
477
  return { lines: [], fingerprint: null };
package/config.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // config.ts - Config loading with import support
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
2
+ import { existsSync, readFileSync, realpathSync, statSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { parse as parseToml } from "smol-toml";
6
+ import stripJsonComments from "strip-json-comments";
6
7
  import { getAgentPath, getConfigDirName } from "./agent-dir.ts";
7
8
  import { getAgentPluginSummaries, loadAgentPluginConfigs, type AgentPluginSummary } from "./agent-plugin-loader.ts";
9
+ import { cloneBuiltInAgentPluginEntry, isBuiltInAgentPlugin, mergeBuiltInAgentPluginEntries } from "./agent-plugin-provenance.ts";
8
10
  import { loadClaudePluginBundles } from "./claude-plugin-loader.ts";
9
11
  import { loadPackageMcpConfigs } from "./package-mcp-loader.ts";
10
12
  import { formatServerNamespace, isServerDisabled, type ClaudePluginConfig, type HostConfigDiscovery, type McpConfig, type ServerEntry, type McpSettings, type ImportKind, type ServerProvenance } from "./types.ts";
@@ -93,7 +95,7 @@ const IMPORT_PATHS: Record<ImportKind, string[]> = {
93
95
  };
94
96
 
95
97
  interface ConfigSourceSpec {
96
- id: "shared-global" | "agents-global" | "agents-nested-global" | "pi-global" | "shared-project" | "pi-project";
98
+ id: "shared-global" | "agents-global" | "agents-nested-global" | "pi-global" | "shared-project-ancestor" | "pi-project-ancestor" | "shared-project" | "pi-project";
97
99
  label: string;
98
100
  readPath: string;
99
101
  writePath: string;
@@ -312,7 +314,12 @@ export function getMcpDiscoverySummary(
312
314
  }
313
315
 
314
316
  export function cloneMcpConfig(config: McpConfig): McpConfig {
315
- return structuredClone(config);
317
+ const cloned = structuredClone(config);
318
+ for (const [name, source] of Object.entries(config.mcpServers)) {
319
+ const builtInClone = cloneBuiltInAgentPluginEntry(source);
320
+ if (builtInClone) cloned.mcpServers[name] = builtInClone;
321
+ }
322
+ return cloned;
316
323
  }
317
324
 
318
325
  export function loadMcpConfig(overridePath?: string, cwd = process.cwd()): McpConfig {
@@ -502,6 +509,43 @@ function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSou
502
509
  scope: "global",
503
510
  });
504
511
 
512
+ // Compare file identities so symlink aliases cannot reload a global source
513
+ // at ancestor precedence. Keep original paths for display and writes.
514
+ const reservedPaths = new Set([
515
+ ...sources.map((source) => getConfigPathIdentity(source.readPath)),
516
+ getConfigPathIdentity(projectPath),
517
+ getConfigPathIdentity(projectPiPath),
518
+ ]);
519
+ // Only user-global files (including an explicit override) may opt in to
520
+ // ancestor discovery. Project files cannot extend this trust boundary.
521
+ const ancestorSources = new Map<string, ConfigSourceSpec>();
522
+ const descriptors = [
523
+ { id: "shared-project-ancestor", label: "ancestor standard MCP", path: getProjectConfigPath, shared: true },
524
+ { id: "pi-project-ancestor", label: "ancestor Pi override", path: getProjectPiConfigPath, shared: false },
525
+ ] as const;
526
+ const ancestorRoot = getConfiguredAncestorRoot(sources, cwd);
527
+ if (ancestorRoot) {
528
+ for (const dir of getAncestorProjectDirs(cwd, ancestorRoot)) {
529
+ for (const descriptor of descriptors) {
530
+ const path = descriptor.path(dir);
531
+ const identity = getConfigPathIdentity(path);
532
+ if (reservedPaths.has(identity) || !existsSync(path)) continue;
533
+ // Reinsert aliases at their nearest precedence position.
534
+ ancestorSources.delete(identity);
535
+ ancestorSources.set(identity, {
536
+ id: descriptor.id,
537
+ label: descriptor.label,
538
+ readPath: path,
539
+ writePath: path,
540
+ kind: "project",
541
+ shared: descriptor.shared,
542
+ scope: "project",
543
+ });
544
+ }
545
+ }
546
+ }
547
+ sources.push(...ancestorSources.values());
548
+
505
549
  if (projectPath !== userPath) {
506
550
  sources.push({
507
551
  id: "shared-project",
@@ -529,6 +573,68 @@ function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSou
529
573
  return sources;
530
574
  }
531
575
 
576
+ function getConfigPathIdentity(path: string): string {
577
+ try {
578
+ return realpathSync(path);
579
+ } catch {
580
+ // Missing or inaccessible paths still participate in lexical deduplication.
581
+ return resolve(path);
582
+ }
583
+ }
584
+
585
+ function isWithin(base: string, target: string): boolean {
586
+ const path = relative(base, target);
587
+ return path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
588
+ }
589
+
590
+ function getConfiguredAncestorRoot(globalSources: ConfigSourceSpec[], cwd: string): string | undefined {
591
+ let configured: unknown;
592
+ for (const source of globalSources) {
593
+ const roots = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`)?.settings?.ancestorConfigRoots;
594
+ if (roots !== undefined) configured = roots;
595
+ }
596
+ if (configured === undefined || (Array.isArray(configured) && configured.length === 0)) return undefined;
597
+ if (!Array.isArray(configured)) {
598
+ console.warn("Invalid settings.ancestorConfigRoots: expected an array of paths");
599
+ return undefined;
600
+ }
601
+
602
+ const home = getConfigPathIdentity(resolve(homedir()));
603
+ const canonicalCwd = getConfigPathIdentity(resolve(cwd));
604
+ const valid: string[] = [];
605
+ for (const entry of configured) {
606
+ const expanded = typeof entry === "string" && entry.startsWith("~/")
607
+ ? join(homedir(), entry.slice(2))
608
+ : entry;
609
+ if (typeof expanded !== "string" || !isAbsolute(expanded)) {
610
+ console.warn(`Invalid settings.ancestorConfigRoots entry ${JSON.stringify(entry)}: expected an absolute path or ~/...`);
611
+ continue;
612
+ }
613
+ try {
614
+ const root = realpathSync(expanded);
615
+ if (!statSync(root).isDirectory() || !isWithin(home, root) || !isWithin(root, canonicalCwd)) throw new Error();
616
+ valid.push(root);
617
+ } catch {
618
+ console.warn(`Invalid settings.ancestorConfigRoots entry ${JSON.stringify(entry)}: expected an existing directory under HOME containing cwd`);
619
+ }
620
+ }
621
+ return valid.sort((left, right) => right.length - left.length)[0];
622
+ }
623
+
624
+ function getAncestorProjectDirs(cwd: string, root: string): string[] {
625
+ const start = getConfigPathIdentity(resolve(cwd));
626
+ const dirs: string[] = [];
627
+ let current = dirname(start);
628
+ while (isWithin(root, current)) {
629
+ dirs.unshift(current);
630
+ if (current === root) break;
631
+ const parent = dirname(current);
632
+ if (parent === current) break;
633
+ current = parent;
634
+ }
635
+ return dirs;
636
+ }
637
+
532
638
  function isExclusiveConfigMode(): boolean {
533
639
  return process.env.PI_MCP_CONFIG_MODE?.trim().toLowerCase() === "exclusive";
534
640
  }
@@ -574,7 +680,7 @@ function mergeServerMaps(
574
680
  baseEntry = { ...existing };
575
681
  for (const field of [
576
682
  "url", "headers", "requestHeadersCommand", "caFile", "auth", "bearerToken",
577
- "bearerTokenEnv", "oauth", "httpTransport", "socket",
683
+ "bearerTokenEnv", "bearerTokenStore", "oauth", "httpTransport", "socket",
578
684
  ] as const) {
579
685
  delete baseEntry[field];
580
686
  }
@@ -590,7 +696,7 @@ function mergeServerMaps(
590
696
  for (const field of [
591
697
  "command", "args", "env", "cwd", "pluginDataDir", "literalEnv", "inheritEnv", "url",
592
698
  "headers", "requestHeadersCommand", "caFile", "auth", "bearerToken", "bearerTokenEnv",
593
- "oauth", "httpTransport",
699
+ "bearerTokenStore", "oauth", "httpTransport",
594
700
  ] as const) {
595
701
  delete baseEntry[field];
596
702
  }
@@ -604,7 +710,11 @@ function mergeServerMaps(
604
710
  delete baseEntry.oauth;
605
711
  }
606
712
  }
607
- merged[name] = { ...baseEntry, ...definition };
713
+ if (existing && Object.hasOwn(definition, "env") && isBuiltInAgentPlugin(existing, "env") && !Object.hasOwn(definition, "literalEnv")) {
714
+ if (baseEntry === existing) baseEntry = { ...existing };
715
+ delete baseEntry.literalEnv;
716
+ }
717
+ merged[name] = mergeBuiltInAgentPluginEntries(baseEntry, definition);
608
718
  }
609
719
  return merged;
610
720
  }
@@ -719,7 +829,9 @@ function readValidatedConfig(path: string, label: string): McpConfig | null {
719
829
  if (!existsSync(path)) return null;
720
830
 
721
831
  try {
722
- return validateConfig(parseJsonWithComments(readFileSync(path, "utf-8")));
832
+ const text = readFileSync(path, "utf-8");
833
+ if (stripJsonComments(text, { trailingCommas: true }).trim() === "") return null;
834
+ return validateConfig(parseJsonWithComments(text));
723
835
  } catch (error) {
724
836
  console.warn(`Failed to load ${label}:`, error);
725
837
  return null;
@@ -905,6 +1017,7 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
905
1017
  mapped.oauth = {
906
1018
  ...(typeof oauth.clientId === "string" ? { clientId: oauth.clientId } : {}),
907
1019
  ...(typeof oauth.clientSecret === "string" ? { clientSecret: oauth.clientSecret } : {}),
1020
+ ...(typeof oauth.clientMetadataUrl === "string" ? { clientMetadataUrl: oauth.clientMetadataUrl } : {}),
908
1021
  ...(typeof oauth.scope === "string" ? { scope: oauth.scope } : {}),
909
1022
  ...(typeof oauth.authServerMetadataUrl === "string" ? { authServerMetadataUrl: oauth.authServerMetadataUrl } : {}),
910
1023
  ...(typeof oauth.skipIssuerMetadataValidation === "boolean"