import axios from 'axios' import memoize from 'lodash-es/memoize.js' import { getOauthConfig } from 'src/constants/oauth.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { getClaudeAIOAuthTokens } from 'src/utils/auth.js' import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js' import { logForDebugging } from 'src/utils/debug.js' import { isEnvDefinedFalsy } from 'src/utils/envUtils.js' import { clearMcpAuthCache } from './client.js' import { normalizeNameForMCP } from './normalization.js' import type { ScopedMcpServerConfig } from './types.js' type ClaudeAIMcpServer = { type: 'mcp_server' id: string display_name: string url: string created_at: string } type ClaudeAIMcpServersResponse = { data: ClaudeAIMcpServer[] has_more: boolean next_page: string | null } const FETCH_TIMEOUT_MS = 5000 const MCP_SERVERS_BETA_HEADER = 'mcp-servers-2025-12-04' /** * Fetches MCP server configurations from Claude.ai org configs. * These servers are managed by the organization via Claude.ai. * * Results are memoized for the session lifetime (fetch once per CLI session). */ export const fetchClaudeAIMcpConfigsIfEligible = memoize( async (): Promise> => { try { if (isEnvDefinedFalsy(process.env.ENABLE_CLAUDEAI_MCP_SERVERS)) { logForDebugging('[claudeai-mcp] Disabled via env var') logEvent('tengu_claudeai_mcp_eligibility', { state: 'disabled_env_var' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return {} } const tokens = getClaudeAIOAuthTokens() if (!tokens?.accessToken) { logForDebugging('[claudeai-mcp] No access token') logEvent('tengu_claudeai_mcp_eligibility', { state: 'no_oauth_token' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return {} } // Check for user:mcp_servers scope directly instead of isClaudeAISubscriber(). // In non-interactive mode, isClaudeAISubscriber() returns false when ANTHROPIC_API_KEY // is set (even with valid OAuth tokens) because preferThirdPartyAuthentication() causes // isAnthropicAuthEnabled() to return false. Checking the scope directly allows users // with both API keys and OAuth tokens to access claude.ai MCPs in print mode. if (!tokens.scopes?.includes('user:mcp_servers')) { logForDebugging( `[claudeai-mcp] Missing user:mcp_servers scope (scopes=${tokens.scopes?.join(',') || 'none'})`, ) logEvent('tengu_claudeai_mcp_eligibility', { state: 'missing_scope' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return {} } const baseUrl = getOauthConfig().BASE_API_URL const url = `${baseUrl}/v1/mcp_servers?limit=1000` logForDebugging(`[claudeai-mcp] Fetching from ${url}`) const response = await axios.get(url, { headers: { Authorization: `Bearer ${tokens.accessToken}`, 'Content-Type': 'application/json', 'anthropic-beta': MCP_SERVERS_BETA_HEADER, 'anthropic-version': '2023-06-01', }, timeout: FETCH_TIMEOUT_MS, }) const configs: Record = {} // Track used normalized names to detect collisions and assign (2), (3), etc. suffixes. // We check the final normalized name (including suffix) to handle edge cases where // a suffixed name collides with another server's base name (e.g., "Example Server 2" // colliding with "Example Server! (2)" which both normalize to claude_ai_Example_Server_2). const usedNormalizedNames = new Set() for (const server of response.data.data) { const baseName = `claude.ai ${server.display_name}` // Try without suffix first, then increment until we find an unused normalized name let finalName = baseName let finalNormalized = normalizeNameForMCP(finalName) let count = 1 while (usedNormalizedNames.has(finalNormalized)) { count++ finalName = `${baseName} (${count})` finalNormalized = normalizeNameForMCP(finalName) } usedNormalizedNames.add(finalNormalized) configs[finalName] = { type: 'claudeai-proxy', url: server.url, id: server.id, scope: 'claudeai', } } logForDebugging( `[claudeai-mcp] Fetched ${Object.keys(configs).length} servers`, ) logEvent('tengu_claudeai_mcp_eligibility', { state: 'eligible' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return configs } catch { logForDebugging(`[claudeai-mcp] Fetch failed`) return {} } }, ) /** * Clears the memoized cache for fetchClaudeAIMcpConfigsIfEligible. * Call this after login so the next fetch will use the new auth tokens. */ export function clearClaudeAIMcpConfigsCache(): void { fetchClaudeAIMcpConfigsIfEligible.cache.clear?.() // Also clear the auth cache so freshly-authorized servers get re-connected clearMcpAuthCache() } /** * Record that a claude.ai connector successfully connected. Idempotent. * * Gates the "N connectors unavailable/need auth" startup notifications: a * connector that was working yesterday and is now failed is a state change * worth surfacing; an org-configured connector that's been needs-auth since * it showed up is one the user has demonstrably ignored. */ export function markClaudeAiMcpConnected(name: string): void { saveGlobalConfig(current => { const seen = current.claudeAiMcpEverConnected ?? [] if (seen.includes(name)) return current return { ...current, claudeAiMcpEverConnected: [...seen, name] } }) } export function hasClaudeAiMcpEverConnected(name: string): boolean { return (getGlobalConfig().claudeAiMcpEverConnected ?? []).includes(name) }