pi-mcp-adapter 2.25.0 → 2.26.1

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,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.26.1] - 2026-08-18
11
+
12
+ ### Fixed
13
+ - Scoped “Allow for session” tool approvals to the approved arguments, so one approval no longer applies to later calls with different inputs. Thanks to [@spaceshipmike](https://github.com/spaceshipmike) for #367.
14
+ - Stopped MCP panel commands from hanging in RPC, JSON, and print modes when terminal-only UI is unavailable. Thanks to [@shixin-guo](https://github.com/shixin-guo) for PR #365.
15
+ - Kept compact MCP result rows useful by showing a short input preview and skipping leading blank output in collapsed previews.
16
+ - Recovered MCP gateway requests nested inside proxy `args` instead of showing status, and now rejects invalid nested gateway requests with guidance. Thanks to [@ibrmora](https://github.com/ibrmora) for #363.
17
+ - Kept remote keep-alive tool catalogs fresh across server restarts, so long-lived Pi sessions can discover replacement tools without restarting. Thanks to [@dmorn](https://github.com/dmorn) for #369 and PR #370.
18
+
19
+ ## [2.26.0] - 2026-08-14
20
+
21
+ ### Added
22
+ - Added per-server `requestHeadersCommand` support for deriving fail-closed HTTP headers from the exact outbound request on every Streamable HTTP or SSE call. Thanks @kgreen18 for PR #353.
23
+ - Added `settings.warnOnLargeDirectTools` to suppress the advisory for 75 or more resolved direct tools. Thanks @Roshvan for issue #358.
24
+
25
+ ### Changed
26
+ - Refined request-header command result handling types without changing runtime behavior.
27
+
28
+ ### Fixed
29
+ - Matched adapter-owned config and state paths to the host agent directory when Pi is rebranded, including its environment override and config directory. Thanks @mindplay-dk for issue #356.
30
+ - Avoided O(tools²) cross-server tool-name collision scans at startup by skipping collision candidates when selectors are absent and sharing one indexed candidate set when `includeTools` or `excludeTools` is configured. Thanks @mjlbach for PR #357 and @cataldoc for issue #354.
31
+
10
32
  ## [2.25.0] - 2026-08-13
11
33
 
12
34
  ### Added
package/README.md CHANGED
@@ -207,6 +207,7 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
207
207
  | `cwd` | Working directory; supports `${VAR}`, `$env:VAR`, and `~` expansion |
208
208
  | `url` | HTTP endpoint (StreamableHTTP with SSE fallback); supports raw `${VAR}` and `$env:VAR` interpolation, and missing URL variables fail before any request is sent |
209
209
  | `headers` | HTTP headers; supports `${VAR}` and `$env:VAR` interpolation. A value beginning with `!` runs a command when the HTTP server connects or OAuth authenticates; use `!!` for a literal leading `!`. |
210
+ | `requestHeadersCommand` | Trusted executable run for every HTTP request. It receives a versioned JSON envelope containing `method`, `url`, and the exact `bodyBase64` on stdin, and must return a JSON object of headers on stdout. `command`, `args`, and `env` support environment interpolation. Use for caller-bound request signatures; failures stop the request. |
210
211
  | `auth` | `"bearer"` or `"oauth"` |
211
212
  | `oauth.grantType` | `"authorization_code"` (default) or `"client_credentials"` for non-interactive machine auth |
212
213
  | `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. |
@@ -292,8 +293,10 @@ You can also pass only the `code` query parameter with `args: { code: "..." }`.
292
293
 
293
294
  - **`lazy`** (default) — Don't connect at startup. Connect on first tool call. Disconnect after idle timeout. Cached metadata keeps search/list working without connections.
294
295
  - **`eager`** — Connect at startup but don't auto-reconnect if the connection drops. No idle timeout by default (set `idleTimeout` explicitly to enable).
295
- - **`keep-alive`** — Connect at startup. Auto-reconnect via health checks. No idle timeout. Use for servers you always need available.
296
- - **`lazy-keep-alive`** — Don't connect at startup. Connect on first tool call (like `lazy`). Once spawned, never idle-shut down and auto-reconnect via health checks if the process dies (like `keep-alive`). Use for servers that are expensive to start but should stay resident after their first use.
296
+ - **`keep-alive`** — Connect at startup. Remote HTTP servers refresh their tool catalog during health checks, before user input, and before adapter-triggered turns, reconnecting when the server reports that the session expired. No idle timeout. Use for servers you always need available.
297
+ - **`lazy-keep-alive`** — Don't connect at startup. Connect on first tool call (like `lazy`). Once spawned, never idle-shut down and use the same catalog refresh and reconnect checks as `keep-alive`. Use for servers that are expensive to start but should stay resident after their first use.
298
+
299
+ For remote HTTP keep-alive servers, the authoritative `tools/list` refresh is also the fallback when `list_changed` notifications are unavailable or their stream is lost. Each `tools/list` or `ping` request is capped at 5 seconds, up to 10 servers are checked concurrently, and transient failures use bounded backoff. A successful refresh updates metadata without reconnecting; a response proving that the HTTP session expired triggers a full reconnect and reinstalls the notification handlers. Dynamic direct-tool registration follows the refreshed metadata unless `freezeDirectTools` is enabled.
297
300
 
298
301
  When any enabled server uses `eager` or `keep-alive`, initialization also starts when the extension loads. This supports hosts that embed Pi programmatically and never emit `session_start`; if a session does start later, the session-owned runtime supersedes the load-time runtime.
299
302
 
@@ -310,6 +313,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
310
313
  "toolResultRendering": "compact",
311
314
  "collapsedResultLines": 1,
312
315
  "notifyOnStartupConnect": true,
316
+ "warnOnLargeDirectTools": true,
313
317
  "hostConfigDiscovery": "off",
314
318
  "approveTools": ["github_delete_*", "notion_update_*"],
315
319
  "oauthDir": ".pi/mcp-oauth",
@@ -340,6 +344,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
340
344
  | `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. |
341
345
  | `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. |
342
346
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
347
+ | `warnOnLargeDirectTools` | Show the advisory when 75 or more direct tools resolve (default: `true`). Set to `false` to suppress only this advisory. |
343
348
  | `freezeDirectTools` | Keep direct-tool registration stable after the initial sync so automatic reconnects and list-change notifications do not rebuild the system prompt. Use `mcp({ connect: "server" })` or `/mcp reconnect <server>` to refresh deliberately. Default: false. |
344
349
  | `scriptMode` | Register the MCP-only `mcpScript` plain-JavaScript tool (default: true). Set to `false` to hide it. |
345
350
  | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
@@ -544,7 +549,7 @@ To hide specific tools while still using `directTools: true`, add `excludeTools`
544
549
 
545
550
  `includeTools` and `excludeTools` filter direct tools, proxy search/list/describe, and the `/mcp` panel view.
546
551
 
547
- 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[]`. If 75+ direct tools resolve, the adapter prints a warning but still registers the tools you configured.
552
+ 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[]`. If 75+ direct tools resolve, the adapter prints an advisory but still registers the tools you configured. Set `settings.warnOnLargeDirectTools` to `false` to suppress this advisory.
548
553
 
549
554
  Direct tools register from the metadata cache in the Pi agent dir (`~/.pi/agent/mcp-cache.json` by default, or `$PI_CODING_AGENT_DIR/mcp-cache.json` when set), so no server connections are needed at startup. On the first session after adding `directTools` to a new server, the cache won't exist yet — tools fall back to proxy-only while the cache populates, then the extension hot-loads the refreshed direct tools into the current session. Servers that advertise MCP list-change notifications refresh the current session when their tool or resource list changes. On Pi versions that expose `pi.unregisterTool()`, stale direct tools are removed from the registry during refresh; older Pi versions still deactivate them from the active tool set. To force a refresh: `/mcp reconnect <server>`.
550
555
 
@@ -655,7 +660,7 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only
655
660
 
656
661
  `mcp({ connect: "server-name" })` refreshes an already connected server, so new tools, resources, prompts, and instructions can load without restarting Pi.
657
662
 
658
- MCP proxy and direct-tool results use compact self-rendered rows by default. Collapsed success output shows the call title and the first result line, with a `Ctrl+O to expand` hint when more text is hidden. The full result remains available when expanded and is still returned unchanged to the model. Set `settings.toolResultRendering` to `"boxed"` to restore the legacy boxed Pi row, or set `settings.collapsedResultLines` to `2` or `3` when you want more collapsed text.
663
+ MCP proxy and direct-tool results use compact self-rendered rows by default. Collapsed success output shows the call title, a bounded one-line input preview when arguments exist, and the first result line, with a `Ctrl+O to expand` hint when more text is hidden. The full result remains available when expanded and is still returned unchanged to the model. Set `settings.toolResultRendering` to `"boxed"` to restore the legacy boxed Pi row, or set `settings.collapsedResultLines` to `2` or `3` when you want more collapsed text.
659
664
 
660
665
  Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are ranked by weighted matches across name, server, description, and any configured `searchKeywords`, then returned one page at a time (`limit` defaults to 12). Use `details.nextOffset` for the next page. Regex search is still available with `regex: true`, but regex results are paginated without ranking.
661
666
 
@@ -722,7 +727,7 @@ Advertised tool `outputSchema` values support JSON Schema draft-07 and 2020-12.
722
727
  - Idle servers disconnect after 10 minutes (configurable), reconnect automatically on next use
723
728
  - npx-based servers resolve to direct binary paths, skipping the ~143 MB npm parent process
724
729
  - MCP server validates arguments, not the adapter
725
- - Keep-alive servers get health checks and auto-reconnect
730
+ - Remote keep-alive servers force-refresh their tool catalog during health checks, before user input, and before adapter-triggered turns, with bounded reconnect backoff
726
731
  - Specific tools can be promoted from the proxy to first-class Pi tools via `directTools` config, so the LLM sees them directly instead of having to search
727
732
 
728
733
  ## Limitations
package/agent-dir.ts CHANGED
@@ -2,10 +2,18 @@ import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
 
5
+ export function getConfigDirName(): string {
6
+ const configDir = readPiConfig()?.configDir;
7
+ return typeof configDir === "string" && configDir.trim() ? configDir.trim() : ".pi";
8
+ }
9
+
5
10
  export function getAgentDir(): string {
6
- const configured = process.env.PI_CODING_AGENT_DIR?.trim();
11
+ const piConfig = readPiConfig();
12
+ const name = piConfig?.name;
13
+ const appName = typeof name === "string" && name.trim() ? name.trim() : "pi";
14
+ const configured = process.env[`${appName.toUpperCase()}_CODING_AGENT_DIR`]?.trim();
7
15
  if (!configured) {
8
- return join(homedir(), ".pi", "agent");
16
+ return join(homedir(), getConfigDirName(), "agent");
9
17
  }
10
18
  if (configured === "~") {
11
19
  return homedir();
@@ -31,12 +39,12 @@ export function getAgentPath(...segments: string[]): string {
31
39
  *
32
40
  * Falls back to "pi", which is what pi's own APP_NAME resolves to.
33
41
  */
34
- function readPiConfig(): { name?: unknown; clientUri?: unknown } | undefined {
42
+ function readPiConfig(): { name?: unknown; configDir?: unknown; clientUri?: unknown } | undefined {
35
43
  const dir = process.env.PI_PACKAGE_DIR?.trim()
36
44
  if (!dir) return undefined
37
45
  try {
38
46
  const manifest = JSON.parse(readFileSync(join(resolve(dir), "package.json"), "utf8")) as {
39
- piConfig?: { name?: unknown; clientUri?: unknown }
47
+ piConfig?: { name?: unknown; configDir?: unknown; clientUri?: unknown }
40
48
  }
41
49
  return manifest.piConfig
42
50
  } catch {
package/cli.js CHANGED
@@ -14,15 +14,36 @@ function expandHome(input) {
14
14
  return path.resolve(input);
15
15
  }
16
16
 
17
- const AGENT_DIR = process.env.PI_CODING_AGENT_DIR?.trim()
18
- ? expandHome(process.env.PI_CODING_AGENT_DIR.trim())
19
- : path.join(HOME, ".pi", "agent");
17
+ function readPiConfig() {
18
+ const dir = process.env.PI_PACKAGE_DIR?.trim();
19
+ if (!dir) return undefined;
20
+ try {
21
+ return JSON.parse(fs.readFileSync(path.join(path.resolve(dir), "package.json"), "utf8")).piConfig;
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ }
26
+
27
+ function getConfigDirName() {
28
+ const configDir = readPiConfig()?.configDir;
29
+ return typeof configDir === "string" && configDir.trim() ? configDir.trim() : ".pi";
30
+ }
31
+
32
+ function getAgentDir() {
33
+ const piConfig = readPiConfig();
34
+ const appName = typeof piConfig?.name === "string" && piConfig.name.trim() ? piConfig.name.trim() : "pi";
35
+ const configured = process.env[`${appName.toUpperCase()}_CODING_AGENT_DIR`]?.trim();
36
+ if (configured) return expandHome(configured);
37
+ return path.join(HOME, getConfigDirName(), "agent");
38
+ }
39
+
40
+ const AGENT_DIR = getAgentDir();
20
41
  const PI_CONFIG_PATH = path.join(AGENT_DIR, "mcp.json");
21
42
  const GENERIC_GLOBAL_CONFIG_PATH = path.join(HOME, ".config", "mcp", "mcp.json");
22
43
  const AGENTS_GLOBAL_CONFIG_PATH = path.join(HOME, ".agents", "mcp.json");
23
44
  const AGENTS_NESTED_GLOBAL_CONFIG_PATH = path.join(HOME, ".agents", "mcp", "mcp.json");
24
45
  const PROJECT_CONFIG_PATH = path.resolve(process.cwd(), ".mcp.json");
25
- const PROJECT_PI_CONFIG_PATH = path.resolve(process.cwd(), ".pi", "mcp.json");
46
+ const PROJECT_PI_CONFIG_PATH = path.resolve(process.cwd(), getConfigDirName(), "mcp.json");
26
47
 
27
48
  const IMPORT_PATHS = {
28
49
  cursor: [path.join(HOME, ".cursor", "mcp.json")],
package/commands.ts CHANGED
@@ -28,6 +28,19 @@ function terminalHyperlink(label: string, url: string): string {
28
28
  return `\u001B]8;;${sanitizeTerminalText(url)}\u001B\\${sanitizeTerminalText(label)}\u001B]8;;\u001B\\`;
29
29
  }
30
30
 
31
+ /**
32
+ * True when this run mode can display a `ctx.ui.custom()` overlay.
33
+ *
34
+ * `ctx.hasUI` only reports that *some* UI context is bound. In rpc/print mode
35
+ * that context is a headless stub whose `custom()` returns immediately without
36
+ * ever invoking the factory or the `done` callback, so a panel awaited through
37
+ * `new Promise(resolve => ctx.ui.custom(...))` never settles and the command
38
+ * hangs. Overlay panels therefore additionally require `ctx.mode === "tui"`.
39
+ */
40
+ function canRenderPanel(ctx: ExtensionContext): boolean {
41
+ return ctx.hasUI && ctx.mode === "tui";
42
+ }
43
+
31
44
  export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
32
45
  if (!ctx.hasUI) return;
33
46
 
@@ -399,6 +412,10 @@ export async function openMcpSetup(
399
412
  options: { includeHostConfigs?: boolean } = {},
400
413
  ): Promise<PanelFlowResult> {
401
414
  if (!ctx.hasUI) return { configChanged: false };
415
+ if (!canRenderPanel(ctx)) {
416
+ ctx.ui.notify(`The interactive MCP setup panel is only available in the terminal UI (current mode: ${ctx.mode}). Edit .mcp.json directly, or run /mcp status to review servers.`, "info");
417
+ return { configChanged: false };
418
+ }
402
419
  if (state.programmaticConfig) {
403
420
  ctx.ui.notify("MCP setup is unavailable when config is supplied by createMcpAdapter().", "info");
404
421
  return { configChanged: false };
@@ -533,6 +550,11 @@ export async function openMcpPanel(
533
550
  }
534
551
  return { configChanged: false };
535
552
  }
553
+ if (!canRenderPanel(ctx)) {
554
+ // No overlay here, but the same information is available as text.
555
+ await showStatus(state, ctx);
556
+ return { configChanged: false };
557
+ }
536
558
  if (Object.keys(state.config.mcpServers).length === 0) {
537
559
  return openMcpSetup(state, pi, ctx, configOverridePath, "empty", { includeHostConfigs: false });
538
560
  }
@@ -587,6 +609,10 @@ export async function openMcpAuthPanel(
587
609
  configOverridePath?: string,
588
610
  ): Promise<PanelFlowResult> {
589
611
  if (!ctx.hasUI) return { configChanged: false };
612
+ if (!canRenderPanel(ctx)) {
613
+ ctx.ui.notify(`The interactive MCP auth panel is only available in the terminal UI (current mode: ${ctx.mode}). Use /mcp-auth <server> to authenticate a specific server.`, "info");
614
+ return { configChanged: false };
615
+ }
590
616
  if (state.programmaticConfig) {
591
617
  ctx.ui.notify("Use /mcp-auth <server> to authenticate a server from the in-memory SDK config.", "info");
592
618
  return { configChanged: false };
package/config.ts CHANGED
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { parse as parseToml } from "smol-toml";
6
6
  import stripJsonComments from "strip-json-comments";
7
- import { getAgentPath } from "./agent-dir.ts";
7
+ import { getAgentPath, getConfigDirName } from "./agent-dir.ts";
8
8
  import { getAgentPluginSummaries, loadAgentPluginConfigs, type AgentPluginSummary } from "./agent-plugin-loader.ts";
9
9
  import { isServerDisabled, type HostConfigDiscovery, type McpConfig, type ServerEntry, type McpSettings, type ImportKind, type ServerProvenance } from "./types.ts";
10
10
  import { toStringRecord } from "./utils.ts";
@@ -15,7 +15,7 @@ const AGENTS_GLOBAL_CONFIG_PATHS = [
15
15
  join(homedir(), ".agents", "mcp", "mcp.json"),
16
16
  ] as const;
17
17
  const PROJECT_CONFIG_NAME = ".mcp.json";
18
- const PROJECT_PI_CONFIG_NAME = ".pi/mcp.json";
18
+ const PROJECT_PI_CONFIG_NAME = "mcp.json";
19
19
  const REPOPROMPT_BINARY_CANDIDATES = [
20
20
  join(homedir(), "RepoPrompt", "repoprompt_cli"),
21
21
  "/Applications/Repo Prompt.app/Contents/MacOS/repoprompt-mcp",
@@ -177,7 +177,7 @@ export function getProjectConfigPath(cwd = process.cwd()): string {
177
177
  }
178
178
 
179
179
  export function getProjectPiConfigPath(cwd = process.cwd()): string {
180
- return resolve(cwd, PROJECT_PI_CONFIG_NAME);
180
+ return resolve(cwd, getConfigDirName(), PROJECT_PI_CONFIG_NAME);
181
181
  }
182
182
 
183
183
  export function getConfigDiscoveryPaths(overridePath?: string, cwd = process.cwd()): ConfigDiscoveryPath[] {
@@ -471,7 +471,7 @@ function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig {
471
471
  // different url, these MUST NOT be inherited from the lower-precedence entry —
472
472
  // otherwise the original endpoint's credentials would be shipped to the new
473
473
  // url. See the SECURITY note in mergeServerMaps.
474
- const URL_BOUND_AUTH_FIELDS = ["headers", "bearerToken", "bearerTokenEnv"] as const;
474
+ const URL_BOUND_AUTH_FIELDS = ["headers", "bearerToken", "bearerTokenEnv", "requestHeadersCommand"] as const;
475
475
 
476
476
  function mergeServerMaps(
477
477
  base: Record<string, ServerEntry>,
package/direct-tools.ts CHANGED
@@ -11,7 +11,7 @@ import { formatSchema } from "./tool-metadata.ts";
11
11
  import { resolveMcpResultContent, transformMcpContent, transformMcpResourceContents } from "./tool-registrar.ts";
12
12
  import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
13
13
  import { maybeStartUiSession, summarizeUiSessionResult, type UiSessionRuntime } from "./ui-session.ts";
14
- import { formatToolName, getToolNameCandidates, isServerDisabled, isToolAllowed, resolveToolPrefix } from "./types.ts";
14
+ import { createToolSelectorCandidateIndex, formatToolName, getToolNameCandidates, isServerDisabled, isToolAllowed, resolveToolPrefix } from "./types.ts";
15
15
  import { isUiToolVisibleToModel } from "./ui-tool-visibility.ts";
16
16
  import { resourceNameToToolName } from "./resource-tools.ts";
17
17
  import { authenticate, supportsOAuth } from "./mcp-auth-flow.ts";
@@ -150,7 +150,10 @@ export function resolveDirectTools(
150
150
  if (!toolFilter) continue;
151
151
 
152
152
  const effectivePrefix = resolveToolPrefix(definition, prefix);
153
- const getOtherCurrentCandidates = (toolName: string): Set<string> => {
153
+ const hasToolFilters =
154
+ (Array.isArray(definition.includeTools) && definition.includeTools.length > 0) ||
155
+ (Array.isArray(definition.excludeTools) && definition.excludeTools.length > 0);
156
+ const selectorCandidateIndex = hasToolFilters ? (() => {
154
157
  const candidates = new Set<string>();
155
158
  for (const [otherServerName, otherDefinition] of Object.entries(config.mcpServers)) {
156
159
  const otherCache = cache.servers[otherServerName];
@@ -167,14 +170,13 @@ export function resolveDirectTools(
167
170
  }
168
171
  }
169
172
  }
170
- for (const candidate of getToolNameCandidates(toolName, serverName, effectivePrefix, false)) candidates.delete(candidate);
171
- return candidates;
172
- };
173
+ return createToolSelectorCandidateIndex(candidates);
174
+ })() : undefined;
173
175
 
174
176
  for (const tool of serverCache.tools ?? []) {
175
177
  if (!isUiToolVisibleToModel(tool.uiVisibility)) continue;
176
178
  if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
177
- if (!isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, getOtherCurrentCandidates(tool.name))) continue;
179
+ if (!isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, selectorCandidateIndex)) continue;
178
180
  const prefixedName = formatToolName(tool.name, serverName, effectivePrefix);
179
181
  if (BUILTIN_NAMES.has(prefixedName)) {
180
182
  console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
@@ -200,7 +202,7 @@ export function resolveDirectTools(
200
202
  for (const resource of serverCache.resources ?? []) {
201
203
  const baseName = `read_${resourceNameToToolName(resource.name)}`;
202
204
  if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
203
- if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, getOtherCurrentCandidates(baseName))) continue;
205
+ if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, selectorCandidateIndex)) continue;
204
206
  const prefixedName = formatToolName(baseName, serverName, effectivePrefix);
205
207
  if (BUILTIN_NAMES.has(prefixedName)) {
206
208
  console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
@@ -222,7 +224,7 @@ export function resolveDirectTools(
222
224
  }
223
225
  }
224
226
 
225
- if (specs.length >= DIRECT_TOOLS_ADVISORY_THRESHOLD) {
227
+ if (config.settings?.warnOnLargeDirectTools !== false && specs.length >= DIRECT_TOOLS_ADVISORY_THRESHOLD) {
226
228
  console.warn(`MCP: ${specs.length} direct tools resolved. Each direct tool adds prompt context; README guidance recommends targeted sets of 5-20 tools and using the proxy or an explicit string[] when 75+ direct tools would be registered.`);
227
229
  }
228
230
 
@@ -255,8 +257,10 @@ export function buildProxyDescription(
255
257
  const cachedEntry = cache?.servers?.[serverName];
256
258
  const entry = cachedEntry && isServerCacheValid(cachedEntry, definition) ? cachedEntry : undefined;
257
259
  const effectivePrefix = resolveToolPrefix(definition, prefix);
258
- const getOtherCurrentCandidates = (toolName: string): Set<string> | undefined => {
259
- if (!cache) return undefined;
260
+ const hasToolFilters =
261
+ (Array.isArray(definition.includeTools) && definition.includeTools.length > 0) ||
262
+ (Array.isArray(definition.excludeTools) && definition.excludeTools.length > 0);
263
+ const selectorCandidateIndex = hasToolFilters && cache ? (() => {
260
264
  const candidates = new Set<string>();
261
265
  for (const [otherServerName, otherDefinition] of Object.entries(config.mcpServers)) {
262
266
  const otherEntry = cache.servers[otherServerName];
@@ -273,17 +277,16 @@ export function buildProxyDescription(
273
277
  }
274
278
  }
275
279
  }
276
- for (const candidate of getToolNameCandidates(toolName, serverName, effectivePrefix, false)) candidates.delete(candidate);
277
- return candidates;
278
- };
280
+ return createToolSelectorCandidateIndex(candidates);
281
+ })() : undefined;
279
282
  const toolCount = (entry?.tools ?? []).filter(
280
283
  (tool) => isUiToolVisibleToModel(tool.uiVisibility)
281
- && isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, getOtherCurrentCandidates(tool.name)),
284
+ && isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, selectorCandidateIndex),
282
285
  ).length;
283
286
  const resourceCount = definition?.exposeResources !== false
284
287
  ? (entry?.resources ?? []).filter((resource) => {
285
288
  const baseName = `read_${resourceNameToToolName(resource.name)}`;
286
- return isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, getOtherCurrentCandidates(baseName));
289
+ return isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, selectorCandidateIndex);
287
290
  }).length
288
291
  : 0;
289
292
  const totalItems = toolCount + resourceCount;
package/index.ts CHANGED
@@ -417,6 +417,29 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
417
417
  }
418
418
  });
419
419
 
420
+ pi.on("input", async () => {
421
+ const inputOwner = currentOwner;
422
+ if (!inputOwner?.isActive()) return;
423
+
424
+ if (!state && initPromise) {
425
+ try {
426
+ await awaitWithTimeout(initPromise, INIT_WAIT_TIMEOUT_MS);
427
+ } catch {
428
+ return;
429
+ }
430
+ }
431
+
432
+ const inputState = state;
433
+ if (!inputState || !inputOwner.isActive()) return;
434
+ try {
435
+ await inputState.lifecycle.ensureConverged(inputOwner.signal);
436
+ } catch (error) {
437
+ if (!isAbortError(error, inputOwner.signal)) {
438
+ logger.debug(`MCP: keep-alive convergence failed before input: ${formatTerminalError(error)}`);
439
+ }
440
+ }
441
+ });
442
+
420
443
  pi.on("session_shutdown", async () => {
421
444
  ++lifecycleGeneration;
422
445
  const currentState = state;
@@ -737,12 +760,12 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
737
760
  action?: string;
738
761
  }, signal: AbortSignal | undefined, _onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined, _ctx: ExtensionContext) {
739
762
  const executeOwner = currentOwner;
740
- let parsedArgs: Record<string, unknown> | undefined;
741
- if (params.args !== undefined && params.args !== "") {
763
+ const parseArgs = (value: string | Record<string, unknown> | undefined): Record<string, unknown> | undefined => {
764
+ if (value === undefined || value === "") return undefined;
742
765
  let args: unknown;
743
- if (typeof params.args === "string") {
766
+ if (typeof value === "string") {
744
767
  try {
745
- args = JSON.parse(params.args);
768
+ args = JSON.parse(value);
746
769
  } catch (error) {
747
770
  if (error instanceof SyntaxError) {
748
771
  throw new Error(`Invalid args JSON: ${error.message}`, { cause: error });
@@ -750,14 +773,35 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
750
773
  throw error;
751
774
  }
752
775
  } else {
753
- args = params.args;
776
+ args = value;
754
777
  }
755
778
 
756
779
  if (typeof args !== "object" || args === null || Array.isArray(args)) {
757
780
  const gotType = Array.isArray(args) ? "array" : args === null ? "null" : typeof args;
758
781
  throw new Error(`Invalid args: expected a JSON object, got ${gotType}`);
759
782
  }
760
- parsedArgs = args as Record<string, unknown>;
783
+ return args as Record<string, unknown>;
784
+ };
785
+ let parsedArgs = parseArgs(params.args);
786
+ let dispatchParams = params;
787
+ const hasGatewayMode = (value: typeof params): boolean =>
788
+ value.tool !== undefined
789
+ || value.connect !== undefined
790
+ || value.describe !== undefined
791
+ || value.instructions !== undefined
792
+ || value.search !== undefined
793
+ || value.server !== undefined
794
+ || value.action !== undefined;
795
+ if (!hasGatewayMode(params) && parsedArgs) {
796
+ const nestedParams = parsedArgs as typeof params;
797
+ if (hasGatewayMode(nestedParams)) {
798
+ dispatchParams = nestedParams;
799
+ parsedArgs = parseArgs(nestedParams.args);
800
+ } else {
801
+ throw new Error("Gateway params were nested inside `args`; pass them top-level (for example, mcp({ search: \"...\" }) or mcp({ tool: \"...\", args: {} })).");
802
+ }
803
+ } else if (!hasGatewayMode(params) && params.args !== undefined) {
804
+ throw new Error("Gateway params were nested inside `args`; pass them top-level (for example, mcp({ search: \"...\" }) or mcp({ tool: \"...\", args: {} })).");
761
805
  }
762
806
 
763
807
  if (!state && initPromise) {
@@ -788,22 +832,22 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
788
832
  }
789
833
  executeOwner?.throwIfInactive();
790
834
 
791
- if (params.action === "ui-messages") {
835
+ if (dispatchParams.action === "ui-messages") {
792
836
  return executeUiMessages(state);
793
837
  }
794
- if (params.action === "auth-start") {
795
- if (!params.server) {
838
+ if (dispatchParams.action === "auth-start") {
839
+ if (!dispatchParams.server) {
796
840
  return {
797
841
  content: [{ type: "text" as const, text: "auth-start requires `server`. Example: mcp({ action: \"auth-start\", server: \"linear-server\" })" }],
798
842
  details: { mode: "auth-start", error: "missing_server" },
799
843
  };
800
844
  }
801
845
  return signal
802
- ? executeAuthStart(state, params.server, signal)
803
- : executeAuthStart(state, params.server);
846
+ ? executeAuthStart(state, dispatchParams.server, signal)
847
+ : executeAuthStart(state, dispatchParams.server);
804
848
  }
805
- if (params.action === "auth-complete") {
806
- if (!params.server) {
849
+ if (dispatchParams.action === "auth-complete") {
850
+ if (!dispatchParams.server) {
807
851
  return {
808
852
  content: [{ type: "text" as const, text: "auth-complete requires `server`." }],
809
853
  details: { mode: "auth-complete", error: "missing_server" },
@@ -817,28 +861,28 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
817
861
  };
818
862
  }
819
863
  return signal
820
- ? executeAuthComplete(state, params.server, input, signal)
821
- : executeAuthComplete(state, params.server, input);
864
+ ? executeAuthComplete(state, dispatchParams.server, input, signal)
865
+ : executeAuthComplete(state, dispatchParams.server, input);
822
866
  }
823
- if (params.tool) {
824
- return executeCall(state, params.tool, parsedArgs, params.server, getPiTools, signal);
867
+ if (dispatchParams.tool) {
868
+ return executeCall(state, dispatchParams.tool, parsedArgs, dispatchParams.server, getPiTools, signal);
825
869
  }
826
- if (params.connect) {
827
- const result = await executeConnect(state, params.connect, signal);
870
+ if (dispatchParams.connect) {
871
+ const result = await executeConnect(state, dispatchParams.connect, signal);
828
872
  syncToolSurface(_ctx as ExtensionContext);
829
873
  return result;
830
874
  }
831
- if (params.describe) {
832
- return executeDescribe(state, params.describe);
875
+ if (dispatchParams.describe) {
876
+ return executeDescribe(state, dispatchParams.describe);
833
877
  }
834
- if (params.instructions) {
835
- return executeInstructions(state, params.instructions);
878
+ if (dispatchParams.instructions) {
879
+ return executeInstructions(state, dispatchParams.instructions);
836
880
  }
837
- if (params.search !== undefined) {
838
- return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas, params.limit, params.offset);
881
+ if (dispatchParams.search !== undefined) {
882
+ return executeSearch(state, dispatchParams.search, dispatchParams.regex, dispatchParams.server, dispatchParams.includeSchemas, dispatchParams.limit, dispatchParams.offset);
839
883
  }
840
- if (params.server) {
841
- return executeList(state, params.server);
884
+ if (dispatchParams.server) {
885
+ return executeList(state, dispatchParams.server);
842
886
  }
843
887
  return executeStatus(state);
844
888
  },
package/init.ts CHANGED
@@ -179,8 +179,20 @@ export async function initializeMcp(
179
179
  },
180
180
  ...(ui !== undefined ? { ui } : {}),
181
181
  sendMessage: (message, options) => {
182
- if (!owner.isActive()) return;
183
- pi.sendMessage(message as unknown as Parameters<typeof pi.sendMessage>[0], options);
182
+ const deliver = () => {
183
+ if (!owner.isActive()) return;
184
+ pi.sendMessage(message as unknown as Parameters<typeof pi.sendMessage>[0], options);
185
+ };
186
+ if (!options?.triggerTurn) {
187
+ deliver();
188
+ return;
189
+ }
190
+ void lifecycle.ensureConverged(owner.signal).then(deliver, error => {
191
+ if (!owner.isActive() || isAbortError(error, owner.signal)) return;
192
+ const detail = error instanceof Error ? error.message : String(error);
193
+ logger.debug(`MCP: pre-turn keep-alive convergence failed: ${sanitizeTerminalText(detail)}`);
194
+ deliver();
195
+ });
184
196
  },
185
197
  ...(options.statusEvents !== undefined ? { statusEvents: options.statusEvents } : {}),
186
198
  };
@@ -419,6 +431,18 @@ export async function initializeMcp(
419
431
  updateStatusBar(state);
420
432
  });
421
433
 
434
+ lifecycle.setHealthRestoredCallback((serverName) => {
435
+ if (!owner.isActive()) return;
436
+ clearFailure(state, serverName);
437
+ updateStatusBar(state);
438
+ });
439
+
440
+ lifecycle.setAuthRequiredCallback((serverName) => {
441
+ if (!owner.isActive()) return;
442
+ clearFailure(state, serverName);
443
+ updateStatusBar(state);
444
+ });
445
+
422
446
  lifecycle.setIdleShutdownCallback((serverName) => {
423
447
  if (!owner.isActive()) return;
424
448
  const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName);