pi-mcp-adapter 2.14.0 → 2.16.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,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.16.0] - 2026-07-30
11
+
12
+ ### Added
13
+ - Added an optional per-server `toolPrefix` override for direct MCP tools, prompts, and proxy summaries, falling back to the global prefix when unset. Thanks @FurryWolfX for issue #229.
14
+ - Added an advisory warning when resolved direct tools pass the documented 75-tool threshold, with no cap or enforcement. Thanks @JasonLandbridge for issue #240.
15
+
16
+ ### Changed
17
+ - Restored the MCP SDK v1 client for compatibility with deployed MCP servers and OAuth providers. This rollback temporarily removes SDK v2-only protocol negotiation while retaining OAuth issuer binding and callback issuer validation. Thanks @hyknerf for PR #237 and issue #236, @leonfox28 for issue #227, and @JorelLatraille for issue #241.
18
+
19
+ ### Fixed
20
+ - Avoided a double-close race when disconnecting MCP connections by letting the SDK client own transport shutdown. Thanks Szymon Wiszczuk (@golota60) for PR #235.
21
+ - Prevented `/mcp` and `/mcp-auth` from crashing when the OS OAuth credential store is unavailable.
22
+ - Stored oversized OAuth credential payloads as secure-store manifests plus chunks, so Windows Credential Manager value limits no longer block OAuth completion for large token records. Thanks @LysanderdeJong for issue #223.
23
+ - Stored OAuth credential payloads as compact JSON, so multiline secrets no longer corrupt gnome-keyring plaintext keyrings. Thanks @hank-warren for issue #239.
24
+ - Kept collapsed MCP tool results from re-wrapping full multi-10KB payloads on repeated TUI renders, avoiding composer keystroke lag after large MCP dumps. Thanks @hyknerf for PR #233 and @chapmanb for the held-Text follow-up fix.
25
+
26
+ ## [2.15.0] - 2026-07-25
27
+
28
+ ### Added
29
+ - Added native `rmcp-mux` Unix-socket connections for explicitly sharing external MCP server processes across Pi sessions. Thanks j0e1 (@pWoLiAn) for issue #76.
30
+ - Added connection-time command resolution for HTTP bearer tokens and headers, OAuth client secrets, and stdio environment values, with `!!` escaping and fail-closed execution. Thanks @estrizhok for issue #221.
31
+
32
+ ### Fixed
33
+ - Treated null optional server URLs as absent and cleaned unpublished runtimes after initialization failures, preventing `not_initialized` sessions with surviving MCP children. Thanks @autopeasant for the diagnosis in issue #222.
34
+ - Registered env-selected direct MCP tools before child `agent_start` when their metadata cache must be populated first. Thanks @peedrr for the original report in pi-subagents issue #638 and issue #219.
35
+
10
36
  ## [2.14.0] - 2026-07-25
11
37
 
12
38
  ### Added
package/README.md CHANGED
@@ -172,26 +172,28 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
172
172
 
173
173
  | Field | Description |
174
174
  |-------|-------------|
175
- | `command` | Executable for stdio transport |
175
+ | `command` | Executable for stdio transport; mutually exclusive with `url` and `socket` |
176
176
  | `args` | Command arguments |
177
- | `env` | Environment variables; supports `${VAR}` and `$env:VAR` interpolation |
177
+ | `socket` | Explicit `rmcp-mux` Unix-domain socket path; supports `${VAR}`, `$env:VAR`, and `~` expansion and is mutually exclusive with `command` and `url` |
178
+ | `env` | Environment variables; supports `${VAR}` and `$env:VAR` interpolation. A value beginning with `!` runs a command when the stdio server connects; use `!!` for a literal leading `!`. |
178
179
  | `cwd` | Working directory; supports `${VAR}`, `$env:VAR`, and `~` expansion |
179
180
  | `url` | HTTP endpoint (StreamableHTTP with SSE fallback); supports raw `${VAR}` and `$env:VAR` interpolation, and missing URL variables fail before any request is sent |
180
- | `headers` | HTTP headers; supports `${VAR}` and `$env:VAR` interpolation |
181
+ | `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 `!`. |
181
182
  | `auth` | `"bearer"` or `"oauth"` |
182
183
  | `oauth.grantType` | `"authorization_code"` (default) or `"client_credentials"` for non-interactive machine auth |
183
184
  | `oauth.clientId` | Pre-registered OAuth client ID; dynamic registration is used when omitted |
184
- | `oauth.clientSecret` | OAuth client secret for confidential clients |
185
+ | `oauth.clientSecret` | OAuth client secret for confidential clients; a value beginning with `!` runs a command when OAuth authenticates, while `!!` escapes a literal leading `!` |
185
186
  | `oauth.scope` | Requested OAuth scopes |
186
187
  | `oauth.redirectUri` | Exact localhost redirect URI for browser OAuth, including port and path, for providers that pre-register callbacks |
187
188
  | `oauth.clientName` | Client display name advertised during dynamic registration |
188
189
  | `oauth.clientUri` | Client homepage URI advertised during dynamic registration |
189
- | `bearerToken` / `bearerTokenEnv` | Token or env var name; `bearerToken` supports `${VAR}` and `$env:VAR` interpolation |
190
+ | `bearerToken` / `bearerTokenEnv` | Token or env var name; `bearerToken` supports `${VAR}` and `$env:VAR` interpolation. A leading `!` in `bearerToken` runs a command when the HTTP server connects; use `!!` for a literal leading `!`. |
190
191
  | `lifecycle` | `"lazy"` (default), `"eager"`, `"keep-alive"`, or `"lazy-keep-alive"` |
191
192
  | `idleTimeout` | Minutes before idle disconnect (overrides global) |
192
193
  | `requestTimeoutMs` | Request timeout in milliseconds for live MCP calls (overrides global; if omitted or `<= 0`, the MCP SDK default timeout is used) |
193
194
  | `exposeResources` | Expose MCP resources as tools (default: true) |
194
195
  | `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy |
196
+ | `toolPrefix` | Override global `settings.toolPrefix` for this server (`"server"`, `"short"`, `"none"`, or `"mcp"`) |
195
197
  | `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
198
  | `excludeTools` | `string[]` of tool names or glob patterns to hide (applied after `includeTools`) |
197
199
  | `debug` | Show server stderr (default: false) |
@@ -200,6 +202,24 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
200
202
 
201
203
  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.
202
204
 
205
+ Secret values in `headers`, `bearerToken`, `oauth.clientSecret`, and stdio `env` may use a leading `!command` to obtain their value at connection or authentication time. The command runs with stdin and stderr suppressed, stdout is limited to 1 MiB and trimmed, and it must finish within 10 seconds with non-empty output; failures stop the connection or authentication flow. Commands are not run during OAuth discovery or while reading, merging, previewing, hashing, or rendering configuration. Use `!!` to escape a literal leading `!`; ordinary and escaped values retain environment interpolation.
206
+
207
+ ### Shared MCP processes with rmcp-mux
208
+
209
+ To share one stdio MCP server across Pi sessions, run it under [`rmcp-mux`](https://github.com/VetCoders/rmcp-mux) and point each session at the service socket:
210
+
211
+ ```json
212
+ {
213
+ "mcpServers": {
214
+ "memory": {
215
+ "socket": "~/.rmcp-servers/rmcp-mux/sockets/memory.sock"
216
+ }
217
+ }
218
+ }
219
+ ```
220
+
221
+ The adapter owns only its client socket and closes that connection when the Pi runtime stops. `rmcp-mux` owns the upstream process, request routing, initialization cache, restart policy, client limits, and socket permissions. Start and configure the mux separately; the adapter never discovers, starts, adopts, or stops its daemon. A socket is an explicit trusted local endpoint, so do not point unrelated projects or users at a mux service unless its tools, state, credentials, and filesystem access are intended to be shared.
222
+
203
223
  ### Remote/headless OAuth
204
224
 
205
225
  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:
@@ -253,7 +273,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
253
273
 
254
274
  | Setting | Description |
255
275
  |---------|-------------|
256
- | `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), `"none"`, or `"mcp"` (prefixes with `mcp__`, using server-mode normalization) |
276
+ | `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), `"none"`, or `"mcp"` (prefixes with `mcp__`, using server-mode normalization). Per-server `toolPrefix` overrides this for that server. |
257
277
  | `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
258
278
  | `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
259
279
  | `showStatusIcon` | Show the plug icon in MCP status and connection text (default: `true`). Set to `false` for plain `MCP: ...` text. |
@@ -388,7 +408,7 @@ To hide specific tools while still using `directTools: true`, add `excludeTools`
388
408
 
389
409
  `includeTools` and `excludeTools` filter direct tools, proxy search/list/describe, and the `/mcp` panel view.
390
410
 
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[]`.
411
+ 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.
392
412
 
393
413
  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>`.
394
414
 
package/commands.ts CHANGED
@@ -17,7 +17,7 @@ import { markKeepAliveAfterConnect, notifyToolMetadataUpdated, updateMetadataCac
17
17
  import { loadMetadataCache, reconstructPromptMetadata } from "./metadata-cache.ts";
18
18
  import { buildToolMetadata } from "./tool-metadata.ts";
19
19
  import { supportsOAuth, authenticate, removeAuth, type McpOAuthRuntime } from "./mcp-auth-flow.ts";
20
- import { getAuthForUrl, getAuthStorageOptions } from "./mcp-auth.ts";
20
+ import { getAuthStorageOptions, inspectAuthForUrl } from "./mcp-auth.ts";
21
21
  import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markSharedConfigHintShown } from "./onboarding-state.ts";
22
22
  import { openPath, resolveServerUrl, sanitizeTerminalText } from "./utils.ts";
23
23
  import { isAbortError } from "./runtime-owner.ts";
@@ -166,7 +166,7 @@ export async function reconnectServer(
166
166
  const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
167
167
  state.toolMetadata.set(name, metadata);
168
168
  if (!connection.promptDiscoveryFailed) {
169
- state.promptMetadata?.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix));
169
+ state.promptMetadata?.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix, definition));
170
170
  state.promptMetadataLive?.add(name);
171
171
  }
172
172
  if (connection.instructions) {
@@ -312,9 +312,33 @@ export async function logoutServer(
312
312
  return { ok: false, message };
313
313
  }
314
314
 
315
- await removeAuth(serverName, { authStorageOptions: state.authStorageOptions, signal: state.owner?.signal, runtime: state.oauthRuntime });
315
+ const signal = state.owner?.signal;
316
+ try {
317
+ await removeAuth(serverName, { authStorageOptions: state.authStorageOptions, signal, runtime: state.oauthRuntime });
318
+ } catch (error) {
319
+ if (isAbortError(error, signal)) throw error;
320
+ const message = error instanceof Error ? error.message : String(error);
321
+ if (ui) {
322
+ ui.notify(`Failed to clear OAuth credentials for "${serverName}": ${sanitizeTerminalText(message)}`, "error");
323
+ }
324
+ return { ok: false, message };
325
+ }
326
+
316
327
  state.owner?.throwIfInactive();
317
- await state.manager.close(serverName);
328
+ try {
329
+ await state.manager.close(serverName);
330
+ } catch (error) {
331
+ if (isAbortError(error, signal)) throw error;
332
+ const message = error instanceof Error ? error.message : String(error);
333
+ if (ui) {
334
+ ui.notify(
335
+ `OAuth credentials were cleared for "${serverName}", but its connection could not be closed: ${sanitizeTerminalText(message)}`,
336
+ "error",
337
+ );
338
+ }
339
+ return { ok: false, message };
340
+ }
341
+
318
342
  state.owner?.throwIfInactive();
319
343
  updateStatusBar(state);
320
344
 
@@ -416,6 +440,11 @@ function buildMcpPanelCallbacks(
416
440
  config: McpConfig,
417
441
  ctx: ExtensionContext,
418
442
  ): McpPanelCallbacks {
443
+ // Panel-only diagnostics keep status inspection from mutating connection
444
+ // failure state while allowing the existing panel failure UI to show why the
445
+ // credential store could not be inspected.
446
+ const authStatusFailures = new Map<string, string>();
447
+
419
448
  return {
420
449
  reconnect: (serverName: string) => reconnectServer(state, ctx, serverName),
421
450
  canAuthenticate: (serverName: string) => {
@@ -424,12 +453,10 @@ function buildMcpPanelCallbacks(
424
453
  },
425
454
  authenticate: (serverName: string) => authenticateServer(serverName, config, ctx, state.owner?.signal, state.oauthRuntime),
426
455
  getConnectionStatus: (serverName: string) => {
456
+ authStatusFailures.delete(serverName);
427
457
  const definition = config.mcpServers[serverName];
428
458
  if (isServerDisabled(definition)) return "disabled";
429
459
  const connection = state.manager.getConnection(serverName);
430
- if (connection?.status === "needs-auth") {
431
- return "needs-auth";
432
- }
433
460
  let serverUrl: string | undefined;
434
461
  try {
435
462
  serverUrl = definition ? resolveServerUrl(definition) : undefined;
@@ -441,15 +468,22 @@ function buildMcpPanelCallbacks(
441
468
  && serverUrl
442
469
  && definition.oauth !== false
443
470
  && definition.oauth?.grantType !== "client_credentials"
444
- && !getAuthForUrl(serverName, serverUrl, state.authStorageOptions)?.tokens
445
471
  ) {
446
- return "needs-auth";
472
+ const authStatus = inspectAuthForUrl(serverName, serverUrl, state.authStorageOptions);
473
+ if (authStatus.status === "unavailable") {
474
+ authStatusFailures.set(serverName, authStatus.message);
475
+ return "failed";
476
+ }
477
+ if (authStatus.status === "absent" || !authStatus.entry.tokens) {
478
+ return "needs-auth";
479
+ }
447
480
  }
481
+ if (connection?.status === "needs-auth") return "needs-auth";
448
482
  if (connection?.status === "connected") return "connected";
449
483
  if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
450
484
  return "idle";
451
485
  },
452
- getFailureMessage: (serverName: string) => getFailureMessage(state, serverName),
486
+ getFailureMessage: (serverName: string) => authStatusFailures.get(serverName) ?? getFailureMessage(state, serverName),
453
487
  refreshCacheAfterReconnect: (serverName: string) => {
454
488
  const freshCache = loadMetadataCache();
455
489
  return freshCache?.servers?.[serverName] ?? null;
package/config.ts CHANGED
@@ -409,8 +409,20 @@ function mergeServerMaps(
409
409
  // applies (it is spread last). Behaviour is unchanged when the url is
410
410
  // identical or the override omits `url` (partial overrides still inherit).
411
411
  let baseEntry: ServerEntry = existing ?? {};
412
- if (existing && typeof definition.url === "string" && definition.url !== existing.url) {
412
+ if (existing && typeof definition.socket === "string") {
413
+ baseEntry = { ...existing };
414
+ for (const field of [
415
+ "command", "args", "env", "cwd", "url", "headers", "auth",
416
+ "bearerToken", "bearerTokenEnv", "oauth",
417
+ ] as const) {
418
+ delete baseEntry[field];
419
+ }
420
+ } else if (existing?.socket && (typeof definition.command === "string" || typeof definition.url === "string")) {
413
421
  baseEntry = { ...existing };
422
+ delete baseEntry.socket;
423
+ }
424
+ if (existing && typeof definition.url === "string" && definition.url !== existing.url) {
425
+ if (baseEntry === existing) baseEntry = { ...existing };
414
426
  for (const field of URL_BOUND_AUTH_FIELDS) {
415
427
  delete baseEntry[field];
416
428
  }
package/direct-tools.ts CHANGED
@@ -1,17 +1,17 @@
1
1
  import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { UrlElicitationRequiredError } from "@modelcontextprotocol/client";
2
+ import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
3
3
  import type { McpExtensionState } from "./state.ts";
4
4
  import type { DirectToolSpec, McpConfig, McpContent, ToolPrefix } from "./types.ts";
5
5
  import type { MetadataCache } from "./metadata-cache.ts";
6
6
  import { lazyConnect, getFailureAgeSeconds, clearFailure } from "./init.ts";
7
7
  import { abortable, throwIfAborted } from "./abort.ts";
8
- import { isServerCacheValid } from "./metadata-cache.ts";
8
+ import { isServerCacheValid, parseDirectToolSelectors } from "./metadata-cache.ts";
9
9
  export { getMissingConfiguredDirectToolServers } from "./metadata-cache.ts";
10
10
  import { formatSchema } from "./tool-metadata.ts";
11
11
  import { resolveMcpResultContent, transformMcpContent } 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, isServerDisabled, isToolAllowed } from "./types.ts";
14
+ import { formatToolName, isServerDisabled, isToolAllowed, resolveToolPrefix } from "./types.ts";
15
15
  import { resourceNameToToolName } from "./resource-tools.ts";
16
16
  import { authenticate, supportsOAuth } from "./mcp-auth-flow.ts";
17
17
  import { formatAuthRequiredMessage, resolveServerUrl, truncateAtWord } from "./utils.ts";
@@ -20,6 +20,7 @@ import { combineAbortSignals, isAbortError } from "./runtime-owner.ts";
20
20
 
21
21
  const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
22
22
  const INSTRUCTIONS_SNIPPET_LENGTH = 150;
23
+ export const DIRECT_TOOLS_ADVISORY_THRESHOLD = 75;
23
24
 
24
25
  type DirectAutoAuthResult =
25
26
  | { status: "skipped" }
@@ -114,25 +115,7 @@ export function resolveDirectTools(
114
115
 
115
116
  const seenNames = new Set<string>();
116
117
 
117
- const envServers = new Set<string>();
118
- const envTools = new Map<string, Set<string>>();
119
- if (envOverride) {
120
- for (let item of envOverride) {
121
- item = item.replace(/\/+$/, "");
122
- if (item.includes("/")) {
123
- const [server, tool] = item.split("/", 2);
124
- if (server && tool) {
125
- if (!envTools.has(server)) envTools.set(server, new Set());
126
- envTools.get(server)!.add(tool);
127
- } else if (server) {
128
- envServers.add(server);
129
- }
130
- } else if (item) {
131
- envServers.add(item);
132
- }
133
- }
134
- }
135
-
118
+ const envSelection = envOverride ? parseDirectToolSelectors(envOverride) : null;
136
119
  const globalDirect = config.settings?.directTools;
137
120
 
138
121
  for (const [serverName, definition] of Object.entries(config.mcpServers)) {
@@ -142,11 +125,11 @@ export function resolveDirectTools(
142
125
 
143
126
  let toolFilter: true | string[] | false = false;
144
127
 
145
- if (envOverride) {
146
- if (envServers.has(serverName)) {
128
+ if (envSelection) {
129
+ if (envSelection.servers.has(serverName)) {
147
130
  toolFilter = true;
148
- } else if (envTools.has(serverName)) {
149
- toolFilter = [...envTools.get(serverName)!];
131
+ } else if (envSelection.tools.has(serverName)) {
132
+ toolFilter = [...envSelection.tools.get(serverName)!];
150
133
  }
151
134
  } else {
152
135
  if (definition.directTools !== undefined) {
@@ -158,10 +141,12 @@ export function resolveDirectTools(
158
141
 
159
142
  if (!toolFilter) continue;
160
143
 
144
+ const effectivePrefix = resolveToolPrefix(definition, prefix);
145
+
161
146
  for (const tool of serverCache.tools ?? []) {
162
147
  if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
163
- if (!isToolAllowed(tool.name, serverName, prefix, definition.includeTools, definition.excludeTools)) continue;
164
- const prefixedName = formatToolName(tool.name, serverName, prefix);
148
+ if (!isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools)) continue;
149
+ const prefixedName = formatToolName(tool.name, serverName, effectivePrefix);
165
150
  if (BUILTIN_NAMES.has(prefixedName)) {
166
151
  console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
167
152
  continue;
@@ -186,8 +171,8 @@ export function resolveDirectTools(
186
171
  for (const resource of serverCache.resources ?? []) {
187
172
  const baseName = `read_${resourceNameToToolName(resource.name)}`;
188
173
  if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
189
- if (!isToolAllowed(baseName, serverName, prefix, definition.includeTools, definition.excludeTools)) continue;
190
- const prefixedName = formatToolName(baseName, serverName, prefix);
174
+ if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools)) continue;
175
+ const prefixedName = formatToolName(baseName, serverName, effectivePrefix);
191
176
  if (BUILTIN_NAMES.has(prefixedName)) {
192
177
  console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
193
178
  continue;
@@ -208,6 +193,10 @@ export function resolveDirectTools(
208
193
  }
209
194
  }
210
195
 
196
+ if (specs.length >= DIRECT_TOOLS_ADVISORY_THRESHOLD) {
197
+ 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.`);
198
+ }
199
+
211
200
  return specs;
212
201
  }
213
202
 
@@ -235,13 +224,14 @@ export function buildProxyDescription(
235
224
  const definition = config.mcpServers[serverName];
236
225
  if (isServerDisabled(definition)) continue;
237
226
  const entry = cache?.servers?.[serverName];
227
+ const effectivePrefix = resolveToolPrefix(definition, prefix);
238
228
  const toolCount = (entry?.tools ?? []).filter(
239
- (tool) => isToolAllowed(tool.name, serverName, prefix, definition.includeTools, definition.excludeTools),
229
+ (tool) => isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools),
240
230
  ).length;
241
231
  const resourceCount = definition?.exposeResources !== false
242
232
  ? (entry?.resources ?? []).filter((resource) => {
243
233
  const baseName = `read_${resourceNameToToolName(resource.name)}`;
244
- return isToolAllowed(baseName, serverName, prefix, definition.includeTools, definition.excludeTools);
234
+ return isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools);
245
235
  }).length
246
236
  : 0;
247
237
  const totalItems = toolCount + resourceCount;
@@ -451,9 +441,9 @@ export function createDirectToolExecutor(
451
441
  name: spec.originalName,
452
442
  arguments: params ?? {},
453
443
  _meta: uiSession?.requestMeta,
454
- }, requestOptions), ownedSignal),
444
+ }, undefined, requestOptions), ownedSignal),
455
445
  );
456
- uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/client").CallToolResult);
446
+ uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
457
447
 
458
448
  if (result.isError) {
459
449
  const mcpContent = (result.content ?? []) as McpContent[];
@@ -1,15 +1,16 @@
1
1
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
3
  import {
3
- ProtocolError,
4
- ProtocolErrorCode,
5
- type Client,
4
+ ElicitRequestSchema,
5
+ ErrorCode,
6
+ McpError,
6
7
  type ElicitRequest,
7
8
  type ElicitRequestFormParams,
8
9
  type ElicitRequestURLParams,
9
10
  type ElicitResult,
10
- type JsonSchemaType,
11
- } from "@modelcontextprotocol/client";
12
- import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
11
+ } from "@modelcontextprotocol/sdk/types.js";
12
+ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
13
+ import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types.js";
13
14
  import open from "open";
14
15
 
15
16
  export type ElicitationValue = string | number | boolean | string[] | undefined;
@@ -27,7 +28,7 @@ export interface ElicitationHandlerOptions {
27
28
  export type ServerElicitationConfig = Omit<ElicitationHandlerOptions, "serverName" | "onUrlAccepted">;
28
29
 
29
30
  export function registerElicitationHandler(client: Client, options: ElicitationHandlerOptions): void {
30
- client.setRequestHandler("elicitation/create", (request) =>
31
+ client.setRequestHandler(ElicitRequestSchema, request =>
31
32
  handleElicitationRequest(options, request));
32
33
  }
33
34
 
@@ -304,16 +305,16 @@ export async function handleUrlElicitation(
304
305
  options: ElicitationHandlerOptions,
305
306
  params: ElicitRequestURLParams,
306
307
  ): Promise<ElicitResult> {
307
- if (!options.allowUrl) throw new ProtocolError(ProtocolErrorCode.InvalidParams, "URL elicitation is not supported");
308
+ if (!options.allowUrl) throw new McpError(ErrorCode.InvalidParams, "URL elicitation is not supported");
308
309
 
309
310
  let parsed: URL;
310
311
  try {
311
312
  parsed = new URL(params.url);
312
313
  } catch {
313
- throw new ProtocolError(ProtocolErrorCode.InvalidParams, "URL elicitation supplied an invalid URL");
314
+ throw new McpError(ErrorCode.InvalidParams, "URL elicitation supplied an invalid URL");
314
315
  }
315
316
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
316
- throw new ProtocolError(ProtocolErrorCode.InvalidParams, "URL elicitation only supports HTTP and HTTPS URLs");
317
+ throw new McpError(ErrorCode.InvalidParams, "URL elicitation only supports HTTP and HTTPS URLs");
317
318
  }
318
319
 
319
320
  const decision = await options.ui.select([
package/index.ts CHANGED
@@ -254,7 +254,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
254
254
  type: "string",
255
255
  });
256
256
 
257
- function startInitialization(ctx: ExtensionContext, owner: McpRuntimeOwner, oauthRuntime: McpOAuthRuntime, generation: number, staleReason: string): void {
257
+ function startInitialization(ctx: ExtensionContext, owner: McpRuntimeOwner, oauthRuntime: McpOAuthRuntime, generation: number, staleReason: string): Promise<void> {
258
258
  const promise = initializeMcp(pi, ctx, owner, {
259
259
  ...(programmaticConfig || options.configPath !== undefined
260
260
  ? { configPath: earlyConfigPath, config: sessionConfig }
@@ -264,7 +264,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
264
264
  });
265
265
  initPromise = promise;
266
266
 
267
- promise.then(async (nextState) => {
267
+ return promise.then(async (nextState) => {
268
268
  if (!owner.isActive() || generation !== lifecycleGeneration || initPromise !== promise) {
269
269
  try {
270
270
  await shutdownState(nextState, staleReason);
@@ -284,7 +284,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
284
284
  syncToolSurface(ctx);
285
285
  updateStatusBar(nextState);
286
286
  initPromise = null;
287
- }).catch(err => {
287
+ }).catch(async err => {
288
288
  if (!owner.isActive() || generation !== lifecycleGeneration) {
289
289
  return;
290
290
  }
@@ -293,6 +293,16 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
293
293
  }
294
294
  console.error(`MCP initialization failed: ${formatTerminalError(err)}`);
295
295
  initPromise = null;
296
+ if (state) return;
297
+
298
+ try {
299
+ await Promise.all([
300
+ owner.stop("MCP initialization failed"),
301
+ shutdownOAuth(oauthRuntime),
302
+ ]);
303
+ } catch (error) {
304
+ console.error(`MCP: failed to clean rejected initialization: ${formatTerminalError(error)}`);
305
+ }
296
306
  });
297
307
  }
298
308
 
@@ -347,7 +357,17 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
347
357
 
348
358
  if (generation !== lifecycleGeneration || !owner.isActive()) return;
349
359
 
350
- startInitialization(ctx, owner, oauthRuntime, generation, "stale_session_start");
360
+ const initialization = startInitialization(ctx, owner, oauthRuntime, generation, "stale_session_start");
361
+ if (envRaw !== undefined && envRaw !== "__none__") {
362
+ const missingEnvDirectTools = getMissingConfiguredDirectToolServers(
363
+ earlyConfig,
364
+ loadMetadataCache(),
365
+ envDirectToolOverride,
366
+ );
367
+ if (missingEnvDirectTools.length > 0) {
368
+ await initialization;
369
+ }
370
+ }
351
371
  });
352
372
 
353
373
  pi.on("session_shutdown", async () => {
@@ -726,7 +746,11 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
726
746
  }
727
747
 
728
748
  function syncProxyTool(config: McpConfig, cache: MetadataCache | null, directSpecs: DirectToolSpec[]): void {
729
- const missingConfiguredDirectToolServers = getMissingConfiguredDirectToolServers(config, cache);
749
+ const missingConfiguredDirectToolServers = getMissingConfiguredDirectToolServers(
750
+ config,
751
+ cache,
752
+ envRaw === undefined || envRaw === "__none__" ? undefined : envDirectToolOverride,
753
+ );
730
754
  const shouldRegisterProxyTool =
731
755
  config.settings?.disableProxyTool !== true
732
756
  || directSpecs.length === 0
package/init.ts CHANGED
@@ -245,7 +245,7 @@ export async function initializeMcp(
245
245
  resourceCounts.set(name, cachedEntry.resources.length);
246
246
  }
247
247
  if (cachedEntry.prompts?.length) {
248
- promptMetadata.set(name, reconstructPromptMetadata(name, cachedEntry.prompts ?? [], prefix));
248
+ promptMetadata.set(name, reconstructPromptMetadata(name, cachedEntry.prompts ?? [], prefix, definition));
249
249
  }
250
250
  if (cachedEntry.instructions) {
251
251
  serverInstructions.set(name, cachedEntry.instructions);
@@ -301,7 +301,7 @@ export async function initializeMcp(
301
301
  toolMetadata.set(name, metadata);
302
302
  resourceCounts.set(name, connection.resources.length);
303
303
  if (!connection.promptDiscoveryFailed) {
304
- promptMetadata.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix));
304
+ promptMetadata.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix, definition));
305
305
  promptMetadataLive.add(name);
306
306
  }
307
307
  if (connection.instructions) {
@@ -334,7 +334,8 @@ export async function initializeMcp(
334
334
  const envDirect = process.env.MCP_DIRECT_TOOLS;
335
335
  if (envDirect !== "__none__") {
336
336
  const currentCache = loadMetadataCache();
337
- const missingCacheServers = getMissingConfiguredDirectToolServers(config, currentCache);
337
+ const envDirectToolOverride = envDirect?.split(",").map(selector => selector.trim()).filter(Boolean);
338
+ const missingCacheServers = getMissingConfiguredDirectToolServers(config, currentCache, envDirectToolOverride);
338
339
 
339
340
  if (missingCacheServers.length > 0) {
340
341
  const bootstrapResults = await parallelLimit(
@@ -432,7 +433,7 @@ export function updateServerMetadata(state: McpExtensionState, serverName: strin
432
433
  state.toolMetadata.set(serverName, metadata);
433
434
  state.resourceCounts?.set(serverName, connection.resources.length);
434
435
  if (!connection.promptDiscoveryFailed) {
435
- state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix));
436
+ state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix, definition));
436
437
  state.promptMetadataLive?.add(serverName);
437
438
  }
438
439
  if (connection.instructions) {
@@ -1,13 +1,15 @@
1
- import {
2
- Ajv,
3
- AjvJsonSchemaValidator,
4
- addFormats,
5
- } from "@modelcontextprotocol/client/validators/ajv";
1
+ import { Ajv } from "ajv";
2
+ import Ajv2020Import from "ajv/dist/2020.js";
3
+ import addFormatsImport from "ajv-formats";
4
+ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
6
5
  import type {
7
6
  JsonSchemaType,
8
7
  JsonSchemaValidator,
9
8
  jsonSchemaValidator as JsonSchemaValidatorProvider,
10
- } from "@modelcontextprotocol/client";
9
+ } from "@modelcontextprotocol/sdk/validation/types.js";
10
+
11
+ // ajv-formats types target its bundled ajv; the runtime accepts both instances.
12
+ const addFormats = addFormatsImport as unknown as (instance: Ajv) => void;
11
13
 
12
14
  type SchemaDialect =
13
15
  | { status: "unstamped" }
@@ -17,6 +19,9 @@ const DRAFT_07_SCHEMA_URIS: ReadonlySet<string> = new Set([
17
19
  "http://json-schema.org/draft-07/schema",
18
20
  "https://json-schema.org/draft-07/schema",
19
21
  ]);
22
+ const DRAFT_2020_12_SCHEMA_URIS: ReadonlySet<string> = new Set([
23
+ "https://json-schema.org/draft/2020-12/schema",
24
+ ]);
20
25
 
21
26
  function schemaDialect(schema: JsonSchemaType): SchemaDialect {
22
27
  if (!("$schema" in schema) || typeof schema.$schema !== "string") {
@@ -29,14 +34,23 @@ function schemaDialect(schema: JsonSchemaType): SchemaDialect {
29
34
  }
30
35
 
31
36
  export function createJsonSchemaValidator(): JsonSchemaValidatorProvider {
32
- const defaultValidator = new AjvJsonSchemaValidator();
33
37
  let draft07Validator: AjvJsonSchemaValidator | undefined;
38
+ let draft2020Validator: AjvJsonSchemaValidator | undefined;
34
39
 
35
40
  return {
36
41
  getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
37
42
  const dialect = schemaDialect(schema);
38
- if (dialect.status !== "stamped" || !DRAFT_07_SCHEMA_URIS.has(dialect.uri)) {
39
- return defaultValidator.getValidator<T>(schema);
43
+ if (dialect.status === "unstamped" || DRAFT_2020_12_SCHEMA_URIS.has(dialect.uri)) {
44
+ draft2020Validator ??= (() => {
45
+ const Ajv2020 = Ajv2020Import as unknown as typeof Ajv;
46
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
47
+ addFormats(ajv);
48
+ return new AjvJsonSchemaValidator(ajv);
49
+ })();
50
+ return draft2020Validator.getValidator<T>(schema);
51
+ }
52
+ if (!DRAFT_07_SCHEMA_URIS.has(dialect.uri)) {
53
+ throw new Error(`Unsupported JSON Schema dialect: ${dialect.uri}`);
40
54
  }
41
55
 
42
56
  draft07Validator ??= (() => {