From 2145ab8f8ef4e5b734c3544494f17bb9169b2e6b Mon Sep 17 00:00:00 2001 From: slee1996 Date: Wed, 22 Jul 2026 23:44:38 -0600 Subject: [PATCH] fix(coding-agent): retry MCP tool auth challenges --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/mcp/manager.ts | 47 +++++-- packages/coding-agent/src/mcp/tool-bridge.ts | 118 ++++++++++++++++-- packages/coding-agent/src/mcp/types.ts | 7 ++ .../controllers/mcp-command-controller.ts | 102 +++++++++------ .../src/modes/interactive-mode.ts | 3 + .../test/mcp-command-reauth.test.ts | 56 +++++++++ .../coding-agent/test/mcp-reconnect.test.ts | 47 +++++++ 8 files changed, 325 insertions(+), 56 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 536e7fe87..fc1a4cd8a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Fixed the setup wizard hiding the selected row on short terminals (e.g. 24x80): the provider sign-in, theme, and web-search lists now fit their windows to the visible height, and decorative chrome (sign-in hint, theme mock preview) yields to the list when space is tight. +- Fixed MCP tool calls that return a `WWW-Authenticate` challenge by preserving the structured metadata, completing the configured OAuth flow, and retrying the call once on the refreshed connection. ## [17.0.8] - 2026-07-22 diff --git a/packages/coding-agent/src/mcp/manager.ts b/packages/coding-agent/src/mcp/manager.ts index 0f3b249c1..12a8e1ad9 100644 --- a/packages/coding-agent/src/mcp/manager.ts +++ b/packages/coding-agent/src/mcp/manager.ts @@ -38,6 +38,7 @@ import type { MCPToolDetails } from "./tool-bridge"; import { DeferredMCPTool, MCPTool } from "./tool-bridge"; import type { MCPToolCache } from "./tool-cache"; import type { + MCPAuthChallenge, MCPGetPromptResult, MCPPrompt, MCPRequestOptions, @@ -160,6 +161,9 @@ export interface MCPDiscoverOptions { onStatus?: (event: McpConnectionStatusEvent) => void; } +/** Handles an MCP `WWW-Authenticate` challenge and returns refreshed config. */ +export type MCPAuthHandler = (serverName: string, challenge: MCPAuthChallenge) => Promise; + /** * MCP Server Manager. * @@ -189,6 +193,7 @@ export class MCPManager { #pendingToolLoads = new Map>(); #sources = new Map(); #authStorage: AuthStorage | null = null; + #authHandler?: MCPAuthHandler; #onNotification?: (serverName: string, method: string, params: unknown) => void; #onToolsChanged?: (tools: CustomTool[]) => void; #onResourcesChanged?: (serverName: string, uri: string) => void; @@ -201,7 +206,7 @@ export class MCPManager { /** Preserved configs for reconnection after connection loss. */ #serverConfigs = new Map(); /** - * Timestamps of recent `reconnectServer` invocations per server, used by the + * Timestamps of recent reconnectServer invocations per server, used by the * crash-storm circuit breaker (see {@link RECONNECT_BURST_LIMIT}). */ #reconnectHistory = new Map(); @@ -312,6 +317,11 @@ export class MCPManager { this.#authStorage = authStorage; } + /** Set the callback used to complete OAuth after a tool-level auth challenge. */ + setAuthHandler(handler: MCPAuthHandler | undefined): void { + this.#authHandler = handler; + } + /** * Discover and connect to all MCP servers from .mcp.json files. * Returns tools and any connection errors. @@ -474,7 +484,8 @@ export class MCPManager { .then(async ({ connection, serverTools }) => { if (this.#pendingToolLoads.get(name) !== toolsPromise) return; this.#pendingToolLoads.delete(name); - const reconnect = () => this.reconnectServer(name); + const reconnect = (options?: { authChallenge?: MCPAuthChallenge }) => + this.reconnectServer(name, options); const customTools = MCPTool.fromTools(connection, serverTools, reconnect); this.#replaceServerTools(name, customTools); this.#onToolsChanged?.(this.#tools); @@ -810,13 +821,15 @@ export class MCPManager { * the same server share one reconnection attempt. Returns the new * connection, or `null` if reconnection failed or the per-server crash * burst limit (see {@link RECONNECT_BURST_LIMIT}) is exceeded. - * * @param options.manual - When `true`, resets the crash-burst window so a * user-driven retry (e.g. `/mcp reconnect`) is never blocked by an * earlier storm. Defaults to `false`; the transport `onClose` callback * and the per-tool-call retry path in `tool-bridge` MUST NOT set it. */ - async reconnectServer(name: string, options?: { manual?: boolean }): Promise { + async reconnectServer( + name: string, + options?: { manual?: boolean; authChallenge?: MCPAuthChallenge }, + ): Promise { if (options?.manual) { this.#reconnectHistory.delete(name); } @@ -828,7 +841,7 @@ export class MCPManager { return null; } - const attempt = this.#doReconnect(name); + const attempt = this.#doReconnect(name, options?.authChallenge); this.#pendingReconnections.set(name, attempt); return attempt.finally(() => this.#pendingReconnections.delete(name)); } @@ -873,12 +886,30 @@ export class MCPManager { return false; } - async #doReconnect(name: string): Promise { + async #doReconnect(name: string, authChallenge?: MCPAuthChallenge): Promise { const oldConnection = this.#connections.get(name); - const config = oldConnection?.config ?? this.#serverConfigs.get(name); + let config = oldConnection?.config ?? this.#serverConfigs.get(name); const source = this.#sources.get(name) ?? oldConnection?._source; if (!config) return null; + if (authChallenge) { + if (!this.#authHandler) { + logger.error("MCP auth challenge cannot be handled; no auth handler is configured", { + path: `mcp:${name}`, + }); + return null; + } + try { + const refreshedConfig = await this.#authHandler(name, authChallenge); + if (!refreshedConfig) return null; + config = refreshedConfig; + this.#serverConfigs.set(name, config); + } catch (error) { + logger.error("MCP auth challenge handling failed", { path: `mcp:${name}`, error }); + return null; + } + } + logger.debug("MCP reconnecting", { path: `mcp:${name}` }); // Close the old transport without removing tools or notifying consumers. @@ -987,7 +1018,7 @@ export class MCPManager { }; try { const serverTools = await listTools(connection); - const reconnect = () => this.reconnectServer(name); + const reconnect = (options?: { authChallenge?: MCPAuthChallenge }) => this.reconnectServer(name, options); const customTools = MCPTool.fromTools(connection, serverTools, reconnect); void this.toolCache?.set(name, config, serverTools); this.#replaceServerTools(name, customTools); diff --git a/packages/coding-agent/src/mcp/tool-bridge.ts b/packages/coding-agent/src/mcp/tool-bridge.ts index 57f4a1447..24a870b6c 100644 --- a/packages/coding-agent/src/mcp/tool-bridge.ts +++ b/packages/coding-agent/src/mcp/tool-bridge.ts @@ -22,10 +22,17 @@ import { normalizeLocalScheme } from "../tools/path-utils"; import { ToolAbortError, throwIfAborted } from "../tools/tool-errors"; import { callTool } from "./client"; import { renderMCPCall, renderMCPResult } from "./render"; -import type { MCPContent, MCPServerConnection, MCPToolCallParams, MCPToolCallResult, MCPToolDefinition } from "./types"; +import type { + MCPAuthChallenge, + MCPContent, + MCPServerConnection, + MCPToolCallParams, + MCPToolCallResult, + MCPToolDefinition, +} from "./types"; -/** Reconnect callback: tears down stale connection, returns new one or null. */ -export type MCPReconnect = () => Promise; +/** Reconnect callback: tears down a stale connection, optionally authorizing first. */ +export type MCPReconnect = (options?: { authChallenge?: MCPAuthChallenge }) => Promise; /** * Network-level and stale-session errors that warrant a reconnect + single retry. @@ -174,6 +181,8 @@ export interface MCPToolDetails { isError?: boolean; /** Raw content from MCP response */ rawContent?: MCPContent[]; + /** Structured metadata from the MCP response */ + mcpMeta?: Record; /** Provider ID (e.g., "claude", "mcp-json") */ provider?: string; /** Provider display name (e.g., "Claude Code", "MCP Config") */ @@ -222,6 +231,7 @@ function buildResult( mcpToolName, isError: result.isError, rawContent: result.content, + mcpMeta: result._meta, provider, providerName, }; @@ -249,6 +259,51 @@ function buildErrorResult( }; } +type MCPToolCallAttempt = { + connection: MCPServerConnection; + result?: MCPToolCallResult; + error?: unknown; +}; + +function getMcpAuthChallenge(result: MCPToolCallResult): MCPAuthChallenge | undefined { + if (!result.isError) return undefined; + const values = result._meta?.["mcp/www_authenticate"]; + if (!Array.isArray(values)) return undefined; + const wwwAuthenticate = values.filter((value): value is string => typeof value === "string" && value.trim() !== ""); + return wwwAuthenticate.length > 0 ? { wwwAuthenticate } : undefined; +} + +async function callToolWithAuthRetry( + connection: MCPServerConnection, + toolName: string, + args: MCPToolArgs, + reconnect: MCPReconnect | undefined, + signal?: AbortSignal, +): Promise { + const result = await callTool(connection, toolName, args, { signal }); + const authChallenge = getMcpAuthChallenge(result); + if (!authChallenge || !reconnect) return { connection, result }; + + let newConnection: MCPServerConnection | null; + try { + newConnection = await reconnectWithAbort(reconnect, signal, { authChallenge }); + } catch (error) { + rethrowIfAborted(error, signal); + return { connection, error }; + } + if (!newConnection) return { connection, result }; + + try { + return { + connection: newConnection, + result: await callTool(newConnection, toolName, args, { signal }), + }; + } catch (error) { + rethrowIfAborted(error, signal); + return { connection: newConnection, error }; + } +} + /** Re-throw abort-related errors so they bypass error-result handling. */ function rethrowIfAborted(error: unknown, signal?: AbortSignal): void { if (error instanceof ToolAbortError) throw error; @@ -256,9 +311,13 @@ function rethrowIfAborted(error: unknown, signal?: AbortSignal): void { if (signal?.aborted) throw new ToolAbortError(); } -async function reconnectWithAbort(reconnect: MCPReconnect, signal?: AbortSignal): Promise { +async function reconnectWithAbort( + reconnect: MCPReconnect, + signal?: AbortSignal, + options?: { authChallenge?: MCPAuthChallenge }, +): Promise { try { - return await untilAborted(signal, reconnect); + return await untilAborted(signal, () => reconnect(options)); } catch (error) { rethrowIfAborted(error, signal); return null; @@ -379,8 +438,27 @@ export class MCPTool implements CustomTool { const providerName = this.connection._source?.providerName; try { - const result = await callTool(this.connection, this.tool.name, args, { signal }); - return buildResult(result, this.connection.name, this.tool.name, provider, providerName); + const attempt = await callToolWithAuthRetry(this.connection, this.tool.name, args, this.reconnect, signal); + if (attempt.error !== undefined) { + return buildErrorResult(attempt.error, this.connection.name, this.tool.name, provider, providerName); + } + if (!attempt.result) { + return buildErrorResult( + new Error("MCP tool call returned no result"), + this.connection.name, + this.tool.name, + provider, + providerName, + ); + } + this.connection = attempt.connection; + return buildResult( + attempt.result, + attempt.connection.name, + this.tool.name, + attempt.connection._source?.provider ?? provider, + attempt.connection._source?.providerName ?? providerName, + ); } catch (error) { rethrowIfAborted(error, signal); if (this.reconnect && isRetriableConnectionError(error)) { @@ -483,13 +561,31 @@ export class DeferredMCPTool implements CustomTool { const connection = await untilAborted(signal, () => this.getConnection()); throwIfAborted(signal); try { - const result = await callTool(connection, this.tool.name, args, { signal }); + const attempt = await callToolWithAuthRetry(connection, this.tool.name, args, this.reconnect, signal); + if (attempt.error !== undefined) { + return buildErrorResult( + attempt.error, + this.serverName, + this.tool.name, + attempt.connection._source?.provider ?? provider, + attempt.connection._source?.providerName ?? providerName, + ); + } + if (!attempt.result) { + return buildErrorResult( + new Error("MCP tool call returned no result"), + this.serverName, + this.tool.name, + provider, + providerName, + ); + } return buildResult( - result, + attempt.result, this.serverName, this.tool.name, - connection._source?.provider ?? provider, - connection._source?.providerName ?? providerName, + attempt.connection._source?.provider ?? provider, + attempt.connection._source?.providerName ?? providerName, ); } catch (callError) { rethrowIfAborted(callError, signal); diff --git a/packages/coding-agent/src/mcp/types.ts b/packages/coding-agent/src/mcp/types.ts index c526cf8c5..4859249e3 100644 --- a/packages/coding-agent/src/mcp/types.ts +++ b/packages/coding-agent/src/mcp/types.ts @@ -206,10 +206,17 @@ export interface MCPResourceContent { export type MCPContent = MCPTextContent | MCPImageContent | MCPResourceContent; +/** Structured authentication challenge returned in a tool result. */ +export interface MCPAuthChallenge { + /** Values from `_meta["mcp/www_authenticate"]`. */ + readonly wwwAuthenticate: readonly string[]; +} + /** tools/call response */ export interface MCPToolCallResult { content: MCPContent[]; isError?: boolean; + _meta?: Record; } // ============================================================================= diff --git a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts index 8f2bd263f..e60dc8cad 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -47,7 +47,7 @@ import { searchSmitheryRegistry, toConfigName, } from "../../mcp/smithery-registry"; -import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types"; +import type { MCPAuthChallenge, MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types"; import { shortenPath } from "../../tools/render-utils"; import { urlHyperlinkAlways } from "../../tui"; import { copyToClipboard } from "../../utils/clipboard"; @@ -1042,7 +1042,10 @@ export class MCPCommandController { return next; } - async #resolveOAuthEndpointsFromServer(config: MCPServerConfig): Promise { + async #resolveOAuthEndpointsFromServer( + config: MCPServerConfig, + authChallenge?: MCPAuthChallenge, + ): Promise { // Stdio servers manage credentials inside the child process; OMP's OAuth // flow only applies to http/sse transports. Without this guard the // unauthenticated preflight below spawns the child, which happily reuses @@ -1073,8 +1076,13 @@ export class MCPCommandController { throw new Error("Server connection succeeded without OAuth; reauthorization is not required."); } - // Analyze the connection error to extract OAuth endpoints - const authResult = analyzeAuthError(connectionError!, "url" in config ? config.url : undefined); + // Tool calls can carry richer RFC 6750/RFC 9728 hints than the original + // connection error. Feed those hints through the same analyzer so + // resource_metadata and scope reach protected-resource discovery. + const authError = authChallenge + ? new Error(`${connectionError?.message ?? "HTTP 401"}\n${authChallenge.wwwAuthenticate.join("\n")}`) + : connectionError!; + const authResult = analyzeAuthError(authError, "url" in config ? config.url : undefined); let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null; if (!oauth && (config.type === "http" || config.type === "sse") && config.url) { @@ -1678,21 +1686,29 @@ export class MCPCommandController { } } - async #handleReauth(name: string | undefined): Promise { + /** Reauthorize a server after a tool-level OAuth challenge. */ + async handleMCPAuthChallenge(name: string, challenge: MCPAuthChallenge): Promise { + return this.#handleReauth(name, { silent: true, reload: false, authChallenge: challenge }); + } + + async #handleReauth( + name: string | undefined, + options: { silent?: boolean; reload?: boolean; authChallenge?: MCPAuthChallenge } = {}, + ): Promise { if (!name) { - this.ctx.showError("Server name required. Usage: /mcp reauth "); + if (!options.silent) this.ctx.showError("Server name required. Usage: /mcp reauth "); return; } try { const found = await this.#resolveServerForAuth(name); if (!found) { - this.ctx.showError(`Server "${name}" not found.`); + if (!options.silent) this.ctx.showError(`Server "${name}" not found.`); return; } if (found.config.enabled === false) { - this.ctx.showError(`Server "${name}" is disabled. Run /mcp enable ${name} first.`); + if (!options.silent) this.ctx.showError(`Server "${name}" is disabled. Run /mcp enable ${name} first.`); return; } @@ -1705,7 +1721,7 @@ export class MCPCommandController { // happened yet if the server turns out not to need (or support) OAuth. // Use the same env-expanded config shape runtime discovery passes to // MCPManager; the raw file value may contain `${...}` placeholders. - const oauth = await this.#resolveOAuthEndpointsFromServer(runtimeBaseConfig); + const oauth = await this.#resolveOAuthEndpointsFromServer(runtimeBaseConfig, options.authChallenge); const serverUrl = runtimeBaseConfig.type === "http" || runtimeBaseConfig.type === "sse" ? runtimeBaseConfig.url : undefined; // A user-supplied client secret may live in either block (the wizard @@ -1719,7 +1735,9 @@ export class MCPCommandController { const userClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret; const flowClientSecret = userClientSecret ?? storedClientSecret ?? ""; - this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n")); + if (!options.silent) { + this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n")); + } const currentAuthResource = currentAuth?.resource ? expandEnvVarsDeep(currentAuth.resource) : undefined; const oauthResource = @@ -1755,39 +1773,49 @@ export class MCPCommandController { // Definition-only entries resolve through the url-keyed binding alone; // skip the write-back so a committed project mcp.json stays clean. const urlKeyedId = serverUrl ? mcpOAuthCredentialId(serverUrl) : undefined; - if (currentAuth || oauthResult.credentialId !== urlKeyedId) { - const updated = this.#persistOAuthResult(baseConfig, oauthResult, { - tokenUrl: oauth.tokenUrl, - clientId: oauth.clientId, - userClientSecret, - resource: oauthResource, - stripSameOriginResource: oauthResourceIsFallback, - }); - await updateMCPServer(found.filePath, name, updated); + const shouldPersist = currentAuth || oauthResult.credentialId !== urlKeyedId; + const updatedConfig = shouldPersist + ? this.#persistOAuthResult(baseConfig, oauthResult, { + tokenUrl: oauth.tokenUrl, + clientId: oauth.clientId, + userClientSecret, + resource: oauthResource, + stripSameOriginResource: oauthResourceIsFallback, + }) + : baseConfig; + if (shouldPersist) { + await updateMCPServer(found.filePath, name, updatedConfig); } - await this.#reloadMCP(); - const state = await this.#waitForServerConnectionWithAnimation(name); + if (options.reload !== false) { + await this.#reloadMCP(); + const state = await this.#waitForServerConnectionWithAnimation(name); - const lines = [ - "", - theme.fg("success", `✓ Reauthorized "${name}" (${found.scope} config)`), - "", - ` Status: ${ - state === "connected" - ? theme.fg("success", "connected") - : state === "connecting" - ? theme.fg("muted", "connecting") - : theme.fg("warning", "not connected") - }`, - "", - ]; - this.#showMessage(lines.join("\n")); + const lines = [ + "", + theme.fg("success", `✓ Reauthorized "${name}" (${found.scope} config)`), + "", + ` Status: ${ + state === "connected" + ? theme.fg("success", "connected") + : state === "connecting" + ? theme.fg("muted", "connecting") + : theme.fg("warning", "not connected") + }`, + "", + ]; + this.#showMessage(lines.join("\n")); + } + return updatedConfig; } catch (error) { if (error instanceof MCPOAuthCancelledError) { - this.ctx.showStatus(`Reauthorization cancelled for "${name}"`); + if (!options.silent) this.ctx.showStatus(`Reauthorization cancelled for "${name}"`); return; } - this.ctx.showError(`Failed to reauthorize server: ${error instanceof Error ? error.message : String(error)}`); + if (!options.silent) { + this.ctx.showError( + `Failed to reauthorize server: ${error instanceof Error ? error.message : String(error)}`, + ); + } } } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 5ee79802f..efd0263bf 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -656,6 +656,9 @@ export class InteractiveMode implements InteractiveModeContext { this.#toolUiContextSetter = setToolUIContext; this.lspServers = lspServers; this.mcpManager = mcpManager; + this.mcpManager?.setAuthHandler((serverName, challenge) => + new MCPCommandController(this).handleMCPAuthChallenge(serverName, challenge), + ); this.#eventBus = eventBus; if (eventBus) { this.#eventBusUnsubscribers.push( diff --git a/packages/coding-agent/test/mcp-command-reauth.test.ts b/packages/coding-agent/test/mcp-command-reauth.test.ts index dfc841098..d0b1bed8f 100644 --- a/packages/coding-agent/test/mcp-command-reauth.test.ts +++ b/packages/coding-agent/test/mcp-command-reauth.test.ts @@ -244,6 +244,62 @@ describe("/mcp auth commands", () => { }); }); + test("uses tool challenge resource metadata and scopes during reauth", async () => { + const authStorage = freshAuthStorage(); + await authStorage.reload(); + vi.spyOn(mcpClient, "connectToServer").mockRejectedValue(new Error("HTTP 401: Unauthorized")); + + const resourceMetadataUrl = "https://gateway.example.com/.well-known/oauth-protected-resource"; + const fetchMock = Object.assign( + async (input: string | URL | Request): Promise => { + const url = String(input); + if (url === resourceMetadataUrl) { + return new Response( + JSON.stringify({ + resource: "https://gateway.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "https://auth.example.com/.well-known/oauth-authorization-server") { + return new Response( + JSON.stringify({ + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + client_id: "challenge-client", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }, + { preconnect: globalThis.fetch.preconnect }, + ); + vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock); + + let authorizationUrl = ""; + vi.spyOn(oauthFlow.MCPOAuthFlow.prototype, "login").mockImplementation(async function ( + this: oauthFlow.MCPOAuthFlow, + ) { + authorizationUrl = (await this.generateAuthUrl("state", "http://127.0.0.1:53192/callback")).url; + return { + access: "fresh-access", + refresh: "fresh-refresh", + expires: Date.now() + 3_600_000, + }; + }); + + const { controller, showError } = createController(authStorage); + const updated = await controller.handleMCPAuthChallenge("envserver", { + wwwAuthenticate: [`Bearer resource_metadata="${resourceMetadataUrl}" scope="orders.read"`], + }); + expect(updated).toEqual(expect.objectContaining({ type: "http", url: RAW_SERVER_URL })); + expect(showError).not.toHaveBeenCalled(); + expect(new URL(authorizationUrl).searchParams.get("scope")).toBe("orders.read"); + expect(new URL(authorizationUrl).searchParams.get("resource")).toBe("https://gateway.example.com/mcp"); + }); + test("reuses embedded DCR client secret during reauth token exchange", async () => { const authStorage = freshAuthStorage(); await authStorage.reload(); diff --git a/packages/coding-agent/test/mcp-reconnect.test.ts b/packages/coding-agent/test/mcp-reconnect.test.ts index b2830abad..a5d9d99ae 100644 --- a/packages/coding-agent/test/mcp-reconnect.test.ts +++ b/packages/coding-agent/test/mcp-reconnect.test.ts @@ -271,6 +271,53 @@ describe("MCPTool.execute retry on connection error", () => { expect(result.details?.provider).toBe("orig"); expect(result.details?.providerName).toBe("Original"); }); + it("reconnects once when a tool result carries an OAuth challenge", async () => { + let oldCalls = 0; + let newCalls = 0; + let challenge: unknown; + const oldTransport = mockTransport(async () => { + oldCalls++; + return { + ...toolCallResult("authorize me", true), + _meta: { "mcp/www_authenticate": ['Bearer resource_metadata="https://mcp.example/meta"'] }, + }; + }); + const newTransport = mockTransport(async () => { + newCalls++; + return toolCallResult("authorized"); + }); + const newConn = makeConnection(newTransport, "test-server-authorized"); + const reconnect: MCPReconnect = async options => { + challenge = options?.authChallenge; + return newConn; + }; + + const tool = new MCPTool(makeConnection(oldTransport), TOOL_DEF, reconnect); + const result = await tool.execute("call-1", {}, noop, noCtx); + + expect(oldCalls).toBe(1); + expect(newCalls).toBe(1); + expect(challenge).toEqual({ + wwwAuthenticate: ['Bearer resource_metadata="https://mcp.example/meta"'], + }); + expect(result.details?.isError).toBeFalsy(); + expect(result.content[0]).toEqual({ type: "text", text: "authorized" }); + }); + + it("preserves the OAuth challenge metadata when no reconnect handler exists", async () => { + const result = await new MCPTool( + makeConnection( + mockTransport(async () => ({ + ...toolCallResult("authorize me", true), + _meta: { "mcp/www_authenticate": ["Bearer"] }, + })), + ), + TOOL_DEF, + ).execute("call-1", {}, noop, noCtx); + + expect(result.details?.isError).toBe(true); + expect(result.details?.mcpMeta).toEqual({ "mcp/www_authenticate": ["Bearer"] }); + }); }); describe("reconnect abort propagation", () => {