claudeai.ts
1 import axios from 'axios' 2 import memoize from 'lodash-es/memoize.js' 3 import { getOauthConfig } from 'src/constants/oauth.js' 4 import { 5 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 6 logEvent, 7 } from 'src/services/analytics/index.js' 8 import { getClaudeAIOAuthTokens } from 'src/utils/auth.js' 9 import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js' 10 import { logForDebugging } from 'src/utils/debug.js' 11 import { isEnvDefinedFalsy } from 'src/utils/envUtils.js' 12 import { clearMcpAuthCache } from './client.js' 13 import { normalizeNameForMCP } from './normalization.js' 14 import type { ScopedMcpServerConfig } from './types.js' 15 16 type ClaudeAIMcpServer = { 17 type: 'mcp_server' 18 id: string 19 display_name: string 20 url: string 21 created_at: string 22 } 23 24 type ClaudeAIMcpServersResponse = { 25 data: ClaudeAIMcpServer[] 26 has_more: boolean 27 next_page: string | null 28 } 29 30 const FETCH_TIMEOUT_MS = 5000 31 const MCP_SERVERS_BETA_HEADER = 'mcp-servers-2025-12-04' 32 33 /** 34 * Fetches MCP server configurations from Claude.ai org configs. 35 * These servers are managed by the organization via Claude.ai. 36 * 37 * Results are memoized for the session lifetime (fetch once per CLI session). 38 */ 39 export const fetchClaudeAIMcpConfigsIfEligible = memoize( 40 async (): Promise<Record<string, ScopedMcpServerConfig>> => { 41 try { 42 if (isEnvDefinedFalsy(process.env.ENABLE_CLAUDEAI_MCP_SERVERS)) { 43 logForDebugging('[claudeai-mcp] Disabled via env var') 44 logEvent('tengu_claudeai_mcp_eligibility', { 45 state: 46 'disabled_env_var' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 47 }) 48 return {} 49 } 50 51 const tokens = getClaudeAIOAuthTokens() 52 if (!tokens?.accessToken) { 53 logForDebugging('[claudeai-mcp] No access token') 54 logEvent('tengu_claudeai_mcp_eligibility', { 55 state: 56 'no_oauth_token' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 57 }) 58 return {} 59 } 60 61 // Check for user:mcp_servers scope directly instead of isClaudeAISubscriber(). 62 // In non-interactive mode, isClaudeAISubscriber() returns false when ANTHROPIC_API_KEY 63 // is set (even with valid OAuth tokens) because preferThirdPartyAuthentication() causes 64 // isAnthropicAuthEnabled() to return false. Checking the scope directly allows users 65 // with both API keys and OAuth tokens to access claude.ai MCPs in print mode. 66 if (!tokens.scopes?.includes('user:mcp_servers')) { 67 logForDebugging( 68 `[claudeai-mcp] Missing user:mcp_servers scope (scopes=${tokens.scopes?.join(',') || 'none'})`, 69 ) 70 logEvent('tengu_claudeai_mcp_eligibility', { 71 state: 72 'missing_scope' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 73 }) 74 return {} 75 } 76 77 const baseUrl = getOauthConfig().BASE_API_URL 78 const url = `${baseUrl}/v1/mcp_servers?limit=1000` 79 80 logForDebugging(`[claudeai-mcp] Fetching from ${url}`) 81 82 const response = await axios.get<ClaudeAIMcpServersResponse>(url, { 83 headers: { 84 Authorization: `Bearer ${tokens.accessToken}`, 85 'Content-Type': 'application/json', 86 'anthropic-beta': MCP_SERVERS_BETA_HEADER, 87 'anthropic-version': '2023-06-01', 88 }, 89 timeout: FETCH_TIMEOUT_MS, 90 }) 91 92 const configs: Record<string, ScopedMcpServerConfig> = {} 93 // Track used normalized names to detect collisions and assign (2), (3), etc. suffixes. 94 // We check the final normalized name (including suffix) to handle edge cases where 95 // a suffixed name collides with another server's base name (e.g., "Example Server 2" 96 // colliding with "Example Server! (2)" which both normalize to claude_ai_Example_Server_2). 97 const usedNormalizedNames = new Set<string>() 98 99 for (const server of response.data.data) { 100 const baseName = `claude.ai ${server.display_name}` 101 102 // Try without suffix first, then increment until we find an unused normalized name 103 let finalName = baseName 104 let finalNormalized = normalizeNameForMCP(finalName) 105 let count = 1 106 while (usedNormalizedNames.has(finalNormalized)) { 107 count++ 108 finalName = `${baseName} (${count})` 109 finalNormalized = normalizeNameForMCP(finalName) 110 } 111 usedNormalizedNames.add(finalNormalized) 112 113 configs[finalName] = { 114 type: 'claudeai-proxy', 115 url: server.url, 116 id: server.id, 117 scope: 'claudeai', 118 } 119 } 120 121 logForDebugging( 122 `[claudeai-mcp] Fetched ${Object.keys(configs).length} servers`, 123 ) 124 logEvent('tengu_claudeai_mcp_eligibility', { 125 state: 126 'eligible' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 127 }) 128 return configs 129 } catch { 130 logForDebugging(`[claudeai-mcp] Fetch failed`) 131 return {} 132 } 133 }, 134 ) 135 136 /** 137 * Clears the memoized cache for fetchClaudeAIMcpConfigsIfEligible. 138 * Call this after login so the next fetch will use the new auth tokens. 139 */ 140 export function clearClaudeAIMcpConfigsCache(): void { 141 fetchClaudeAIMcpConfigsIfEligible.cache.clear?.() 142 // Also clear the auth cache so freshly-authorized servers get re-connected 143 clearMcpAuthCache() 144 } 145 146 /** 147 * Record that a claude.ai connector successfully connected. Idempotent. 148 * 149 * Gates the "N connectors unavailable/need auth" startup notifications: a 150 * connector that was working yesterday and is now failed is a state change 151 * worth surfacing; an org-configured connector that's been needs-auth since 152 * it showed up is one the user has demonstrably ignored. 153 */ 154 export function markClaudeAiMcpConnected(name: string): void { 155 saveGlobalConfig(current => { 156 const seen = current.claudeAiMcpEverConnected ?? [] 157 if (seen.includes(name)) return current 158 return { ...current, claudeAiMcpEverConnected: [...seen, name] } 159 }) 160 } 161 162 export function hasClaudeAiMcpEverConnected(name: string): boolean { 163 return (getGlobalConfig().claudeAiMcpEverConnected ?? []).includes(name) 164 }