| 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' |
|
|
| |
| |
| |
| |
| |
| |
| export const fetchClaudeAIMcpConfigsIfEligible = memoize( |
| async (): Promise<Record<string, ScopedMcpServerConfig>> => { |
| 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 {} |
| } |
|
|
| |
| |
| |
| |
| |
| 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<ClaudeAIMcpServersResponse>(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<string, ScopedMcpServerConfig> = {} |
| |
| |
| |
| |
| const usedNormalizedNames = new Set<string>() |
|
|
| for (const server of response.data.data) { |
| const baseName = `claude.ai ${server.display_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 {} |
| } |
| }, |
| ) |
|
|
| |
| |
| |
| |
| export function clearClaudeAIMcpConfigsCache(): void { |
| fetchClaudeAIMcpConfigsIfEligible.cache.clear?.() |
| |
| clearMcpAuthCache() |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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) |
| } |
|
|