pi-mcp-adapter 2.12.1 → 2.14.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,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.14.0] - 2026-07-25
11
+
12
+ ### Added
13
+ - Added the global `settings.showStatusIcon` opt-out for plain `MCP: ...` status and connection text while keeping the plug icon enabled by default. Thanks @vaultboy001 for issue #216.
14
+
15
+ ### Fixed
16
+ - Deferred implicit OAuth credential-store access until an HTTP server actually challenges for authentication, so unauthenticated remote Streamable HTTP servers work in headless environments. Thanks @vdom-1 for issue #218.
17
+ - Accepted draft-07 tool output schemas alongside JSON Schema 2020-12 while preserving structured-content validation. Thanks Daniel Marbach (@danielmarbach) for issue #217.
18
+
19
+ ## [2.13.0] - 2026-07-25
20
+
21
+ ### Added
22
+ - Added a versioned, sanitized MCP runtime status snapshot on Pi's shared event bus for extensions, without connecting lazy servers or exposing SDK internals. Thanks Ludev (@ludevdot) for issue #110.
23
+ - Added opt-in host-specific MCP config discovery with source/provenance and conflict reporting. Standard shared and Pi-owned config precedence remains unchanged, and external host files are never written or silently executed. Thanks @lsmir2 for issue #169.
24
+ - Added opt-in metadata-only JSONL MCP protocol tracing with bounded per-session files and redaction. Thanks @66-firebat for issue #45.
25
+ - Added per-server `includeTools` allowlists with exact-name and glob matching for proxy, direct-tool, and `/mcp` panel surfaces. Thanks Finn (@finnvyrn) for issue #136.
26
+ - Made `mcp({ connect: "server" })` refresh an already connected server instead of reusing stale tool metadata. Thanks Sebastiano Poggi (@rock3r) for issue #28 and @theflysurfer for the refresh analysis.
27
+ - Added a plug icon prefix to MCP footer status text. Thanks Felipe Cadal (@cadal-cw) for issue #145.
28
+ - Discovered user-global MCP configs from `~/.agents/mcp.json` and `~/.agents/mcp/mcp.json`. Thanks David Jadczyk (@davidjadczyk) for issue #117.
29
+ - Accepted JSONC-style comments and trailing commas in MCP JSON config files. Thanks @GoCoder7 for issue #124.
30
+
31
+ ### Changed
32
+ - Measured and spilled oversized raw MCP result details as compact JSON instead of pretty-printed JSON, reducing hot-path allocation and event-loop work. Thanks José Maia (@glitch-ux) for issue #214 and PR #215.
33
+ - Renamed generated MCP resource tools from `get_<resource>` to `read_<resource>` to match the MCP `resources/read` operation. Thanks @vdom-1 for issue #185.
34
+
35
+ ### Security
36
+ - Moved persistent OAuth credentials from plaintext `tokens.json` files into the operating system credential store, with one-way legacy import and fail-closed behavior when secure storage is unavailable. Thanks Sam Atkins (@atkinsam) for issue #180.
37
+
38
+ ### Fixed
39
+ - Skipped `resources/list` for MCP servers that do not advertise the `resources` capability, matching the existing prompt discovery gate and silencing the SDK v2 debug line that tools-only servers printed on every connect. Thanks Aleksandr Davydenko (@kotuke) for PR #213.
40
+ - Made the MCP footer show enabled configured servers as the primary count, with active connections as secondary state, so lazy servers no longer look broken before first use or after idle shutdown. Thanks blumlaut (@Blumlaut) for issue #93.
41
+ - Show the actual proxied MCP server/tool name in `mcp` tool results. Thanks Finn (@finnvyrn) and @dillontkh for issue #68.
42
+ - Removed the remaining TypeScript import cycles reported by `madge`. Thanks @av1155 for issue #101.
43
+
10
44
  ## [2.12.1] - 2026-07-24
11
45
 
12
46
  ### Fixed
package/README.md CHANGED
@@ -51,19 +51,23 @@ Preferred project config: `.mcp.json`
51
51
  }
52
52
  ```
53
53
 
54
- Preferred user-global shared config: `~/.config/mcp/mcp.json`
54
+ Preferred user-global shared config: `~/.config/mcp/mcp.json`. Pi also reads the tool-agnostic global paths `~/.agents/mcp.json` and `~/.agents/mcp/mcp.json`.
55
55
 
56
56
  Pi also reads Pi-owned override files for settings and host-specific compatibility:
57
57
 
58
58
  - `<Pi agent dir>/mcp.json` — Pi global override (`~/.pi/agent/mcp.json` by default)
59
59
  - `.pi/mcp.json` — Pi project override
60
60
 
61
+ Host-specific configs are detected and shown by `/mcp setup` and `pi-mcp-adapter init`, but they are not loaded automatically. To explicitly opt in to host-config fallback discovery, set `settings.hostConfigDiscovery` to `"on"` or run `pi-mcp-adapter init --discover-host-configs`. The default is `"off"`; `"prompt"` is available for integrations that want detection without activation. Host configs are lower precedence than every shared and Pi-owned source, and `/mcp setup` continues to offer explicit import adoption. Discovery reports source paths, provenance, and same-name conflicts; it never writes to external host files or silently launches commands from them.
62
+
61
63
  Precedence is:
62
64
 
63
65
  1. `~/.config/mcp/mcp.json`
64
- 2. `<Pi agent dir>/mcp.json`
65
- 3. `.mcp.json`
66
- 4. `.pi/mcp.json`
66
+ 2. `~/.agents/mcp.json`
67
+ 3. `~/.agents/mcp/mcp.json`
68
+ 4. `<Pi agent dir>/mcp.json`
69
+ 5. `.mcp.json`
70
+ 6. `.pi/mcp.json`
67
71
 
68
72
  `/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.
69
73
 
@@ -97,6 +101,8 @@ Use the shared MCP files when you want one setup to work across hosts, and Pi-ow
97
101
  | File | Purpose |
98
102
  |------|---------|
99
103
  | `~/.config/mcp/mcp.json` | User-global shared MCP config |
104
+ | `~/.agents/mcp.json` | User-global tool-agnostic MCP config |
105
+ | `~/.agents/mcp/mcp.json` | User-global tool-agnostic MCP config |
100
106
  | `.mcp.json` | Project-local shared MCP config |
101
107
  | `<Pi agent dir>/mcp.json` | Pi global override and compatibility imports (`~/.pi/agent/mcp.json` by default) |
102
108
  | `.pi/mcp.json` | Pi project override |
@@ -128,7 +134,23 @@ The package ships TypeScript source for Pi's source-loader and SDK integrations.
128
134
 
129
135
  A supplied `config` is a complete, isolated snapshot. It is not merged with files, imports, global config, project config, or `--mcp-config`, and it is never mutated. Each adapter factory and session receives its own clone, so separate integrations can use different servers and settings safely. In this mode, server status, reconnect, explicit `/mcp-auth <server>`, proxy calls, and direct tools continue to work; setup and no-argument auth/status panels report the limitation instead of discovering or writing ambient config.
130
136
 
131
- With `configPath` and no `config`, the adapter keeps normal file merge behavior, and that path takes precedence over argv and `--mcp-config`. The default export keeps the normal file-based behavior. OAuth credentials remain persistent and are shared by the configured server name by default; callers requiring strict same-name credential-file isolation should set distinct `settings.oauthDir` values. URL binding prevents credentials from being accepted for a different server URL. CSRF state and PKCE verifiers are flow-local, so concurrent authorization flows do not share transient secrets.
137
+ With `configPath` and no `config`, the adapter keeps normal file merge behavior, and that path takes precedence over argv and `--mcp-config`. The default export keeps the normal file-based behavior. OAuth credentials are stored in the operating system credential store and keyed by the configured server name; URL binding prevents credentials from being accepted for a different server URL. `settings.oauthDir` and `MCP_OAUTH_DIR` are used only as legacy plaintext import locations for older `tokens.json` files, not as credential namespaces. CSRF state and PKCE verifiers are flow-local, so concurrent authorization flows do not share transient secrets.
138
+
139
+ ### Runtime status snapshots
140
+
141
+ Extensions can subscribe to the adapter's versioned shared event-bus channel instead of parsing `/mcp` or `mcp({})` output:
142
+
143
+ ```ts
144
+ import { MCP_STATUS_EVENT, type McpStatusSnapshot } from "pi-mcp-adapter";
145
+
146
+ pi.events.on(MCP_STATUS_EVENT, (snapshot) => {
147
+ const status = snapshot as McpStatusSnapshot;
148
+ // status.servers contains connected, cached, failed, needs-auth,
149
+ // not-connected, or disabled entries.
150
+ });
151
+ ```
152
+
153
+ The snapshot is read-only machine-readable data with copied per-server entries. It includes `totalTools`, `totalResources`, `connectedCount`, and `disabledCount`; each server includes `name`, `status`, `toolCount`, and `disabled`, with `resourceCount` when known and `failedAgoSeconds` only for an active failure. Reading status never connects a lazy server, starts authentication, or exposes SDK clients, transports, credentials, or server definitions. An initial snapshot is emitted after initialization, updates are emitted for status and metadata changes, and an empty snapshot is emitted when the session shuts down.
132
154
 
133
155
  In the configuration examples below, `30000` is illustrative only. If `requestTimeoutMs` is omitted or set to `<= 0`, the MCP SDK default timeout is used.
134
156
 
@@ -170,15 +192,17 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
170
192
  | `requestTimeoutMs` | Request timeout in milliseconds for live MCP calls (overrides global; if omitted or `<= 0`, the MCP SDK default timeout is used) |
171
193
  | `exposeResources` | Expose MCP resources as tools (default: true) |
172
194
  | `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy |
173
- | `excludeTools` | `string[]` of tool names to hide (matches original names like `get_screenshot` and prefixed names like `figma_get_screenshot`) |
195
+ | `includeTools` | `string[]` of tool names or glob patterns to expose (matches original names like `get_screenshot`, generated resource names like `read_figjam`, and prefixed names like `figma_get_screenshot`) |
196
+ | `excludeTools` | `string[]` of tool names or glob patterns to hide (applied after `includeTools`) |
174
197
  | `debug` | Show server stderr (default: false) |
198
+ | `trace` | Enable metadata-only JSONL protocol tracing for this server; payloads, prompts, tool arguments/results, authorization data, and URLs are never persisted |
175
199
  | `disabled` | Keep the server visible in config and status, but prevent connections, authentication, tools, and resource calls (only literal `true` disables it) |
176
200
 
177
201
  For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact callback registered with the provider, for example `"http://localhost:3118/callback"`. Dynamic clients normally omit it and use a lazy OS-assigned localhost callback port.
178
202
 
179
203
  ### Remote/headless OAuth
180
204
 
181
- If Pi is running on a remote server and cannot open a local browser, start OAuth through the proxy tool:
205
+ If Pi is running on a remote server and cannot open a local browser, start OAuth through the proxy tool. 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:
182
206
 
183
207
  ```js
184
208
  mcp({ action: "auth-start", server: "linear-server" })
@@ -213,7 +237,15 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
213
237
  "toolPrefix": "server",
214
238
  "idleTimeout": 10,
215
239
  "requestTimeoutMs": 30000,
216
- "oauthDir": ".pi/mcp-oauth"
240
+ "showStatusIcon": true,
241
+ "hostConfigDiscovery": "off",
242
+ "oauthDir": ".pi/mcp-oauth",
243
+ "trace": {
244
+ "enabled": true,
245
+ "file": ".pi/mcp-traces/mcp.jsonl",
246
+ "maxBytes": 262144,
247
+ "maxEvents": 10000
248
+ }
217
249
  },
218
250
  "mcpServers": { }
219
251
  }
@@ -224,7 +256,9 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
224
256
  | `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), `"none"`, or `"mcp"` (prefixes with `mcp__`, using server-mode normalization) |
225
257
  | `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
226
258
  | `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
227
- | `oauthDir` | OAuth credential directory for this MCP config. Relative paths resolve from the active project cwd. `MCP_OAUTH_DIR` still wins when set. |
259
+ | `showStatusIcon` | Show the plug icon in MCP status and connection text (default: `true`). Set to `false` for plain `MCP: ...` text. |
260
+ | `hostConfigDiscovery` | Host-specific config policy: `"off"` (default), `"prompt"` (detect/report only), or `"on"` (explicitly load detected host configs as the lowest-precedence fallback) |
261
+ | `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. |
228
262
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
229
263
  | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
230
264
  | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
@@ -232,8 +266,9 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
232
266
  | `samplingAutoApprove` | Skip sampling confirmation prompts. Required for sampling in non-UI sessions (default: false). |
233
267
  | `elicitation` | Allow MCP servers to request user input through Pi dialogs (default: true when Pi UI is available). |
234
268
  | `outputGuard` | Guard oversized MCP output: `true` (default), `false`, or `{ maxBytes, maxLines, detailsMaxBytes }`. See [Output Guard](#output-guard). |
269
+ | `trace` | Opt-in metadata-only protocol tracing. Set `{ enabled: true }` globally or `trace: true` on a server. The per-session JSONL file defaults to `.pi/mcp-traces/`; `file`, `maxBytes` (default 262144), and `maxEvents` (default 10000) can be set. Raw MCP payloads, prompts, tool arguments/results, auth data, and URLs are never persisted. |
235
270
 
236
- Per-server `idleTimeout` and `requestTimeoutMs` override the global settings.
271
+ Per-server `idleTimeout` and `requestTimeoutMs` override the global settings. `debug` remains stderr display and is unrelated to protocol tracing.
237
272
 
238
273
  ### Output Guard
239
274
 
@@ -323,7 +358,21 @@ To set a global default for all servers:
323
358
 
324
359
  Per-server `directTools` overrides the global setting. The example above registers direct tools for every server except `huge-server`.
325
360
 
326
- To exclude specific tools while still using `directTools: true`, add `excludeTools` on the server:
361
+ To expose only a subset of a noisy server, add `includeTools` on the server. Values can be exact original names, generated resource names such as `read_<resource>`, prefixed names, or simple glob patterns:
362
+
363
+ ```json
364
+ {
365
+ "mcpServers": {
366
+ "dokploy": {
367
+ "url": "http://localhost:3845/mcp",
368
+ "directTools": true,
369
+ "includeTools": ["get_*", "dokploy_list_apps"]
370
+ }
371
+ }
372
+ }
373
+ ```
374
+
375
+ To hide specific tools while still using `directTools: true`, add `excludeTools` on the server. `excludeTools` is applied after `includeTools`:
327
376
 
328
377
  ```json
329
378
  {
@@ -331,13 +380,13 @@ To exclude specific tools while still using `directTools: true`, add `excludeToo
331
380
  "figma": {
332
381
  "url": "http://localhost:3845/mcp",
333
382
  "directTools": true,
334
- "excludeTools": ["get_figjam", "figma_get_code_connect_map"]
383
+ "excludeTools": ["read_figjam", "figma_get_code_connect_map"]
335
384
  }
336
385
  }
337
386
  }
338
387
  ```
339
388
 
340
- `excludeTools` filters direct tools, proxy search/list/describe, and the `/mcp` panel view.
389
+ `includeTools` and `excludeTools` filter direct tools, proxy search/list/describe, and the `/mcp` panel view.
341
390
 
342
391
  Each direct tool costs ~150-300 tokens in the system prompt (name + description + schema). Good for targeted sets of 5-20 tools. For servers with 75+ tools, stick with the proxy or pick specific tools with a `string[]`.
343
392
 
@@ -445,6 +494,8 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only
445
494
  | Auth start | `mcp({ action: "auth-start", server: "name" })` |
446
495
  | Auth complete | `mcp({ action: "auth-complete", server: "name", args: { redirectUrl: "..." } })` |
447
496
 
497
+ `mcp({ connect: "server-name" })` refreshes an already connected server, so new tools, resources, prompts, and instructions can load without restarting Pi.
498
+
448
499
  MCP proxy and direct-tool results render compactly by default: long text shows the first three terminal-wrapped lines plus a `Ctrl+O to expand` hint, while the full result remains available when expanded and is still returned unchanged to the model.
449
500
 
450
501
  Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are OR'd.
@@ -473,6 +524,10 @@ If `settings.autoAuth` is `true`, `mcp({ connect: ... })`, `mcp({ tool: ... })`,
473
524
 
474
525
  In interactive sessions, you can also authenticate from `/mcp` with `ctrl+a` or Enter on a server that needs auth. In remote/headless sessions, use the proxy tool's `auth-start` and `auth-complete` actions to copy the authorization URL locally and paste the redirect URL back into Pi. `/mcp-auth` without a server only opens a picker in the interactive UI.
475
526
 
527
+ ### MCP output schemas
528
+
529
+ Advertised tool `outputSchema` values support JSON Schema draft-07 and 2020-12. Unstamped schemas use the SDK's 2020-12 default. Returned `structuredContent` is validated against the advertised schema for both proxy and direct-tool calls.
530
+
476
531
  ## How It Works
477
532
 
478
533
  - One `mcp` tool in context (~200 tokens) instead of hundreds
package/cli.js CHANGED
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import os from "node:os";
6
6
  import { pathToFileURL } from "node:url";
7
+ import stripJsonComments from "strip-json-comments";
7
8
 
8
9
  const HOME = os.homedir();
9
10
 
@@ -18,6 +19,8 @@ const AGENT_DIR = process.env.PI_CODING_AGENT_DIR?.trim()
18
19
  : path.join(HOME, ".pi", "agent");
19
20
  const PI_CONFIG_PATH = path.join(AGENT_DIR, "mcp.json");
20
21
  const GENERIC_GLOBAL_CONFIG_PATH = path.join(HOME, ".config", "mcp", "mcp.json");
22
+ const AGENTS_GLOBAL_CONFIG_PATH = path.join(HOME, ".agents", "mcp.json");
23
+ const AGENTS_NESTED_GLOBAL_CONFIG_PATH = path.join(HOME, ".agents", "mcp", "mcp.json");
21
24
  const PROJECT_CONFIG_PATH = path.resolve(process.cwd(), ".mcp.json");
22
25
  const PROJECT_PI_CONFIG_PATH = path.resolve(process.cwd(), ".pi", "mcp.json");
23
26
 
@@ -33,6 +36,10 @@ const IMPORT_PATHS = {
33
36
  path.join(HOME, ".codex", "config.toml"),
34
37
  path.join(HOME, ".codex", "config.json"),
35
38
  ],
39
+ opencode: [
40
+ path.join(HOME, ".config", "opencode", "opencode.json"),
41
+ path.resolve(process.cwd(), "opencode.json"),
42
+ ],
36
43
  windsurf: [path.join(HOME, ".windsurf", "mcp.json")],
37
44
  vscode: [path.resolve(process.cwd(), ".vscode", "mcp.json")],
38
45
  };
@@ -44,10 +51,11 @@ function printHelp(log = console.log) {
44
51
  log("Then optionally run:");
45
52
  log(" pi-mcp-adapter init Detect host configs and scaffold Pi imports");
46
53
  log(" pi-mcp-adapter init --dry-run");
54
+ log(" pi-mcp-adapter init --discover-host-configs Opt in to host config fallback discovery");
47
55
  }
48
56
 
49
57
  function readJsonFile(filePath) {
50
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
58
+ return JSON.parse(stripJsonComments(fs.readFileSync(filePath, "utf-8"), { trailingCommas: true }));
51
59
  }
52
60
 
53
61
  function loadPiConfig() {
@@ -90,6 +98,8 @@ function printDiscovery(log, imports) {
90
98
 
91
99
  const paths = [
92
100
  ["User-global standard MCP", GENERIC_GLOBAL_CONFIG_PATH],
101
+ ["User-global .agents MCP", AGENTS_GLOBAL_CONFIG_PATH],
102
+ ["User-global .agents nested MCP", AGENTS_NESTED_GLOBAL_CONFIG_PATH],
93
103
  ["Pi global override", PI_CONFIG_PATH],
94
104
  ["Project standard MCP", PROJECT_CONFIG_PATH],
95
105
  ["Project Pi override", PROJECT_PI_CONFIG_PATH],
@@ -118,6 +128,7 @@ function writePiConfig(config) {
118
128
 
119
129
  async function runInit(argv, log = console.log) {
120
130
  const dryRun = argv.includes("--dry-run");
131
+ const discoverHostConfigs = argv.includes("--discover-host-configs");
121
132
  const foundImports = findAvailableImports();
122
133
  const existingConfig = loadPiConfig();
123
134
  const existingImports = new Set(existingConfig.imports ?? []);
@@ -127,7 +138,8 @@ async function runInit(argv, log = console.log) {
127
138
 
128
139
  printDiscovery(log, foundImports);
129
140
 
130
- if (importsToAdd.length === 0) {
141
+ const discoverySettingChanged = discoverHostConfigs && existingConfig.settings?.hostConfigDiscovery !== "on";
142
+ if (importsToAdd.length === 0 && !discoverySettingChanged) {
131
143
  log("\nNo Pi config changes needed.");
132
144
  log("Standard MCP configs are discovered automatically, and host-specific imports are already configured or unavailable.");
133
145
  return 0;
@@ -135,11 +147,17 @@ async function runInit(argv, log = console.log) {
135
147
 
136
148
  const nextConfig = {
137
149
  ...existingConfig,
138
- imports: [...existingImports, ...importsToAdd],
150
+ ...(discoverySettingChanged ? { settings: { ...existingConfig.settings, hostConfigDiscovery: "on" } } : {}),
151
+ ...(importsToAdd.length > 0 ? { imports: [...existingImports, ...importsToAdd] } : {}),
139
152
  mcpServers: existingConfig.mcpServers ?? {},
140
153
  };
141
154
 
142
- log(`\nDetected host configs to import into Pi: ${importsToAdd.join(", ")}`);
155
+ if (importsToAdd.length > 0) {
156
+ log(`\nDetected host configs to import into Pi: ${importsToAdd.join(", ")}`);
157
+ }
158
+ if (discoverySettingChanged) {
159
+ log("Opting in to host-specific fallback discovery (standard and Pi-owned configs still take precedence).");
160
+ }
143
161
 
144
162
  if (dryRun) {
145
163
  log(`Dry run: would update ${PI_CONFIG_PATH}`);
@@ -149,6 +167,9 @@ async function runInit(argv, log = console.log) {
149
167
  writePiConfig(nextConfig);
150
168
  log(`Updated ${PI_CONFIG_PATH}`);
151
169
  log("Pi will now keep reading standard MCP configs automatically, while these imports cover host-specific config formats.");
170
+ if (discoverySettingChanged) {
171
+ log("Host config discovery is explicit and does not write to or execute commands from external host files.");
172
+ }
152
173
  return 0;
153
174
  }
154
175
 
package/config.ts CHANGED
@@ -3,11 +3,16 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { parse as parseToml } from "smol-toml";
6
+ import stripJsonComments from "strip-json-comments";
6
7
  import { getAgentPath } from "./agent-dir.ts";
7
- import { isServerDisabled, type McpConfig, type ServerEntry, type McpSettings, type ImportKind, type ServerProvenance } from "./types.ts";
8
+ import { isServerDisabled, type HostConfigDiscovery, type McpConfig, type ServerEntry, type McpSettings, type ImportKind, type ServerProvenance } from "./types.ts";
8
9
  import { toStringRecord } from "./utils.ts";
9
10
 
10
11
  const GENERIC_GLOBAL_CONFIG_PATH = join(homedir(), ".config", "mcp", "mcp.json");
12
+ const AGENTS_GLOBAL_CONFIG_PATHS = [
13
+ join(homedir(), ".agents", "mcp.json"),
14
+ join(homedir(), ".agents", "mcp", "mcp.json"),
15
+ ] as const;
11
16
  const PROJECT_CONFIG_NAME = ".mcp.json";
12
17
  const PROJECT_PI_CONFIG_NAME = ".pi/mcp.json";
13
18
  const REPOPROMPT_BINARY_CANDIDATES = [
@@ -36,7 +41,7 @@ const IMPORT_PATHS: Record<ImportKind, string[]> = {
36
41
  };
37
42
 
38
43
  interface ConfigSourceSpec {
39
- id: "shared-global" | "pi-global" | "shared-project" | "pi-project";
44
+ id: "shared-global" | "agents-global" | "agents-nested-global" | "pi-global" | "shared-project" | "pi-project";
40
45
  label: string;
41
46
  readPath: string;
42
47
  writePath: string;
@@ -68,6 +73,16 @@ export interface ImportConfigSummary extends DiscoveredImportConfig {
68
73
  serverCount: number;
69
74
  }
70
75
 
76
+ export interface HostConfigSummary extends ImportConfigSummary {
77
+ active: boolean;
78
+ }
79
+
80
+ export interface McpConfigConflict {
81
+ serverName: string;
82
+ sources: Array<{ kind: "shared" | "pi" | "host"; path: string }>;
83
+ winner: { kind: "shared" | "pi" | "host"; path: string };
84
+ }
85
+
71
86
  export interface RepoPromptDiscovery {
72
87
  configured: boolean;
73
88
  configuredPath?: string;
@@ -80,6 +95,9 @@ export interface RepoPromptDiscovery {
80
95
  export interface McpDiscoverySummary {
81
96
  sources: ConfigDiscoverySource[];
82
97
  imports: ImportConfigSummary[];
98
+ hostConfigs: HostConfigSummary[];
99
+ hostConfigDiscovery: HostConfigDiscovery;
100
+ conflicts: McpConfigConflict[];
83
101
  hasAnyConfig: boolean;
84
102
  hasAnyDetectedPaths: boolean;
85
103
  hasSharedServers: boolean;
@@ -136,7 +154,8 @@ export function findAvailableImportConfigs(cwd = process.cwd()): DiscoveredImpor
136
154
  }
137
155
 
138
156
  export function getMcpDiscoverySummary(overridePath?: string, cwd = process.cwd()): McpDiscoverySummary {
139
- const sources = getConfigSources(overridePath, cwd).map((source) => {
157
+ const sourceSpecs = getConfigSources(overridePath, cwd);
158
+ const sources = sourceSpecs.map((source) => {
140
159
  const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
141
160
  return {
142
161
  id: source.id,
@@ -160,7 +179,8 @@ export function getMcpDiscoverySummary(overridePath?: string, cwd = process.cwd(
160
179
  } satisfies ImportConfigSummary;
161
180
  })
162
181
  .filter((value): value is ImportConfigSummary => value !== null);
163
-
182
+ const hostConfigDiscovery = getConfiguredHostConfigDiscovery(overridePath, cwd);
183
+ const hostConfigs = imports.map((entry) => ({ ...entry, active: hostConfigDiscovery === "on" }));
164
184
  const totalServerCount = sources.reduce((sum, source) => sum + source.serverCount, 0);
165
185
  const hasSharedServers = sources.some((source) => source.kind === "shared" && source.serverCount > 0);
166
186
  const hasPiOwnedServers = sources.some((source) => source.kind === "pi" && source.serverCount > 0);
@@ -170,6 +190,9 @@ export function getMcpDiscoverySummary(overridePath?: string, cwd = process.cwd(
170
190
  const summaryWithoutRepoPrompt = {
171
191
  sources,
172
192
  imports,
193
+ hostConfigs,
194
+ hostConfigDiscovery,
195
+ conflicts: getConfigConflicts(sourceSpecs, imports, cwd),
173
196
  hasAnyConfig,
174
197
  hasAnyDetectedPaths,
175
198
  hasSharedServers,
@@ -180,6 +203,8 @@ export function getMcpDiscoverySummary(overridePath?: string, cwd = process.cwd(
180
203
  const fingerprint = JSON.stringify({
181
204
  sources: sources.map((source) => [source.id, source.exists, source.serverCount]),
182
205
  imports: imports.map((entry) => [entry.kind, entry.path, entry.serverCount]),
206
+ hostConfigDiscovery,
207
+ conflicts: summaryWithoutRepoPrompt.conflicts,
183
208
  });
184
209
 
185
210
  return {
@@ -194,9 +219,16 @@ export function cloneMcpConfig(config: McpConfig): McpConfig {
194
219
  }
195
220
 
196
221
  export function loadMcpConfig(overridePath?: string, cwd = process.cwd()): McpConfig {
197
- let config: McpConfig = { mcpServers: {} };
198
-
199
- for (const source of getConfigSources(overridePath, cwd)) {
222
+ const sourceSpecs = getConfigSources(overridePath, cwd);
223
+ const hostConfigDiscovery = getConfiguredHostConfigDiscovery(overridePath, cwd);
224
+ // Host files are a lower-precedence fallback. This ordering means an opt-in
225
+ // discovery cannot override a shared or Pi-owned definition, and all normal
226
+ // URL-bound credential stripping remains in mergeServerMaps.
227
+ let config: McpConfig = hostConfigDiscovery === "on"
228
+ ? loadDiscoveredHostConfigs(cwd)
229
+ : { mcpServers: {} };
230
+
231
+ for (const source of sourceSpecs) {
200
232
  const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
201
233
  if (!loaded) continue;
202
234
  config = mergeConfigs(config, expandImports(loaded, cwd));
@@ -205,6 +237,75 @@ export function loadMcpConfig(overridePath?: string, cwd = process.cwd()): McpCo
205
237
  return config;
206
238
  }
207
239
 
240
+ function getConfiguredHostConfigDiscovery(overridePath?: string, cwd = process.cwd()): HostConfigDiscovery {
241
+ let configured: HostConfigDiscovery = "off";
242
+ for (const source of getConfigSources(overridePath, cwd)) {
243
+ const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
244
+ const value = loaded?.settings?.hostConfigDiscovery;
245
+ if (value === "off" || value === "prompt" || value === "on") configured = value;
246
+ }
247
+ return configured;
248
+ }
249
+
250
+ function loadDiscoveredHostConfigs(cwd: string): McpConfig {
251
+ let config: McpConfig = { mcpServers: {} };
252
+ for (const importKind of Object.keys(IMPORT_PATHS) as ImportKind[]) {
253
+ const imported = loadImportedConfig(importKind, cwd, `Failed to discover imported MCP config from ${importKind}:`);
254
+ if (!imported) continue;
255
+ config = mergeConfigs(config, {
256
+ mcpServers: extractServers(imported.value, importKind),
257
+ });
258
+ }
259
+ return config;
260
+ }
261
+
262
+ function getConfigConflicts(
263
+ sourceSpecs: ConfigSourceSpec[],
264
+ imports: ImportConfigSummary[],
265
+ cwd: string,
266
+ ): McpConfigConflict[] {
267
+ const seen = new Map<string, Array<{ kind: "shared" | "pi" | "host"; path: string }>>();
268
+ const record = (name: string, source: { kind: "shared" | "pi" | "host"; path: string }): void => {
269
+ const entries = seen.get(name) ?? [];
270
+ if (!entries.some((entry) => entry.kind === source.kind && entry.path === source.path)) entries.push(source);
271
+ seen.set(name, entries);
272
+ };
273
+
274
+ // Host candidates are listed first because, when enabled, they are the
275
+ // lowest-precedence fallback. The fixed IMPORT_PATHS order is deterministic.
276
+ for (const entry of imports) {
277
+ const imported = loadImportedConfig(entry.kind, cwd, `Failed to inspect imported MCP config from ${entry.kind}:`);
278
+ if (!imported) continue;
279
+ for (const name of Object.keys(extractServers(imported.value, entry.kind))) {
280
+ record(name, { kind: "host", path: imported.path });
281
+ }
282
+ }
283
+ for (const source of sourceSpecs) {
284
+ const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
285
+ if (!loaded) continue;
286
+ if (loaded.imports?.length) {
287
+ for (const importKind of loaded.imports) {
288
+ const imported = loadImportedConfig(importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:`);
289
+ if (!imported) continue;
290
+ for (const name of Object.keys(extractServers(imported.value, importKind))) {
291
+ record(name, { kind: "host", path: imported.path });
292
+ }
293
+ }
294
+ }
295
+ for (const name of Object.keys(loaded.mcpServers)) {
296
+ record(name, {
297
+ kind: source.shared ? "shared" : "pi",
298
+ path: source.readPath,
299
+ });
300
+ }
301
+ }
302
+
303
+ return [...seen.entries()]
304
+ .filter(([, sources]) => sources.length > 1)
305
+ .map(([serverName, sources]) => ({ serverName, sources, winner: sources[sources.length - 1]! }))
306
+ .sort((left, right) => left.serverName.localeCompare(right.serverName));
307
+ }
308
+
208
309
  function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSourceSpec[] {
209
310
  const userPath = getPiGlobalConfigPath(overridePath);
210
311
  const projectPath = getProjectConfigPath(cwd);
@@ -224,6 +325,20 @@ function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSou
224
325
  });
225
326
  }
226
327
 
328
+ for (const [index, agentsPath] of AGENTS_GLOBAL_CONFIG_PATHS.entries()) {
329
+ if (agentsPath === userPath || agentsPath === GENERIC_GLOBAL_CONFIG_PATH) continue;
330
+ sources.push({
331
+ id: index === 0 ? "agents-global" : "agents-nested-global",
332
+ label: index === 0 ? "user-global .agents MCP" : "user-global .agents nested MCP",
333
+ readPath: agentsPath,
334
+ writePath: userPath,
335
+ kind: "import",
336
+ importKind: index === 0 ? ".agents MCP config" : ".agents/mcp MCP config",
337
+ shared: true,
338
+ scope: "global",
339
+ });
340
+ }
341
+
227
342
  sources.push({
228
343
  id: "pi-global",
229
344
  label: "Pi global override",
@@ -365,9 +480,13 @@ function resolveImportCandidates(importKind: ImportKind, cwd: string): string[]
365
480
  });
366
481
  }
367
482
 
483
+ function parseJsonConfig(raw: string): unknown {
484
+ return JSON.parse(stripJsonComments(raw, { trailingCommas: true }));
485
+ }
486
+
368
487
  function readImportedConfig(path: string): unknown {
369
488
  const raw = readFileSync(path, "utf-8");
370
- return path.endsWith(".toml") ? parseToml(raw) : JSON.parse(raw);
489
+ return path.endsWith(".toml") ? parseToml(raw) : parseJsonConfig(raw);
371
490
  }
372
491
 
373
492
  function loadImportedConfig(
@@ -417,7 +536,7 @@ function readValidatedConfig(path: string, label: string): McpConfig | null {
417
536
  if (!existsSync(path)) return null;
418
537
 
419
538
  try {
420
- return validateConfig(JSON.parse(readFileSync(path, "utf-8")));
539
+ return validateConfig(parseJsonConfig(readFileSync(path, "utf-8")));
421
540
  } catch (error) {
422
541
  console.warn(`Failed to load ${label}:`, error);
423
542
  return null;
@@ -667,7 +786,7 @@ function readRawConfigObject(filePath: string): Record<string, unknown> {
667
786
  if (!existsSync(filePath)) return {};
668
787
 
669
788
  try {
670
- const raw = JSON.parse(readFileSync(filePath, "utf-8"));
789
+ const raw = parseJsonConfig(readFileSync(filePath, "utf-8"));
671
790
  return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
672
791
  } catch {
673
792
  return {};
@@ -714,7 +833,7 @@ export function writeProjectServerDisabledOverride(
714
833
  let raw: Record<string, unknown> = {};
715
834
  if (existsSync(filePath)) {
716
835
  try {
717
- const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
836
+ const parsed = parseJsonConfig(readFileSync(filePath, "utf-8"));
718
837
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
719
838
  throw new Error("root value must be an object");
720
839
  }
@@ -903,6 +1022,18 @@ export function getServerProvenance(overridePath?: string, cwd = process.cwd()):
903
1022
  const provenance = new Map<string, ServerProvenance>();
904
1023
  const userPath = getPiGlobalConfigPath(overridePath);
905
1024
 
1025
+ if (getConfiguredHostConfigDiscovery(overridePath, cwd) === "on") {
1026
+ for (const importKind of Object.keys(IMPORT_PATHS) as ImportKind[]) {
1027
+ const imported = loadImportedConfig(importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:`);
1028
+ if (!imported) continue;
1029
+ for (const name of Object.keys(extractServers(imported.value, importKind))) {
1030
+ // Keep writes inside Pi-owned storage even though the source is external.
1031
+ // Later import kinds win in the same deterministic order as loadDiscoveredHostConfigs.
1032
+ provenance.set(name, { path: userPath, kind: "import", importKind });
1033
+ }
1034
+ }
1035
+ }
1036
+
906
1037
  for (const source of getConfigSources(overridePath, cwd)) {
907
1038
  const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
908
1039
  if (!loaded) continue;