Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
18 kB · 575 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576import { createHash } from 'crypto'import { join } from 'path'import { getIsNonInteractiveSession } from '../../bootstrap/state.js'import type { Command } from '../../commands.js'import type { AgentMcpServerInfo } from '../../components/mcp/types.js'import type { Tool } from '../../Tool.js'import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'import { getCwd } from '../../utils/cwd.js'import { getGlobalClaudeFile } from '../../utils/env.js'import { isSettingSourceEnabled } from '../../utils/settings/constants.js'import { getSettings_DEPRECATED, hasSkipDangerousModePermissionPrompt,} from '../../utils/settings/settings.js'import { jsonStringify } from '../../utils/slowOperations.js'import { getEnterpriseMcpFilePath, getMcpConfigByName } from './config.js'import { mcpInfoFromString } from './mcpStringUtils.js'import { normalizeNameForMCP } from './normalization.js'import { type ConfigScope, ConfigScopeSchema, type MCPServerConnection, type McpHTTPServerConfig, type McpServerConfig, type McpSSEServerConfig, type McpStdioServerConfig, type McpWebSocketServerConfig, type ScopedMcpServerConfig, type ServerResource,} from './types.js'
/** * Filters tools by MCP server name * * @param tools Array of tools to filter * @param serverName Name of the MCP server * @returns Tools belonging to the specified server */export function filterToolsByServer(tools: Tool[], serverName: string): Tool[] { const prefix = `mcp__${normalizeNameForMCP(serverName)}__` return tools.filter(tool => tool.name?.startsWith(prefix))}
/** * True when a command belongs to the given MCP server. * * MCP **prompts** are named `mcp__<server>__<prompt>` (wire-format constraint); * MCP **skills** are named `<server>:<skill>` (matching plugin/nested-dir skill * naming). Both live in `mcp.commands`, so cleanup and filtering must match * either shape. */export function commandBelongsToServer( command: Command, serverName: string,): boolean { const normalized = normalizeNameForMCP(serverName) const name = command.name if (!name) return false return ( name.startsWith(`mcp__${normalized}__`) || name.startsWith(`${normalized}:`) )}
/** * Filters commands by MCP server name * @param commands Array of commands to filter * @param serverName Name of the MCP server * @returns Commands belonging to the specified server */export function filterCommandsByServer( commands: Command[], serverName: string,): Command[] { return commands.filter(c => commandBelongsToServer(c, serverName))}
/** * Filters MCP **prompts** (not skills) by server. Used by the `/mcp` menu * capabilities display — skills are a separate feature shown in `/skills`, * so they mustn't inflate the "prompts" capability badge. * * The distinguisher is `loadedFrom === 'mcp'`: MCP skills set it, MCP * prompts don't (they use `isMcp: true` instead). */export function filterMcpPromptsByServer( commands: Command[], serverName: string,): Command[] { return commands.filter( c => commandBelongsToServer(c, serverName) && !(c.type === 'prompt' && c.loadedFrom === 'mcp'), )}
/** * Filters resources by MCP server name * @param resources Array of resources to filter * @param serverName Name of the MCP server * @returns Resources belonging to the specified server */export function filterResourcesByServer( resources: ServerResource[], serverName: string,): ServerResource[] { return resources.filter(resource => resource.server === serverName)}
/** * Removes tools belonging to a specific MCP server * @param tools Array of tools * @param serverName Name of the MCP server to exclude * @returns Tools not belonging to the specified server */export function excludeToolsByServer( tools: Tool[], serverName: string,): Tool[] { const prefix = `mcp__${normalizeNameForMCP(serverName)}__` return tools.filter(tool => !tool.name?.startsWith(prefix))}
/** * Removes commands belonging to a specific MCP server * @param commands Array of commands * @param serverName Name of the MCP server to exclude * @returns Commands not belonging to the specified server */export function excludeCommandsByServer( commands: Command[], serverName: string,): Command[] { return commands.filter(c => !commandBelongsToServer(c, serverName))}
/** * Removes resources belonging to a specific MCP server * @param resources Map of server resources * @param serverName Name of the MCP server to exclude * @returns Resources map without the specified server */export function excludeResourcesByServer( resources: Record<string, ServerResource[]>, serverName: string,): Record<string, ServerResource[]> { const result = { ...resources } delete result[serverName] return result}
/** * Stable hash of an MCP server config for change detection on /reload-plugins. * Excludes `scope` (provenance, not content — moving a server from .mcp.json * to settings.json shouldn't reconnect it). Keys sorted so `{a:1,b:2}` and * `{b:2,a:1}` hash the same. */export function hashMcpConfig(config: ScopedMcpServerConfig): string { const { scope: _scope, ...rest } = config const stable = jsonStringify(rest, (_k, v: unknown) => { if (v && typeof v === 'object' && !Array.isArray(v)) { const obj = v as Record<string, unknown> const sorted: Record<string, unknown> = {} for (const k of Object.keys(obj).sort()) sorted[k] = obj[k] return sorted } return v }) return createHash('sha256').update(stable).digest('hex').slice(0, 16)}
/** * Remove stale MCP clients and their tools/commands/resources. A client is * stale if: * - scope 'dynamic' and name no longer in configs (plugin disabled), or * - config hash changed (args/url/env edited in .mcp.json) — any scope * * The removal case is scoped to 'dynamic' so /reload-plugins can't * accidentally disconnect a user-configured server that's just temporarily * absent from the in-memory config (e.g. during a partial reload). The * config-changed case applies to all scopes — if the config actually changed * on disk, reconnecting is what you want. * * Returns the stale clients so the caller can disconnect them (clearServerCache). */export function excludeStalePluginClients( mcp: { clients: MCPServerConnection[] tools: Tool[] commands: Command[] resources: Record<string, ServerResource[]> }, configs: Record<string, ScopedMcpServerConfig>,): { clients: MCPServerConnection[] tools: Tool[] commands: Command[] resources: Record<string, ServerResource[]> stale: MCPServerConnection[]} { const stale = mcp.clients.filter(c => { const fresh = configs[c.name] if (!fresh) return c.config.scope === 'dynamic' return hashMcpConfig(c.config) !== hashMcpConfig(fresh) }) if (stale.length === 0) { return { ...mcp, stale: [] } }
let { tools, commands, resources } = mcp for (const s of stale) { tools = excludeToolsByServer(tools, s.name) commands = excludeCommandsByServer(commands, s.name) resources = excludeResourcesByServer(resources, s.name) } const staleNames = new Set(stale.map(c => c.name))
return { clients: mcp.clients.filter(c => !staleNames.has(c.name)), tools, commands, resources, stale, }}
/** * Checks if a tool name belongs to a specific MCP server * @param toolName The tool name to check * @param serverName The server name to match against * @returns True if the tool belongs to the specified server */export function isToolFromMcpServer( toolName: string, serverName: string,): boolean { const info = mcpInfoFromString(toolName) return info?.serverName === serverName}
/** * Checks if a tool belongs to any MCP server * @param tool The tool to check * @returns True if the tool is from an MCP server */export function isMcpTool(tool: Tool): boolean { return tool.name?.startsWith('mcp__') || tool.isMcp === true}
/** * Checks if a command belongs to any MCP server * @param command The command to check * @returns True if the command is from an MCP server */export function isMcpCommand(command: Command): boolean { return command.name?.startsWith('mcp__') || command.isMcp === true}
/** * Describe the file path for a given MCP config scope. * @param scope The config scope ('user', 'project', 'local', or 'dynamic') * @returns A description of where the config is stored */export function describeMcpConfigFilePath(scope: ConfigScope): string { switch (scope) { case 'user': return getGlobalClaudeFile() case 'project': return join(getCwd(), '.mcp.json') case 'local': return `${getGlobalClaudeFile()} [project: ${getCwd()}]` case 'dynamic': return 'Dynamically configured' case 'enterprise': return getEnterpriseMcpFilePath() case 'claudeai': return 'claude.ai' default: return scope }}
export function getScopeLabel(scope: ConfigScope): string { switch (scope) { case 'local': return 'Local config (private to you in this project)' case 'project': return 'Project config (shared via .mcp.json)' case 'user': return 'User config (available in all your projects)' case 'dynamic': return 'Dynamic config (from command line)' case 'enterprise': return 'Enterprise config (managed by your organization)' case 'claudeai': return 'claude.ai config' default: return scope }}
export function ensureConfigScope(scope?: string): ConfigScope { if (!scope) return 'local'
if (!ConfigScopeSchema().options.includes(scope as ConfigScope)) { throw new Error( `Invalid scope: ${scope}. Must be one of: ${ConfigScopeSchema().options.join(', ')}`, ) }
return scope as ConfigScope}
export function ensureTransport(type?: string): 'stdio' | 'sse' | 'http' { if (!type) return 'stdio'
if (type !== 'stdio' && type !== 'sse' && type !== 'http') { throw new Error( `Invalid transport type: ${type}. Must be one of: stdio, sse, http`, ) }
return type as 'stdio' | 'sse' | 'http'}
export function parseHeaders(headerArray: string[]): Record<string, string> { const headers: Record<string, string> = {}
for (const header of headerArray) { const colonIndex = header.indexOf(':') if (colonIndex === -1) { throw new Error( `Invalid header format: "${header}". Expected format: "Header-Name: value"`, ) }
const key = header.substring(0, colonIndex).trim() const value = header.substring(colonIndex + 1).trim()
if (!key) { throw new Error( `Invalid header: "${header}". Header name cannot be empty.`, ) }
headers[key] = value }
return headers}
export function getProjectMcpServerStatus( serverName: string,): 'approved' | 'rejected' | 'pending' { const settings = getSettings_DEPRECATED() const normalizedName = normalizeNameForMCP(serverName)
// TODO: This fails an e2e test if the ?. is not present. This is likely a bug in the e2e test. // Will fix this in a follow-up PR. if ( settings?.disabledMcpjsonServers?.some( name => normalizeNameForMCP(name) === normalizedName, ) ) { return 'rejected' }
if ( settings?.enabledMcpjsonServers?.some( name => normalizeNameForMCP(name) === normalizedName, ) || settings?.enableAllProjectMcpServers ) { return 'approved' }
// In bypass permissions mode (--dangerously-skip-permissions), there's no way // to show an approval popup. Auto-approve if projectSettings is enabled since // the user has explicitly chosen to bypass all permission checks. // SECURITY: We intentionally only check skipDangerousModePermissionPrompt via // hasSkipDangerousModePermissionPrompt(), which reads from userSettings/localSettings/ // flagSettings/policySettings but NOT projectSettings (repo-level .claude/settings.json). // This is intentional: a repo should not be able to accept the bypass dialog on behalf of // users. We also do NOT check getSessionBypassPermissionsMode() here because // sessionBypassPermissionsMode can be set from project settings before the dialog is shown, // which would allow RCE attacks via malicious project settings. if ( hasSkipDangerousModePermissionPrompt() && isSettingSourceEnabled('projectSettings') ) { return 'approved' }
// In non-interactive mode (SDK, claude -p, piped input), there's no way to // show an approval popup. Auto-approve if projectSettings is enabled since: // 1. The user/developer explicitly chose to run in this mode // 2. For SDK, projectSettings is off by default - they must explicitly enable it // 3. For -p mode, the help text warns to only use in trusted directories if ( getIsNonInteractiveSession() && isSettingSourceEnabled('projectSettings') ) { return 'approved' }
return 'pending'}
/** * Get the scope/settings source for an MCP server from a tool name * @param toolName MCP tool name (format: mcp__serverName__toolName) * @returns ConfigScope or null if not an MCP tool or server not found */export function getMcpServerScopeFromToolName( toolName: string,): ConfigScope | null { if (!isMcpTool({ name: toolName } as Tool)) { return null }
// Extract server name from tool name (format: mcp__serverName__toolName) const mcpInfo = mcpInfoFromString(toolName) if (!mcpInfo) { return null }
// Look up server config const serverConfig = getMcpConfigByName(mcpInfo.serverName)
// Fallback: claude.ai servers have normalized names starting with "claude_ai_" // but aren't in getMcpConfigByName (they're fetched async separately) if (!serverConfig && mcpInfo.serverName.startsWith('claude_ai_')) { return 'claudeai' }
return serverConfig?.scope ?? null}
// Type guards for MCP server config typesfunction isStdioConfig( config: McpServerConfig,): config is McpStdioServerConfig { return config.type === 'stdio' || config.type === undefined}
function isSSEConfig(config: McpServerConfig): config is McpSSEServerConfig { return config.type === 'sse'}
function isHTTPConfig(config: McpServerConfig): config is McpHTTPServerConfig { return config.type === 'http'}
function isWebSocketConfig( config: McpServerConfig,): config is McpWebSocketServerConfig { return config.type === 'ws'}
/** * Extracts MCP server definitions from agent frontmatter and groups them by server name. * This is used to show agent-specific MCP servers in the /mcp command. * * @param agents Array of agent definitions * @returns Array of AgentMcpServerInfo, grouped by server name with list of source agents */export function extractAgentMcpServers( agents: AgentDefinition[],): AgentMcpServerInfo[] { // Map: server name -> { config, sourceAgents } const serverMap = new Map< string, { config: McpServerConfig & { name: string } sourceAgents: string[] } >()
for (const agent of agents) { if (!agent.mcpServers?.length) continue
for (const spec of agent.mcpServers) { // Skip string references - these refer to servers already in global config if (typeof spec === 'string') continue
// Inline definition as { [name]: config } const entries = Object.entries(spec) if (entries.length !== 1) continue
const [serverName, serverConfig] = entries[0]! const existing = serverMap.get(serverName)
if (existing) { // Add this agent as another source if (!existing.sourceAgents.includes(agent.agentType)) { existing.sourceAgents.push(agent.agentType) } } else { // New server serverMap.set(serverName, { config: { ...serverConfig, name: serverName } as McpServerConfig & { name: string }, sourceAgents: [agent.agentType], }) } } }
// Convert map to array of AgentMcpServerInfo // Only include transport types supported by AgentMcpServerInfo const result: AgentMcpServerInfo[] = [] for (const [name, { config, sourceAgents }] of serverMap) { // Use type guards to properly narrow the discriminated union type // Only include transport types that are supported by AgentMcpServerInfo if (isStdioConfig(config)) { result.push({ name, sourceAgents, transport: 'stdio', command: config.command, needsAuth: false, }) } else if (isSSEConfig(config)) { result.push({ name, sourceAgents, transport: 'sse', url: config.url, needsAuth: true, }) } else if (isHTTPConfig(config)) { result.push({ name, sourceAgents, transport: 'http', url: config.url, needsAuth: true, }) } else if (isWebSocketConfig(config)) { result.push({ name, sourceAgents, transport: 'ws', url: config.url, needsAuth: false, }) } // Skip unsupported transport types (sdk, claudeai-proxy, sse-ide, ws-ide) // These are internal types not meant for agent MCP server display }
return result.sort((a, b) => a.name.localeCompare(b.name))}
/** * Extracts the MCP server base URL (without query string) for analytics logging. * Query strings are stripped because they can contain access tokens. * Trailing slashes are also removed for normalization. * Returns undefined for stdio/sdk servers or if URL parsing fails. */export function getLoggingSafeMcpBaseUrl( config: McpServerConfig,): string | undefined { if (!('url' in config) || typeof config.url !== 'string') { return undefined }
try { const url = new URL(config.url) url.search = '' return url.toString().replace(/\/$/, '') } catch { return undefined }}