pi-mcp-adapter 2.25.0 → 2.26.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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.26.0] - 2026-08-14
11
+
12
+ ### Added
13
+ - 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.
14
+ - Added `settings.warnOnLargeDirectTools` to suppress the advisory for 75 or more resolved direct tools. Thanks @Roshvan for issue #358.
15
+
16
+ ### Changed
17
+ - Refined request-header command result handling types without changing runtime behavior.
18
+
19
+ ### Fixed
20
+ - 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.
21
+ - 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.
22
+
10
23
  ## [2.25.0] - 2026-08-13
11
24
 
12
25
  ### 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. |
@@ -310,6 +311,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
310
311
  "toolResultRendering": "compact",
311
312
  "collapsedResultLines": 1,
312
313
  "notifyOnStartupConnect": true,
314
+ "warnOnLargeDirectTools": true,
313
315
  "hostConfigDiscovery": "off",
314
316
  "approveTools": ["github_delete_*", "notion_update_*"],
315
317
  "oauthDir": ".pi/mcp-oauth",
@@ -340,6 +342,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
340
342
  | `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
343
  | `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
344
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
345
+ | `warnOnLargeDirectTools` | Show the advisory when 75 or more direct tools resolve (default: `true`). Set to `false` to suppress only this advisory. |
343
346
  | `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
347
  | `scriptMode` | Register the MCP-only `mcpScript` plain-JavaScript tool (default: true). Set to `false` to hide it. |
345
348
  | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
@@ -544,7 +547,7 @@ To hide specific tools while still using `directTools: true`, add `excludeTools`
544
547
 
545
548
  `includeTools` and `excludeTools` filter direct tools, proxy search/list/describe, and the `/mcp` panel view.
546
549
 
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.
550
+ 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
551
 
549
552
  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
553
 
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/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;
@@ -1,6 +1,7 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
3
3
  import type { ImportKind } from "./types.ts";
4
+ import { getConfigDirName } from "./agent-dir.ts";
4
5
  import { KNOWN_SERVER_PRESETS, type ConfigWritePreview, type KnownServerPreset, type McpDiscoverySummary } from "./config.ts";
5
6
  import type { McpOnboardingState } from "./onboarding-state.ts";
6
7
 
@@ -556,7 +557,7 @@ export class McpSetupPanel {
556
557
  "3. ~/.agents/mcp/mcp.json",
557
558
  "4. <Pi agent dir>/mcp.json",
558
559
  "5. .mcp.json",
559
- "6. .pi/mcp.json",
560
+ `6. ${getConfigDirName()}/mcp.json`,
560
561
  `Host discovery: ${this.discovery.hostConfigDiscovery}. Conflicts reported: ${this.discovery.conflicts.length}.`,
561
562
  ...this.discovery.conflicts.slice(0, 8).map((conflict) =>
562
563
  `${conflict.serverName}: ${conflict.sources.map((source) => source.path).join(" -> ")} (winner: ${conflict.winner.path})`,
package/metadata-cache.ts CHANGED
@@ -19,11 +19,12 @@ import type {
19
19
  ToolMetadata,
20
20
  PromptMetadata,
21
21
  } from "./types.ts";
22
- import { formatPromptCommandName, formatToolName, getToolNameCandidates, isServerDisabled, isToolAllowed, resolveToolPrefix, type ToolPrefix } from "./types.ts";
22
+ import { createToolSelectorCandidateIndex, formatPromptCommandName, formatToolName, getToolNameCandidates, isServerDisabled, isToolAllowed, resolveToolPrefix, type ToolPrefix } from "./types.ts";
23
23
  import { resourceNameToToolName } from "./resource-tools.ts";
24
24
  import {
25
25
  extractToolUiStreamMode,
26
26
  interpolateEnvRecord,
27
+ interpolateEnvVars,
27
28
  resolveBearerToken,
28
29
  resolveConfigPath,
29
30
  resolveServerUrl,
@@ -90,6 +91,14 @@ export function computeServerHash(definition: ServerEntry): string {
90
91
  cwd: resolveConfigPath(definition.cwd),
91
92
  url: resolveServerUrl(definition),
92
93
  headers: interpolateEnvRecord(definition.headers),
94
+ requestHeadersCommand: definition.requestHeadersCommand
95
+ ? {
96
+ command: interpolateEnvVars(definition.requestHeadersCommand.command),
97
+ args: definition.requestHeadersCommand.args?.map(interpolateEnvVars),
98
+ env: interpolateEnvRecord(definition.requestHeadersCommand.env),
99
+ timeoutMs: definition.requestHeadersCommand.timeoutMs,
100
+ }
101
+ : undefined,
93
102
  auth: definition.auth,
94
103
  protocolVersion: definition.protocolVersion,
95
104
  bearerToken: resolveBearerToken(definition),
@@ -184,8 +193,10 @@ export function reconstructToolMetadata(
184
193
  const metadata: ToolMetadata[] = [];
185
194
  const seenNames = new Set<string>();
186
195
  const effectivePrefix = resolveToolPrefix(definition, prefix);
187
- const getOtherCurrentCandidates = (toolName: string): Set<string> | undefined => {
188
- if (!configuredServers || !cache) return undefined;
196
+ const hasToolFilters =
197
+ (Array.isArray(definition.includeTools) && definition.includeTools.length > 0) ||
198
+ (Array.isArray(definition.excludeTools) && definition.excludeTools.length > 0);
199
+ const selectorCandidateIndex = hasToolFilters && configuredServers && cache ? (() => {
189
200
  const candidates = new Set<string>();
190
201
  for (const [otherServerName, otherDefinition] of Object.entries(configuredServers)) {
191
202
  const otherEntry = cache.servers[otherServerName];
@@ -202,16 +213,15 @@ export function reconstructToolMetadata(
202
213
  }
203
214
  }
204
215
  }
205
- for (const candidate of getToolNameCandidates(toolName, serverName, effectivePrefix, false)) candidates.delete(candidate);
206
- return candidates;
207
- };
216
+ return createToolSelectorCandidateIndex(candidates);
217
+ })() : undefined;
208
218
 
209
219
  for (const tool of entry.tools ?? []) {
210
220
  if (!tool?.name) continue;
211
221
  if (!isUiToolVisibleToModel(tool.uiVisibility)) {
212
222
  continue;
213
223
  }
214
- if (!isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, getOtherCurrentCandidates(tool.name))) {
224
+ if (!isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, selectorCandidateIndex)) {
215
225
  continue;
216
226
  }
217
227
 
@@ -236,7 +246,7 @@ export function reconstructToolMetadata(
236
246
  for (const resource of entry.resources ?? []) {
237
247
  if (!resource?.name || !resource?.uri) continue;
238
248
  const baseName = `read_${resourceNameToToolName(resource.name)}`;
239
- if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, getOtherCurrentCandidates(baseName))) {
249
+ if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, selectorCandidateIndex)) {
240
250
  continue;
241
251
  }
242
252
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.25.0",
3
+ "version": "2.26.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -89,6 +89,7 @@
89
89
  "ui-app-bridge-helpers.ts",
90
90
  "config.ts",
91
91
  "server-manager.ts",
92
+ "request-headers-command.ts",
92
93
  "unix-socket-transport.ts",
93
94
  "json-schema-validator.ts",
94
95
  "session-recovery.ts",
@@ -0,0 +1,336 @@
1
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { FetchLike } from "@modelcontextprotocol/client";
4
+ import type { HttpRequestHeadersCommand } from "./types.ts";
5
+ import { interpolateEnvVars } from "./utils.ts";
6
+
7
+ const DEFAULT_TIMEOUT_MS = 10_000;
8
+ const MAX_OUTPUT_BYTES = 64 * 1024;
9
+ const USE_PROCESS_GROUP = process.platform !== "win32";
10
+ const CLEANUP_TOKEN_ENV = "PI_MCP_REQUEST_HEADERS_CLEANUP_TOKEN";
11
+
12
+ function isNoSuchProcessError(error: unknown): boolean {
13
+ return typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ESRCH";
14
+ }
15
+
16
+ function runPosixPs(args: string[]): { status: number | null; stdout: string } {
17
+ if (process.env.PI_MCP_ADAPTER_TEST_FAIL_PS === "1") return { status: 1, stdout: "" };
18
+ return spawnSync("ps", args, { encoding: "utf8" });
19
+ }
20
+
21
+ function collectPosixDescendantPids(rootPid: number): number[] {
22
+ const result = runPosixPs(["-axo", "pid=,ppid="]);
23
+ if (result.status !== 0) {
24
+ throw new Error(`HTTP request headers command cleanup failed: ps exited with code ${result.status ?? "unknown"}`);
25
+ }
26
+
27
+ const childrenByParent = new Map<number, number[]>();
28
+ for (const line of result.stdout.split("\n")) {
29
+ const [pidText, ppidText] = line.trim().split(/\s+/, 2);
30
+ const pid = Number(pidText);
31
+ const ppid = Number(ppidText);
32
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
33
+ const children = childrenByParent.get(ppid);
34
+ if (children) children.push(pid);
35
+ else childrenByParent.set(ppid, [pid]);
36
+ }
37
+
38
+ const descendants: number[] = [];
39
+ const stack = [...(childrenByParent.get(rootPid) ?? [])];
40
+ while (stack.length > 0) {
41
+ const pid = stack.pop()!;
42
+ descendants.push(pid);
43
+ stack.push(...(childrenByParent.get(pid) ?? []));
44
+ }
45
+ return descendants;
46
+ }
47
+
48
+ function collectPosixCleanupTokenPids(cleanupToken: string): number[] {
49
+ const result = runPosixPs(["axeww", "-o", "pid=,command="]);
50
+ if (result.status !== 0) {
51
+ throw new Error(`HTTP request headers command cleanup failed: ps exited with code ${result.status ?? "unknown"}`);
52
+ }
53
+
54
+ const needle = `${CLEANUP_TOKEN_ENV}=${cleanupToken}`;
55
+ const pids: number[] = [];
56
+ for (const line of result.stdout.split("\n")) {
57
+ const trimmed = line.trim();
58
+ if (!trimmed.includes(needle)) continue;
59
+ const [pidText] = trimmed.split(/\s+/, 1);
60
+ const pid = Number(pidText);
61
+ if (Number.isInteger(pid) && pid !== process.pid) pids.push(pid);
62
+ }
63
+ return pids;
64
+ }
65
+
66
+ function assertPosixProcessDiscoveryAvailable(): void {
67
+ collectPosixDescendantPids(process.pid);
68
+ collectPosixCleanupTokenPids(`${process.pid}-preflight`);
69
+ }
70
+
71
+ function isTaskkillNoSuchProcess(result: ReturnType<typeof spawnSync>): boolean {
72
+ return `${result.stdout ?? ""}\n${result.stderr ?? ""}`.toLowerCase().includes("not found");
73
+ }
74
+
75
+ function signalPid(pid: number, signal: NodeJS.Signals): void {
76
+ try {
77
+ process.kill(pid, signal);
78
+ } catch (error) {
79
+ if (!isNoSuchProcessError(error)) throw error;
80
+ }
81
+ }
82
+
83
+ function signalProcessGroup(pid: number, signal: NodeJS.Signals): void {
84
+ try {
85
+ process.kill(-pid, signal);
86
+ } catch (error) {
87
+ if (!isNoSuchProcessError(error)) throw error;
88
+ }
89
+ }
90
+
91
+ function killRequestHeadersCommand(child: ChildProcess, trackedPosixDescendantPids = new Set<number>(), cleanupToken?: string): void {
92
+ if (process.platform === "win32" && child.pid !== undefined) {
93
+ const result = spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
94
+ encoding: "utf8",
95
+ windowsHide: true,
96
+ });
97
+ if (result.status === 0 || isTaskkillNoSuchProcess(result)) return;
98
+ throw new Error(`HTTP request headers command cleanup failed: taskkill exited with code ${result.status ?? "unknown"}`);
99
+ }
100
+
101
+ if (USE_PROCESS_GROUP && child.pid !== undefined) {
102
+ const frozenPids = new Set<number>();
103
+ let cleanupError: Error | undefined;
104
+ try {
105
+ signalProcessGroup(child.pid, "SIGSTOP");
106
+ for (const pid of trackedPosixDescendantPids) {
107
+ signalPid(pid, "SIGSTOP");
108
+ frozenPids.add(pid);
109
+ }
110
+ let stablePasses = 0;
111
+ for (let pass = 0; pass < 16; pass++) {
112
+ const candidates = [
113
+ ...collectPosixDescendantPids(child.pid),
114
+ ...(cleanupToken ? collectPosixCleanupTokenPids(cleanupToken) : []),
115
+ ];
116
+ const newPids = candidates.filter(pid => !frozenPids.has(pid));
117
+ if (newPids.length === 0) {
118
+ stablePasses++;
119
+ if (stablePasses >= 2) return;
120
+ continue;
121
+ }
122
+ stablePasses = 0;
123
+ for (const pid of newPids) {
124
+ signalPid(pid, "SIGSTOP");
125
+ frozenPids.add(pid);
126
+ }
127
+ }
128
+ cleanupError = new Error("HTTP request headers command cleanup failed: descendant process tree did not stabilize");
129
+ } catch (error) {
130
+ cleanupError = error instanceof Error ? error : new Error(String(error));
131
+ } finally {
132
+ signalProcessGroup(child.pid, "SIGKILL");
133
+ for (const pid of frozenPids) signalPid(pid, "SIGKILL");
134
+ }
135
+ if (cleanupError) throw cleanupError;
136
+ return;
137
+ }
138
+
139
+ child.kill("SIGKILL");
140
+ }
141
+
142
+ export interface HttpRequestCommandEnvelope {
143
+ version: 1;
144
+ method: string;
145
+ url: string;
146
+ bodyBase64: string;
147
+ }
148
+
149
+ type CommandResult =
150
+ | { status: "error"; error: Error }
151
+ | { status: "success"; headers: Headers };
152
+
153
+ function resolvedCommand(config: HttpRequestHeadersCommand): {
154
+ command: string;
155
+ args: string[];
156
+ env: NodeJS.ProcessEnv;
157
+ timeoutMs: number;
158
+ } {
159
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
160
+ throw new Error("HTTP request headers command must be an object");
161
+ }
162
+ if (typeof config.command !== "string" || config.command.trim() === "") {
163
+ throw new Error("HTTP request headers command requires a non-empty command");
164
+ }
165
+ if (config.args !== undefined && (!Array.isArray(config.args) || config.args.some(arg => typeof arg !== "string"))) {
166
+ throw new Error("HTTP request headers command args must be strings");
167
+ }
168
+ if (config.env !== undefined && (
169
+ typeof config.env !== "object"
170
+ || Array.isArray(config.env)
171
+ || Object.values(config.env).some(value => typeof value !== "string")
172
+ )) {
173
+ throw new Error("HTTP request headers command env values must be strings");
174
+ }
175
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
176
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
177
+ throw new Error("HTTP request headers command timeoutMs must be an integer between 1 and 60000");
178
+ }
179
+ return {
180
+ command: interpolateEnvVars(config.command),
181
+ args: (config.args ?? []).map(interpolateEnvVars),
182
+ env: {
183
+ ...process.env,
184
+ ...Object.fromEntries(
185
+ Object.entries(config.env ?? {}).map(([key, value]) => [key, interpolateEnvVars(value)]),
186
+ ),
187
+ },
188
+ timeoutMs,
189
+ };
190
+ }
191
+
192
+ async function invokeRequestHeadersCommand(
193
+ config: HttpRequestHeadersCommand,
194
+ envelope: HttpRequestCommandEnvelope,
195
+ signal: AbortSignal,
196
+ ): Promise<Headers> {
197
+ const resolved = resolvedCommand(config);
198
+ if (USE_PROCESS_GROUP) assertPosixProcessDiscoveryAvailable();
199
+ return new Promise<Headers>((resolve, reject) => {
200
+ let stdout = Buffer.alloc(0);
201
+ let settled = false;
202
+ const cleanupToken = randomUUID();
203
+ const child = spawn(resolved.command, resolved.args, {
204
+ env: { ...resolved.env, [CLEANUP_TOKEN_ENV]: cleanupToken },
205
+ stdio: ["pipe", "pipe", "ignore"],
206
+ windowsHide: true,
207
+ detached: USE_PROCESS_GROUP,
208
+ });
209
+
210
+ const trackedPosixDescendantPids = new Set<number>();
211
+ let trackingError: Error | undefined;
212
+ const trackPosixDescendants = () => {
213
+ if (!USE_PROCESS_GROUP || child.pid === undefined || settled || trackingError) return;
214
+ try {
215
+ for (const pid of collectPosixDescendantPids(child.pid)) trackedPosixDescendantPids.add(pid);
216
+ for (const pid of collectPosixCleanupTokenPids(cleanupToken)) trackedPosixDescendantPids.add(pid);
217
+ } catch (error) {
218
+ trackingError = error instanceof Error ? error : new Error(String(error));
219
+ }
220
+ };
221
+ const descendantTracker = USE_PROCESS_GROUP ? setInterval(trackPosixDescendants, 50) : undefined;
222
+ descendantTracker?.unref();
223
+
224
+ const finish = (result: CommandResult) => {
225
+ if (settled) return;
226
+ settled = true;
227
+ clearTimeout(timer);
228
+ if (descendantTracker) clearInterval(descendantTracker);
229
+ signal.removeEventListener("abort", abort);
230
+ if (result.status === "error") reject(result.error);
231
+ else resolve(result.headers);
232
+ };
233
+ const finishAfterKill = (result: CommandResult) => {
234
+ try {
235
+ killRequestHeadersCommand(child, trackedPosixDescendantPids, cleanupToken);
236
+ finish(result);
237
+ } catch (cleanupError) {
238
+ finish({ status: "error", error: cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)) });
239
+ }
240
+ };
241
+ const failAfterKill = (message: string) => {
242
+ finishAfterKill({ status: "error", error: trackingError ?? new Error(message) });
243
+ };
244
+ const abort = () => {
245
+ failAfterKill("HTTP request headers command aborted");
246
+ };
247
+ const timer = setTimeout(() => {
248
+ failAfterKill(`HTTP request headers command timed out after ${resolved.timeoutMs}ms`);
249
+ }, resolved.timeoutMs);
250
+
251
+ signal.addEventListener("abort", abort, { once: true });
252
+ if (signal.aborted) {
253
+ abort();
254
+ return;
255
+ }
256
+
257
+ child.on("error", () => finish({ status: "error", error: new Error("HTTP request headers command failed to start") }));
258
+ child.stdout.on("data", (chunk: Buffer | string) => {
259
+ if (settled) return;
260
+ stdout = Buffer.concat([stdout, Buffer.from(chunk)]);
261
+ if (stdout.byteLength > MAX_OUTPUT_BYTES) {
262
+ failAfterKill("HTTP request headers command output exceeded 64 KiB");
263
+ }
264
+ });
265
+ child.on("close", code => {
266
+ if (settled) return;
267
+ if (trackingError) {
268
+ failAfterKill(trackingError.message);
269
+ return;
270
+ }
271
+ if (code !== 0) {
272
+ failAfterKill(`HTTP request headers command exited with code ${code ?? "unknown"}`);
273
+ return;
274
+ }
275
+ let parsed: unknown;
276
+ try {
277
+ parsed = JSON.parse(stdout.toString("utf8"));
278
+ } catch {
279
+ finishAfterKill({ status: "error", error: new Error("HTTP request headers command returned invalid JSON") });
280
+ return;
281
+ }
282
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
283
+ finishAfterKill({ status: "error", error: new Error("HTTP request headers command must return a JSON object") });
284
+ return;
285
+ }
286
+ const entries = Object.entries(parsed);
287
+ if (entries.some(([, value]) => typeof value !== "string")) {
288
+ finishAfterKill({ status: "error", error: new Error("HTTP request headers command values must be strings") });
289
+ return;
290
+ }
291
+ try {
292
+ finishAfterKill({ status: "success", headers: new Headers(entries as Array<[string, string]>) });
293
+ } catch {
294
+ finishAfterKill({ status: "error", error: new Error("HTTP request headers command returned an invalid header") });
295
+ }
296
+ });
297
+
298
+ child.stdin.on("error", () => {});
299
+ child.stdin.end(JSON.stringify(envelope));
300
+ });
301
+ }
302
+
303
+ /** Wrap fetch so a trusted command can derive headers from the exact request. */
304
+ export function createRequestHeadersCommandFetch(
305
+ config: HttpRequestHeadersCommand,
306
+ delegate: FetchLike = globalThis.fetch,
307
+ ): FetchLike {
308
+ // Validate static configuration before the first request.
309
+ resolvedCommand(config);
310
+ return async (input: URL | RequestInfo, init?: RequestInit): Promise<Response> => {
311
+ const request = new Request(input, init);
312
+ const body = Buffer.from(await request.clone().arrayBuffer());
313
+ const derived = await invokeRequestHeadersCommand(config, {
314
+ version: 1,
315
+ method: request.method.toUpperCase(),
316
+ url: request.url,
317
+ bodyBase64: body.toString("base64"),
318
+ }, request.signal);
319
+ const headers = new Headers(request.headers);
320
+ derived.forEach((value, name) => headers.set(name, value));
321
+ return delegate(new URL(request.url), {
322
+ method: request.method,
323
+ headers,
324
+ ...(request.method === "GET" || request.method === "HEAD" ? {} : { body }),
325
+ signal: request.signal,
326
+ cache: request.cache,
327
+ credentials: request.credentials,
328
+ integrity: request.integrity,
329
+ keepalive: request.keepalive,
330
+ mode: request.mode,
331
+ redirect: request.redirect,
332
+ referrer: request.referrer,
333
+ referrerPolicy: request.referrerPolicy,
334
+ });
335
+ };
336
+ }
package/server-manager.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  traceTransportKind,
57
57
  wrapTransportWithMcpTrace,
58
58
  } from "./mcp-trace.ts";
59
+ import { createRequestHeadersCommandFetch } from "./request-headers-command.ts";
59
60
 
60
61
  const MAX_CAPTURED_STDERR_BYTES = 8 * 1024;
61
62
  const MAX_CAPTURED_STDERR_LINES = 3;
@@ -746,6 +747,9 @@ export class McpServerManager {
746
747
  }
747
748
 
748
749
  const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined;
750
+ const requestFetch = definition.requestHeadersCommand
751
+ ? createRequestHeadersCommandFetch(definition.requestHeadersCommand)
752
+ : undefined;
749
753
  const createAuthProvider = (): McpOAuthProvider => new McpOAuthProvider(
750
754
  serverName,
751
755
  serverUrl,
@@ -772,6 +776,7 @@ export class McpServerManager {
772
776
  const authProvider = "provider" in authState ? authState.provider : undefined;
773
777
  const transportOptions = {
774
778
  ...(requestInit !== undefined ? { requestInit } : {}),
779
+ ...(requestFetch !== undefined ? { fetch: requestFetch } : {}),
775
780
  ...(authProvider !== undefined ? { authProvider } : {}),
776
781
  ...(authProvider !== undefined
777
782
  && definition.oauth !== false
package/tool-metadata.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { getToolUiResourceUri } from "./ui-app-bridge-helpers.ts";
2
2
  import type { McpExtensionState } from "./state.ts";
3
3
  import type { ToolMetadata, McpTool, McpResource, ServerEntry, ToolPrefix } from "./types.ts";
4
- import { formatToolName, getToolNameCandidates, isToolAllowed, resolveToolPrefix } from "./types.ts";
4
+ import { createToolSelectorCandidateIndex, formatToolName, getToolNameCandidates, isToolAllowed, resolveToolPrefix } from "./types.ts";
5
5
  import { resourceNameToToolName } from "./resource-tools.ts";
6
6
  import { extractToolUiStreamMode } from "./utils.ts";
7
7
  import { extractUiToolVisibility, isUiToolVisibleToModel } from "./ui-tool-visibility.ts";
@@ -20,48 +20,68 @@ export function buildToolMetadata(
20
20
  const failedTools: string[] = [];
21
21
  const seenNames = new Set<string>();
22
22
  const effectivePrefix = resolveToolPrefix(definition, prefix);
23
- const getOtherCurrentCandidates = (toolName: string): Set<string> | undefined => {
24
- if (!configuredServers) return undefined;
23
+ const hasToolFilters =
24
+ (Array.isArray(definition.includeTools) && definition.includeTools.length > 0) ||
25
+ (Array.isArray(definition.excludeTools) && definition.excludeTools.length > 0);
26
+ const selectorCandidateIndex = hasToolFilters && configuredServers ? (() => {
25
27
  const candidates = new Set<string>();
26
- const addCandidates = (originalName: string, candidateServerName: string, candidatePrefix: ToolPrefix) => {
27
- for (const candidate of getToolNameCandidates(originalName, candidateServerName, candidatePrefix, false)) candidates.add(candidate);
28
+ const additionalCandidatesByToolName = new Map<string, Set<string>>();
29
+ const evaluatedToolNames = new Set<string>();
30
+ const addCandidates = (
31
+ target: Set<string>,
32
+ originalName: string,
33
+ candidateServerName: string,
34
+ candidatePrefix: ToolPrefix,
35
+ ) => {
36
+ for (const candidate of getToolNameCandidates(originalName, candidateServerName, candidatePrefix, false)) target.add(candidate);
28
37
  };
29
38
 
30
39
  for (const tool of tools) {
31
- if (tool?.name) addCandidates(tool.name, serverName, effectivePrefix);
40
+ if (!tool?.name) continue;
41
+ evaluatedToolNames.add(tool.name);
42
+ addCandidates(candidates, tool.name, serverName, effectivePrefix);
32
43
  }
33
44
  if (definition.exposeResources !== false) {
34
45
  for (const resource of resources) {
35
- if (resource?.name && resource?.uri) addCandidates(`read_${resourceNameToToolName(resource.name)}`, serverName, effectivePrefix);
46
+ const baseName = `read_${resourceNameToToolName(resource.name)}`;
47
+ evaluatedToolNames.add(baseName);
48
+ if (resource?.name && resource?.uri) addCandidates(candidates, baseName, serverName, effectivePrefix);
36
49
  }
37
50
  }
38
51
  for (const [otherServerName, otherDefinition] of Object.entries(configuredServers)) {
39
52
  if (otherServerName === serverName) continue;
53
+ const otherPrefix = resolveToolPrefix(otherDefinition, prefix);
40
54
  const knownTools = knownMetadata?.get(otherServerName);
41
55
  if (knownTools) {
42
- const otherPrefix = resolveToolPrefix(otherDefinition, prefix);
43
56
  for (const tool of knownTools) {
44
57
  candidates.add(tool.name);
45
- addCandidates(tool.originalName, otherServerName, otherPrefix);
58
+ addCandidates(candidates, tool.originalName, otherServerName, otherPrefix);
46
59
  }
47
60
  } else if (!knownMetadata || includeMissingConfiguredCandidates) {
48
- const otherPrefix = resolveToolPrefix(otherDefinition, prefix);
49
- addCandidates(toolName, otherServerName, otherPrefix);
50
- if (includeMissingConfiguredCandidates) {
51
- for (const candidate of getToolNameCandidates(toolName, otherServerName, otherPrefix, false)) candidates.add(candidate.replace(/-/g, "_"));
61
+ for (const toolName of evaluatedToolNames) {
62
+ let additionalCandidates = additionalCandidatesByToolName.get(toolName);
63
+ if (!additionalCandidates) {
64
+ additionalCandidates = new Set<string>();
65
+ additionalCandidatesByToolName.set(toolName, additionalCandidates);
66
+ }
67
+ addCandidates(additionalCandidates, toolName, otherServerName, otherPrefix);
68
+ if (includeMissingConfiguredCandidates) {
69
+ for (const candidate of getToolNameCandidates(toolName, otherServerName, otherPrefix, false)) {
70
+ additionalCandidates.add(candidate.replace(/-/g, "_"));
71
+ }
72
+ }
52
73
  }
53
74
  }
54
75
  }
55
- for (const candidate of getToolNameCandidates(toolName, serverName, effectivePrefix, false)) candidates.delete(candidate);
56
- return candidates;
57
- };
76
+ return createToolSelectorCandidateIndex(candidates, additionalCandidatesByToolName);
77
+ })() : undefined;
58
78
 
59
79
  for (const tool of tools) {
60
80
  if (!tool?.name) {
61
81
  failedTools.push("(unnamed)");
62
82
  continue;
63
83
  }
64
- if (!isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, getOtherCurrentCandidates(tool.name))) {
84
+ if (!isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, selectorCandidateIndex)) {
65
85
  continue;
66
86
  }
67
87
 
@@ -97,7 +117,7 @@ export function buildToolMetadata(
97
117
  if (definition.exposeResources !== false) {
98
118
  for (const resource of resources) {
99
119
  const baseName = `read_${resourceNameToToolName(resource.name)}`;
100
- if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, getOtherCurrentCandidates(baseName))) {
120
+ if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools, selectorCandidateIndex)) {
101
121
  continue;
102
122
  }
103
123
 
package/types.ts CHANGED
@@ -356,6 +356,19 @@ export interface OAuthConfig {
356
356
  skipIssuerMetadataValidation?: boolean;
357
357
  }
358
358
 
359
+ /**
360
+ * Trusted executable invoked for every outbound HTTP request. The adapter
361
+ * writes a versioned JSON request envelope to stdin and expects a JSON object
362
+ * containing headers on stdout. This is intended for caller-bound signing
363
+ * schemes whose headers depend on the exact request body.
364
+ */
365
+ export interface HttpRequestHeadersCommand {
366
+ command: string;
367
+ args?: string[];
368
+ env?: Record<string, string>;
369
+ timeoutMs?: number;
370
+ }
371
+
359
372
  // Server configuration
360
373
  export interface ServerEntry {
361
374
  command?: string;
@@ -367,6 +380,8 @@ export interface ServerEntry {
367
380
  // HTTP fields
368
381
  url?: string;
369
382
  headers?: Record<string, string>;
383
+ /** Add or replace HTTP headers by running a trusted command for each request. */
384
+ requestHeadersCommand?: HttpRequestHeadersCommand;
370
385
  /**
371
386
  * Authentication type:
372
387
  * - 'oauth' - Use OAuth 2.1 (auto-discovers endpoints, supports dynamic client registration)
@@ -489,6 +504,8 @@ export interface McpSettings {
489
504
  idleTimeout?: number; // minutes, default 10, 0 to disable
490
505
  requestTimeoutMs?: number; // milliseconds, overrides the SDK request timeout when > 0
491
506
  directTools?: boolean;
507
+ /** Show the advisory when 75 or more direct tools resolve. Defaults to true. */
508
+ warnOnLargeDirectTools?: boolean;
492
509
  /** Register the trusted MCP-only JavaScript scripting tool. Defaults to true; set false to hide it. */
493
510
  scriptMode?: boolean;
494
511
  /** Render MCP tool results as compact self-rendered rows by default, or as the legacy boxed row. */
@@ -789,6 +806,25 @@ function globToRegExp(pattern: string): RegExp {
789
806
  return new RegExp(`^${escaped}$`);
790
807
  }
791
808
 
809
+ export interface ToolSelectorCandidateIndex {
810
+ readonly allCurrentCandidates: ReadonlySet<string>;
811
+ readonly matchingCountByPattern: Map<string, number>;
812
+ readonly matcherByPattern: Map<string, RegExp>;
813
+ readonly additionalCurrentCandidatesByToolName?: ReadonlyMap<string, ReadonlySet<string>>;
814
+ }
815
+
816
+ export function createToolSelectorCandidateIndex(
817
+ allCurrentCandidates: Set<string>,
818
+ additionalCurrentCandidatesByToolName?: ReadonlyMap<string, ReadonlySet<string>>,
819
+ ): ToolSelectorCandidateIndex {
820
+ return {
821
+ allCurrentCandidates,
822
+ matchingCountByPattern: new Map<string, number>(),
823
+ matcherByPattern: new Map<string, RegExp>(),
824
+ ...(additionalCurrentCandidatesByToolName ? { additionalCurrentCandidatesByToolName } : {}),
825
+ };
826
+ }
827
+
792
828
  export function matchesToolPattern(candidates: Set<string>, patterns?: unknown): boolean {
793
829
  if (!Array.isArray(patterns) || patterns.length === 0) return false;
794
830
 
@@ -805,24 +841,71 @@ export function matchesToolPattern(candidates: Set<string>, patterns?: unknown):
805
841
  return false;
806
842
  }
807
843
 
844
+ export type ToolSelectorCandidateContext = Set<string> | ToolSelectorCandidateIndex;
845
+
846
+ function indexHasOtherCurrentMatch(
847
+ index: ToolSelectorCandidateIndex,
848
+ toolName: string,
849
+ currentCandidates: Set<string>,
850
+ pattern: string,
851
+ ): boolean {
852
+ const additionalCandidates = index.additionalCurrentCandidatesByToolName?.get(toolName);
853
+ const hasCandidate = (candidate: string): boolean =>
854
+ index.allCurrentCandidates.has(candidate) || additionalCandidates?.has(candidate) === true;
855
+ const isGlob = pattern.includes("*") || pattern.includes("?");
856
+ if (!isGlob) {
857
+ return hasCandidate(pattern) && !currentCandidates.has(pattern);
858
+ }
859
+
860
+ let matcher = index.matcherByPattern.get(pattern);
861
+ if (!matcher) {
862
+ matcher = globToRegExp(pattern);
863
+ index.matcherByPattern.set(pattern, matcher);
864
+ }
865
+ let matchingCount = index.matchingCountByPattern.get(pattern);
866
+ if (matchingCount === undefined) {
867
+ matchingCount = 0;
868
+ for (const candidate of index.allCurrentCandidates) {
869
+ if (matcher.test(candidate)) matchingCount++;
870
+ }
871
+ index.matchingCountByPattern.set(pattern, matchingCount);
872
+ }
873
+
874
+ let totalMatchingCount = matchingCount;
875
+ if (additionalCandidates) {
876
+ for (const candidate of additionalCandidates) {
877
+ if (!index.allCurrentCandidates.has(candidate) && matcher.test(candidate)) totalMatchingCount++;
878
+ }
879
+ }
880
+ if (totalMatchingCount === 0) return false;
881
+
882
+ let currentMatchingCount = 0;
883
+ for (const candidate of currentCandidates) {
884
+ if (hasCandidate(candidate) && matcher.test(candidate)) currentMatchingCount++;
885
+ }
886
+ return totalMatchingCount > currentMatchingCount;
887
+ }
888
+
808
889
  function matchesToolSelector(
809
890
  toolName: string,
810
891
  serverName: string,
811
892
  prefix: ToolPrefix,
812
893
  patterns: unknown,
813
- otherCurrentCandidates?: Set<string>,
894
+ otherCurrentCandidates?: ToolSelectorCandidateContext,
814
895
  ): boolean {
896
+ if (!Array.isArray(patterns) || patterns.length === 0) return false;
815
897
  const currentCandidates = getToolNameCandidates(toolName, serverName, prefix, false);
816
898
  if (matchesToolPattern(currentCandidates, patterns)) return true;
817
899
  if (!otherCurrentCandidates) return matchesToolPattern(getToolNameCandidates(toolName, serverName, prefix), patterns);
818
900
  const legacyCandidates = getToolNameCandidates(toolName, serverName, prefix);
819
901
  for (const candidate of currentCandidates) legacyCandidates.delete(candidate);
820
- if (!Array.isArray(patterns)) return false;
821
- return patterns.some(pattern =>
822
- typeof pattern === "string"
823
- && !matchesToolPattern(otherCurrentCandidates, [pattern])
824
- && matchesToolPattern(legacyCandidates, [pattern]),
825
- );
902
+ return patterns.some(pattern => {
903
+ if (typeof pattern !== "string" || !matchesToolPattern(legacyCandidates, [pattern])) return false;
904
+ const hasCollision = otherCurrentCandidates instanceof Set
905
+ ? matchesToolPattern(otherCurrentCandidates, [pattern])
906
+ : indexHasOtherCurrentMatch(otherCurrentCandidates, toolName, currentCandidates, pattern);
907
+ return !hasCollision;
908
+ });
826
909
  }
827
910
 
828
911
  export function isToolIncluded(
@@ -830,7 +913,7 @@ export function isToolIncluded(
830
913
  serverName: string,
831
914
  prefix: ToolPrefix,
832
915
  includeTools?: unknown,
833
- otherCurrentCandidates?: Set<string>,
916
+ otherCurrentCandidates?: ToolSelectorCandidateContext,
834
917
  ): boolean {
835
918
  if (!Array.isArray(includeTools) || includeTools.length === 0) return true;
836
919
  return matchesToolSelector(toolName, serverName, prefix, includeTools, otherCurrentCandidates);
@@ -841,7 +924,7 @@ export function isToolExcluded(
841
924
  serverName: string,
842
925
  prefix: ToolPrefix,
843
926
  excludeTools?: unknown,
844
- otherCurrentCandidates?: Set<string>,
927
+ otherCurrentCandidates?: ToolSelectorCandidateContext,
845
928
  ): boolean {
846
929
  return matchesToolSelector(toolName, serverName, prefix, excludeTools, otherCurrentCandidates);
847
930
  }
@@ -852,7 +935,7 @@ export function isToolAllowed(
852
935
  prefix: ToolPrefix,
853
936
  includeTools?: unknown,
854
937
  excludeTools?: unknown,
855
- otherCurrentCandidates?: Set<string>,
938
+ otherCurrentCandidates?: ToolSelectorCandidateContext,
856
939
  ): boolean {
857
940
  return isToolIncluded(toolName, serverName, prefix, includeTools, otherCurrentCandidates)
858
941
  && !isToolExcluded(toolName, serverName, prefix, excludeTools, otherCurrentCandidates);