/ services / mcp / claudeai.ts
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  }