File size: 6,126 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 | 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<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 {}
}
// 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<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> = {}
// 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<string>()
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)
}
|