pi-mcp-adapter 2.15.0 → 2.17.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,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.17.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - Added `settings.mcpFooterStatus` to compact or hide the persistent MCP footer status. Thanks @jwintz for issue #5.
14
+ - Added per-server OAuth `authorizationParams` for provider-specific authorization URL parameters such as Google's `access_type=offline`, while rejecting OAuth flow-owned parameter overrides. Thanks @hank-warren for issue #238.
15
+
16
+ ### Fixed
17
+ - Added a best-effort absolute-path fallback for loading the `@napi-rs/keyring` native binding when compiled Pi/Bun cannot resolve the package loader. Thanks @sgiath for issue #230.
18
+ - Bound collapsed MCP tool result rendering by character count as well as line count, preventing huge single-line results from slowing long TUI sessions. Thanks @Whisperfall for issue #249.
19
+ - Let configured `oauth.scope` override OAuth discovery scopes during authorization flows. Thanks @viggy28 for issue #225 and @adity982 for PR #226.
20
+
21
+ ## [2.16.0] - 2026-07-30
22
+
23
+ ### Added
24
+ - 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.
25
+ - Added an advisory warning when resolved direct tools pass the documented 75-tool threshold, with no cap or enforcement. Thanks @JasonLandbridge for issue #240.
26
+
27
+ ### Changed
28
+ - 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.
29
+
30
+ ### Fixed
31
+ - Avoided a double-close race when disconnecting MCP connections by letting the SDK client own transport shutdown. Thanks Szymon Wiszczuk (@golota60) for PR #235.
32
+ - Prevented `/mcp` and `/mcp-auth` from crashing when the OS OAuth credential store is unavailable.
33
+ - 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.
34
+ - Stored OAuth credential payloads as compact JSON, so multiline secrets no longer corrupt gnome-keyring plaintext keyrings. Thanks @hank-warren for issue #239.
35
+ - 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.
36
+
10
37
  ## [2.15.0] - 2026-07-25
11
38
 
12
39
  ### Added
package/README.md CHANGED
@@ -193,6 +193,7 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
193
193
  | `requestTimeoutMs` | Request timeout in milliseconds for live MCP calls (overrides global; if omitted or `<= 0`, the MCP SDK default timeout is used) |
194
194
  | `exposeResources` | Expose MCP resources as tools (default: true) |
195
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"`) |
196
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`) |
197
198
  | `excludeTools` | `string[]` of tool names or glob patterns to hide (applied after `includeTools`) |
198
199
  | `debug` | Show server stderr (default: false) |
@@ -257,6 +258,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
257
258
  "idleTimeout": 10,
258
259
  "requestTimeoutMs": 30000,
259
260
  "showStatusIcon": true,
261
+ "mcpFooterStatus": "full",
260
262
  "hostConfigDiscovery": "off",
261
263
  "oauthDir": ".pi/mcp-oauth",
262
264
  "trace": {
@@ -272,12 +274,14 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
272
274
 
273
275
  | Setting | Description |
274
276
  |---------|-------------|
275
- | `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), `"none"`, or `"mcp"` (prefixes with `mcp__`, using server-mode normalization) |
277
+ | `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. |
276
278
  | `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
277
279
  | `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
278
280
  | `showStatusIcon` | Show the plug icon in MCP status and connection text (default: `true`). Set to `false` for plain `MCP: ...` text. |
281
+ | `mcpFooterStatus` | MCP footer verbosity: `"full"` (default), `"compact"` for `MCP connected/enabled`, or `"off"` to clear the persistent footer status. `/mcp status` remains available. |
279
282
  | `hostConfigDiscovery` | Host-specific config policy: `"off"` (default), `"prompt"` (detect/report only), or `"on"` (explicitly load detected host configs as the lowest-precedence fallback) |
280
283
  | `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. |
284
+ | `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. |
281
285
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
282
286
  | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
283
287
  | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
@@ -407,7 +411,7 @@ To hide specific tools while still using `directTools: true`, add `excludeTools`
407
411
 
408
412
  `includeTools` and `excludeTools` filter direct tools, proxy search/list/describe, and the `/mcp` panel view.
409
413
 
410
- 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[]`.
414
+ 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.
411
415
 
412
416
  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>`.
413
417
 
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/direct-tools.ts CHANGED
@@ -1,5 +1,5 @@
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";
@@ -11,7 +11,7 @@ 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" }
@@ -140,10 +141,12 @@ export function resolveDirectTools(
140
141
 
141
142
  if (!toolFilter) continue;
142
143
 
144
+ const effectivePrefix = resolveToolPrefix(definition, prefix);
145
+
143
146
  for (const tool of serverCache.tools ?? []) {
144
147
  if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
145
- if (!isToolAllowed(tool.name, serverName, prefix, definition.includeTools, definition.excludeTools)) continue;
146
- 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);
147
150
  if (BUILTIN_NAMES.has(prefixedName)) {
148
151
  console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
149
152
  continue;
@@ -168,8 +171,8 @@ export function resolveDirectTools(
168
171
  for (const resource of serverCache.resources ?? []) {
169
172
  const baseName = `read_${resourceNameToToolName(resource.name)}`;
170
173
  if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
171
- if (!isToolAllowed(baseName, serverName, prefix, definition.includeTools, definition.excludeTools)) continue;
172
- const prefixedName = formatToolName(baseName, serverName, prefix);
174
+ if (!isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools)) continue;
175
+ const prefixedName = formatToolName(baseName, serverName, effectivePrefix);
173
176
  if (BUILTIN_NAMES.has(prefixedName)) {
174
177
  console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
175
178
  continue;
@@ -190,6 +193,10 @@ export function resolveDirectTools(
190
193
  }
191
194
  }
192
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
+
193
200
  return specs;
194
201
  }
195
202
 
@@ -217,13 +224,14 @@ export function buildProxyDescription(
217
224
  const definition = config.mcpServers[serverName];
218
225
  if (isServerDisabled(definition)) continue;
219
226
  const entry = cache?.servers?.[serverName];
227
+ const effectivePrefix = resolveToolPrefix(definition, prefix);
220
228
  const toolCount = (entry?.tools ?? []).filter(
221
- (tool) => isToolAllowed(tool.name, serverName, prefix, definition.includeTools, definition.excludeTools),
229
+ (tool) => isToolAllowed(tool.name, serverName, effectivePrefix, definition.includeTools, definition.excludeTools),
222
230
  ).length;
223
231
  const resourceCount = definition?.exposeResources !== false
224
232
  ? (entry?.resources ?? []).filter((resource) => {
225
233
  const baseName = `read_${resourceNameToToolName(resource.name)}`;
226
- return isToolAllowed(baseName, serverName, prefix, definition.includeTools, definition.excludeTools);
234
+ return isToolAllowed(baseName, serverName, effectivePrefix, definition.includeTools, definition.excludeTools);
227
235
  }).length
228
236
  : 0;
229
237
  const totalItems = toolCount + resourceCount;
@@ -433,9 +441,9 @@ export function createDirectToolExecutor(
433
441
  name: spec.originalName,
434
442
  arguments: params ?? {},
435
443
  _meta: uiSession?.requestMeta,
436
- }, requestOptions), ownedSignal),
444
+ }, undefined, requestOptions), ownedSignal),
437
445
  );
438
- uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/client").CallToolResult);
446
+ uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
439
447
 
440
448
  if (result.isError) {
441
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/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);
@@ -261,7 +261,8 @@ export async function initializeMcp(
261
261
  });
262
262
 
263
263
  if (ui && startupServers.length > 0) {
264
- ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${startupServers.length} servers...`));
264
+ const status = formatMcpStatus(state.config, `connecting to ${startupServers.length} servers...`);
265
+ ui.setStatus("mcp", status);
265
266
  }
266
267
 
267
268
  const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
@@ -301,7 +302,7 @@ export async function initializeMcp(
301
302
  toolMetadata.set(name, metadata);
302
303
  resourceCounts.set(name, connection.resources.length);
303
304
  if (!connection.promptDiscoveryFailed) {
304
- promptMetadata.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix));
305
+ promptMetadata.set(name, reconstructPromptMetadata(name, connection.prompts ?? [], prefix, definition));
305
306
  promptMetadataLive.add(name);
306
307
  }
307
308
  if (connection.instructions) {
@@ -399,6 +400,9 @@ export async function initializeMcp(
399
400
 
400
401
  owner.throwIfInactive();
401
402
  lifecycle.startHealthChecks(runtimeSignal);
403
+ if (config.settings?.mcpFooterStatus === "off") {
404
+ ui?.setStatus("mcp", undefined);
405
+ }
402
406
  publishMcpStatusSnapshot(state);
403
407
 
404
408
  return state;
@@ -433,7 +437,7 @@ export function updateServerMetadata(state: McpExtensionState, serverName: strin
433
437
  state.toolMetadata.set(serverName, metadata);
434
438
  state.resourceCounts?.set(serverName, connection.resources.length);
435
439
  if (!connection.promptDiscoveryFailed) {
436
- state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix));
440
+ state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix, definition));
437
441
  state.promptMetadataLive?.add(serverName);
438
442
  }
439
443
  if (connection.instructions) {
@@ -524,10 +528,24 @@ export function updateStatusBar(state: McpExtensionState): void {
524
528
  const definition = state.config.mcpServers[name];
525
529
  return connection.status === "connected" && definition !== undefined && !isServerDisabled(definition);
526
530
  }).length;
527
- let status = `${enabledCount} ${enabledCount === 1 ? "server" : "servers"} enabled`;
528
- if (connectedCount > 0) status += ` (${connectedCount} connected)`;
529
- if (disabledCount > 0) status += ` (${disabledCount} disabled)`;
530
- const formattedStatus = formatMcpStatus(state.config, status);
531
+ const footerStatus = state.config.settings?.mcpFooterStatus ?? "full";
532
+ if (footerStatus === "off") {
533
+ ui.setStatus("mcp", undefined);
534
+ return;
535
+ }
536
+
537
+ let status = footerStatus === "compact"
538
+ ? `MCP ${connectedCount}/${enabledCount}`
539
+ : `${enabledCount} ${enabledCount === 1 ? "server" : "servers"} enabled`;
540
+ if (footerStatus === "full") {
541
+ if (connectedCount > 0) status += ` (${connectedCount} connected)`;
542
+ if (disabledCount > 0) status += ` (${disabledCount} disabled)`;
543
+ }
544
+ const formattedStatus = footerStatus === "compact" ? status : formatMcpStatus(state.config, status);
545
+ if (formattedStatus === undefined) {
546
+ ui.setStatus("mcp", undefined);
547
+ return;
548
+ }
531
549
  ui.setStatus("mcp", ui.theme ? ui.theme.fg("accent", formattedStatus) : formattedStatus);
532
550
  }
533
551
 
@@ -565,7 +583,8 @@ export async function lazyConnect(state: McpExtensionState, serverName: string,
565
583
 
566
584
  try {
567
585
  if (state.ui) {
568
- state.ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
586
+ const status = formatMcpStatus(state.config, `connecting to ${serverName}...`);
587
+ state.ui.setStatus("mcp", status);
569
588
  }
570
589
  const newConnection = await state.manager.connect(serverName, definition, ownedSignal);
571
590
  if (newConnection.status === "needs-auth") {
@@ -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 ??= (() => {
package/mcp-auth-flow.ts CHANGED
@@ -7,10 +7,9 @@
7
7
  import {
8
8
  auth as runSdkAuth,
9
9
  extractWWWAuthenticateParams,
10
- IssuerMismatchError,
11
10
  UnauthorizedError,
12
- LATEST_PROTOCOL_VERSION,
13
- } from "@modelcontextprotocol/client"
11
+ } from "@modelcontextprotocol/sdk/client/auth.js"
12
+ import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
14
13
  import open from "open"
15
14
  import { McpOAuthProvider, type McpOAuthConfig } from "./mcp-oauth-provider.ts"
16
15
  import {
@@ -58,6 +57,10 @@ type AuthDiscovery = {
58
57
  scope?: string
59
58
  }
60
59
 
60
+ function applyConfiguredScope(discovery: AuthDiscovery, config: McpOAuthConfig): AuthDiscovery {
61
+ return config.scope !== undefined ? { ...discovery, scope: config.scope } : discovery
62
+ }
63
+
61
64
  type PendingAuth = {
62
65
  serverName: string
63
66
  authProvider: McpOAuthProvider
@@ -163,6 +166,18 @@ export function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig {
163
166
  if (typeof definition.oauth.scope !== "string") throw new Error("OAuth scope must be a string")
164
167
  config.scope = interpolateEnvVars(definition.oauth.scope)
165
168
  }
169
+ if (definition.oauth?.authorizationParams !== undefined) {
170
+ const params = definition.oauth.authorizationParams
171
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
172
+ throw new Error("OAuth authorizationParams must be an object")
173
+ }
174
+ config.authorizationParams = {}
175
+ for (const [key, value] of Object.entries(params)) {
176
+ if (!key) throw new Error("OAuth authorizationParams keys must not be empty")
177
+ if (typeof value !== "string") throw new Error(`OAuth authorizationParams.${key} must be a string`)
178
+ config.authorizationParams[key] = interpolateEnvVars(value)
179
+ }
180
+ }
166
181
  if (definition.oauth?.redirectUri !== undefined) {
167
182
  if (typeof definition.oauth.redirectUri !== "string") {
168
183
  throw new Error("OAuth redirectUri must be a string")
@@ -305,7 +320,7 @@ export async function startAuth(
305
320
  },
306
321
  }, authStorageOptions, runtime.signal)
307
322
  try {
308
- const discovery = await probeAuthDiscovery(serverUrl, definition, signal)
323
+ const discovery = applyConfiguredScope(await probeAuthDiscovery(serverUrl, definition, signal), config)
309
324
  throwIfAborted(signal)
310
325
  const result = await abortable(runSdkAuth(authProvider, { serverUrl, ...discovery }), signal)
311
326
  throwIfAborted(signal)
@@ -371,7 +386,7 @@ export async function startAuth(
371
386
 
372
387
  throwIfAborted(signal)
373
388
 
374
- const discovery = await probeAuthDiscovery(serverUrl, definition, signal)
389
+ const discovery = applyConfiguredScope(await probeAuthDiscovery(serverUrl, definition, signal), config)
375
390
  throwIfAborted(signal)
376
391
  const result = await abortable(runSdkAuth(authProvider, { serverUrl, ...discovery }), signal)
377
392
  throwIfAborted(signal)
@@ -572,10 +587,25 @@ export async function completeAuth(
572
587
  let keepPendingForRetry = false
573
588
  let caughtError: unknown
574
589
  try {
590
+ const discoveryState = await pendingAuth.authProvider.discoveryState()
591
+ const metadata = discoveryState?.authorizationServerMetadata
592
+ const expectedIssuer = metadata?.issuer ?? discoveryState?.authorizationServerUrl
593
+ const requiresIssuer = (metadata as { authorization_response_iss_parameter_supported?: unknown } | undefined)
594
+ ?.authorization_response_iss_parameter_supported === true
595
+ if (expectedIssuer !== undefined && iss === undefined && requiresIssuer) {
596
+ keepPendingForRetry = true
597
+ throw new Error(
598
+ `The authorization server for ${serverName} requires the RFC 9207 "iss" parameter. ` +
599
+ "Paste the full redirect URL from the browser address bar (not just the authorization code).",
600
+ )
601
+ }
602
+ if (expectedIssuer !== undefined && iss !== undefined && iss !== expectedIssuer) {
603
+ throw new Error(`The OAuth authorization response issuer does not match the discovered issuer for ${serverName}.`)
604
+ }
605
+
575
606
  const result = await abortable(runSdkAuth(pendingAuth.authProvider, {
576
607
  serverUrl: pendingAuth.serverUrl,
577
608
  authorizationCode: code,
578
- ...(iss !== undefined ? { iss } : {}),
579
609
  ...pendingAuth.discovery,
580
610
  }), signal)
581
611
  throwIfAborted(signal)
@@ -585,17 +615,6 @@ export async function completeAuth(
585
615
  return "authenticated"
586
616
  } catch (error) {
587
617
  caughtError = error
588
- // RFC 9207: the AS advertises authorization_response_iss_parameter_supported
589
- // but no `iss` accompanied the pasted code (e.g. the user pasted only the
590
- // raw code). Keep the pending flow alive so the user can re-paste the full
591
- // redirect URL instead of restarting authentication from scratch.
592
- if (iss === undefined && error instanceof IssuerMismatchError && error.kind === "authorization_response") {
593
- keepPendingForRetry = true
594
- throw new Error(
595
- `The authorization server for ${serverName} requires the RFC 9207 "iss" parameter. ` +
596
- "Paste the full redirect URL from the browser address bar (not just the authorization code).",
597
- )
598
- }
599
618
  throw error
600
619
  } finally {
601
620
  if (!keepPendingForRetry) {