claudeai.ts
services/mcp/claudeai.ts
165
Lines
6126
Bytes
4
Exports
11
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with integrations, mcp. It contains 165 lines, 11 detected imports, and 4 detected exports.
Important relationships
Detected exports
fetchClaudeAIMcpConfigsIfEligibleclearClaudeAIMcpConfigsCachemarkClaudeAiMcpConnectedhasClaudeAiMcpEverConnected
Keywords
nameservertokenslogfordebuggingclaudeai-mcpanalyticsmetadata_i_verified_this_is_not_code_or_filepathslogeventclaudeconfigsfinalname
Detected imports
axioslodash-es/memoize.jssrc/constants/oauth.jssrc/services/analytics/index.jssrc/utils/auth.jssrc/utils/config.jssrc/utils/debug.jssrc/utils/envUtils.js./client.js./normalization.js./types.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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)
}