pi-mcp-adapter 2.15.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,22 @@ 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
+
10
26
  ## [2.15.0] - 2026-07-25
11
27
 
12
28
  ### 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) |
@@ -272,7 +273,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
272
273
 
273
274
  | Setting | Description |
274
275
  |---------|-------------|
275
- | `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. |
276
277
  | `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
277
278
  | `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
278
279
  | `showStatusIcon` | Show the plug icon in MCP status and connection text (default: `true`). Set to `false` for plain `MCP: ...` text. |
@@ -407,7 +408,7 @@ To hide specific tools while still using `directTools: true`, add `excludeTools`
407
408
 
408
409
  `includeTools` and `excludeTools` filter direct tools, proxy search/list/describe, and the `/mcp` panel view.
409
410
 
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[]`.
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.
411
412
 
412
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>`.
413
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/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);
@@ -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) {
@@ -433,7 +433,7 @@ export function updateServerMetadata(state: McpExtensionState, serverName: strin
433
433
  state.toolMetadata.set(serverName, metadata);
434
434
  state.resourceCounts?.set(serverName, connection.resources.length);
435
435
  if (!connection.promptDiscoveryFailed) {
436
- state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix));
436
+ state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix, definition));
437
437
  state.promptMetadataLive?.add(serverName);
438
438
  }
439
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 ??= (() => {
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 {
@@ -572,10 +571,25 @@ export async function completeAuth(
572
571
  let keepPendingForRetry = false
573
572
  let caughtError: unknown
574
573
  try {
574
+ const discoveryState = await pendingAuth.authProvider.discoveryState()
575
+ const metadata = discoveryState?.authorizationServerMetadata
576
+ const expectedIssuer = metadata?.issuer ?? discoveryState?.authorizationServerUrl
577
+ const requiresIssuer = (metadata as { authorization_response_iss_parameter_supported?: unknown } | undefined)
578
+ ?.authorization_response_iss_parameter_supported === true
579
+ if (expectedIssuer !== undefined && iss === undefined && requiresIssuer) {
580
+ keepPendingForRetry = true
581
+ throw new Error(
582
+ `The authorization server for ${serverName} requires the RFC 9207 "iss" parameter. ` +
583
+ "Paste the full redirect URL from the browser address bar (not just the authorization code).",
584
+ )
585
+ }
586
+ if (expectedIssuer !== undefined && iss !== undefined && iss !== expectedIssuer) {
587
+ throw new Error(`The OAuth authorization response issuer does not match the discovered issuer for ${serverName}.`)
588
+ }
589
+
575
590
  const result = await abortable(runSdkAuth(pendingAuth.authProvider, {
576
591
  serverUrl: pendingAuth.serverUrl,
577
592
  authorizationCode: code,
578
- ...(iss !== undefined ? { iss } : {}),
579
593
  ...pendingAuth.discovery,
580
594
  }), signal)
581
595
  throwIfAborted(signal)
@@ -585,17 +599,6 @@ export async function completeAuth(
585
599
  return "authenticated"
586
600
  } catch (error) {
587
601
  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
602
  throw error
600
603
  } finally {
601
604
  if (!keepPendingForRetry) {