import reject from 'lodash-es/reject.js' import { z } from 'zod/v4' import { performMCPOAuthFlow } from '../../services/mcp/auth.js' import { clearMcpAuthCache, reconnectMcpServerImpl, } from '../../services/mcp/client.js' import { buildMcpToolName, getMcpPrefix, } from '../../services/mcp/mcpStringUtils.js' import type { McpHTTPServerConfig, McpSSEServerConfig, ScopedMcpServerConfig, } from '../../services/mcp/types.js' import type { Tool } from '../../Tool.js' import { lazySchema } from '../../utils/lazySchema.js' import { logMCPDebug, logMCPError } from '../../utils/log.js' import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' const inputSchema = lazySchema(() => z.object({})) type InputSchema = ReturnType export type McpAuthOutput = { status: 'auth_url' | 'unsupported' | 'error' message: string authUrl?: string } function summarizeMcpAuthToolError(error: unknown): string { if (error instanceof Error) { return `${error.name} (hasMessage=${error.message.length > 0})` } return `non-Error (${typeof error})` } /** * Creates a pseudo-tool for an MCP server that is installed but not * authenticated. Surfaced in place of the server's real tools so the model * knows the server exists and can start the OAuth flow on the user's behalf. * * When called, starts performMCPOAuthFlow with skipBrowserOpen and returns * the authorization URL. The OAuth callback completes in the background; * once it fires, reconnectMcpServerImpl runs and the server's real tools * are swapped into appState.mcp.tools via the existing prefix-based * replacement (useManageMCPConnections.updateServer wipes anything matching * mcp____*, so this pseudo-tool is removed automatically). */ export function createMcpAuthTool( serverName: string, config: ScopedMcpServerConfig, ): Tool { const transport = config.type ?? 'stdio' const description = `The \`${serverName}\` MCP server (${transport}) is installed but requires authentication. ` + `Call this tool to start the OAuth flow — you'll receive an authorization URL to share with the user. ` + `Once the user completes authorization in their browser, the server's real tools will become available automatically.` return { name: buildMcpToolName(serverName, 'authenticate'), isMcp: true, mcpInfo: { serverName, toolName: 'authenticate' }, isEnabled: () => true, isConcurrencySafe: () => false, isReadOnly: () => false, toAutoClassifierInput: () => serverName, userFacingName: () => `${serverName} - authenticate (MCP)`, maxResultSizeChars: 10_000, renderToolUseMessage: () => `Authenticate ${serverName} MCP server`, async description() { return description }, async prompt() { return description }, get inputSchema(): InputSchema { return inputSchema() }, async checkPermissions(input): Promise { return { behavior: 'allow', updatedInput: input } }, async call(_input, context) { // claude.ai connectors use a separate auth flow (handleClaudeAIAuth in // MCPRemoteServerMenu) that we don't invoke programmatically here — // just point the user at /mcp. if (config.type === 'claudeai-proxy') { return { data: { status: 'unsupported' as const, message: `This is a claude.ai MCP connector. Ask the user to run /mcp and select "${serverName}" to authenticate.`, }, } } // performMCPOAuthFlow only accepts sse/http. needs-auth state is only // set on HTTP 401 (UnauthorizedError) so other transports shouldn't // reach here, but be defensive. if (config.type !== 'sse' && config.type !== 'http') { return { data: { status: 'unsupported' as const, message: `Server "${serverName}" uses ${transport} transport which does not support OAuth from this tool. Ask the user to run /mcp and authenticate manually.`, }, } } const sseOrHttpConfig = config as ( | McpSSEServerConfig | McpHTTPServerConfig ) & { scope: ScopedMcpServerConfig['scope'] } // Mirror cli/print.ts mcp_authenticate: start the flow, capture the // URL via onAuthorizationUrl, return it immediately. The flow's // Promise resolves later when the browser callback fires. let resolveAuthUrl: ((url: string) => void) | undefined const authUrlPromise = new Promise(resolve => { resolveAuthUrl = resolve }) const controller = new AbortController() const { setAppState } = context const oauthPromise = performMCPOAuthFlow( serverName, sseOrHttpConfig, u => resolveAuthUrl?.(u), controller.signal, { skipBrowserOpen: true }, ) // Background continuation: once OAuth completes, reconnect and swap // the real tools into appState. Prefix-based replacement removes this // pseudo-tool since it shares the mcp____ prefix. void oauthPromise .then(async () => { clearMcpAuthCache() const result = await reconnectMcpServerImpl(serverName, config) const prefix = getMcpPrefix(serverName) setAppState(prev => ({ ...prev, mcp: { ...prev.mcp, clients: prev.mcp.clients.map(c => c.name === serverName ? result.client : c, ), tools: [ ...reject(prev.mcp.tools, t => t.name?.startsWith(prefix)), ...result.tools, ], commands: [ ...reject(prev.mcp.commands, c => c.name?.startsWith(prefix)), ...result.commands, ], resources: result.resources ? { ...prev.mcp.resources, [serverName]: result.resources } : prev.mcp.resources, }, })) logMCPDebug( serverName, `OAuth complete, reconnected with ${result.tools.length} tool(s)`, ) }) .catch(err => { logMCPError( serverName, `OAuth flow failed after tool-triggered start: ${summarizeMcpAuthToolError( err, )}`, ) }) try { // Race: get the URL, or the flow completes without needing one // (e.g. XAA with cached IdP token — silent auth). const authUrl = await Promise.race([ authUrlPromise, oauthPromise.then(() => null as string | null), ]) if (authUrl) { return { data: { status: 'auth_url' as const, authUrl, message: `Ask the user to open this URL in their browser to authorize the ${serverName} MCP server:\n\n${authUrl}\n\nOnce they complete the flow, the server's tools will become available automatically.`, }, } } return { data: { status: 'auth_url' as const, message: `Authentication completed silently for ${serverName}. The server's tools should now be available.`, }, } } catch (err) { return { data: { status: 'error' as const, message: `Failed to start OAuth flow for ${serverName}. Ask the user to run /mcp and authenticate manually.`, }, } } }, mapToolResultToToolResultBlockParam(data, toolUseID) { return { tool_use_id: toolUseID, type: 'tool_result', content: data.message, } }, } satisfies Tool }