File size: 7,873 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | 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 { errorMessage } from '../../utils/errors.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<typeof inputSchema>
export type McpAuthOutput = {
status: 'auth_url' | 'unsupported' | 'error'
message: string
authUrl?: string
}
function getConfigUrl(config: ScopedMcpServerConfig): string | undefined {
if ('url' in config) return config.url
return undefined
}
/**
* 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__<server>__*, so this pseudo-tool is removed automatically).
*/
export function createMcpAuthTool(
serverName: string,
config: ScopedMcpServerConfig,
): Tool<InputSchema, McpAuthOutput> {
const url = getConfigUrl(config)
const transport = config.type ?? 'stdio'
const location = url ? `${transport} at ${url}` : transport
const description =
`The \`${serverName}\` MCP server (${location}) 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<PermissionDecision> {
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<string>(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__<server>__ 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: ${errorMessage(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}: ${errorMessage(err)}. 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<InputSchema, McpAuthOutput>
}
|