source dump of claude code
at main 575 lines 18 kB view raw
1import { createHash } from 'crypto' 2import { join } from 'path' 3import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 4import type { Command } from '../../commands.js' 5import type { AgentMcpServerInfo } from '../../components/mcp/types.js' 6import type { Tool } from '../../Tool.js' 7import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' 8import { getCwd } from '../../utils/cwd.js' 9import { getGlobalClaudeFile } from '../../utils/env.js' 10import { isSettingSourceEnabled } from '../../utils/settings/constants.js' 11import { 12 getSettings_DEPRECATED, 13 hasSkipDangerousModePermissionPrompt, 14} from '../../utils/settings/settings.js' 15import { jsonStringify } from '../../utils/slowOperations.js' 16import { getEnterpriseMcpFilePath, getMcpConfigByName } from './config.js' 17import { mcpInfoFromString } from './mcpStringUtils.js' 18import { normalizeNameForMCP } from './normalization.js' 19import { 20 type ConfigScope, 21 ConfigScopeSchema, 22 type MCPServerConnection, 23 type McpHTTPServerConfig, 24 type McpServerConfig, 25 type McpSSEServerConfig, 26 type McpStdioServerConfig, 27 type McpWebSocketServerConfig, 28 type ScopedMcpServerConfig, 29 type ServerResource, 30} from './types.js' 31 32/** 33 * Filters tools by MCP server name 34 * 35 * @param tools Array of tools to filter 36 * @param serverName Name of the MCP server 37 * @returns Tools belonging to the specified server 38 */ 39export function filterToolsByServer(tools: Tool[], serverName: string): Tool[] { 40 const prefix = `mcp__${normalizeNameForMCP(serverName)}__` 41 return tools.filter(tool => tool.name?.startsWith(prefix)) 42} 43 44/** 45 * True when a command belongs to the given MCP server. 46 * 47 * MCP **prompts** are named `mcp__<server>__<prompt>` (wire-format constraint); 48 * MCP **skills** are named `<server>:<skill>` (matching plugin/nested-dir skill 49 * naming). Both live in `mcp.commands`, so cleanup and filtering must match 50 * either shape. 51 */ 52export function commandBelongsToServer( 53 command: Command, 54 serverName: string, 55): boolean { 56 const normalized = normalizeNameForMCP(serverName) 57 const name = command.name 58 if (!name) return false 59 return ( 60 name.startsWith(`mcp__${normalized}__`) || name.startsWith(`${normalized}:`) 61 ) 62} 63 64/** 65 * Filters commands by MCP server name 66 * @param commands Array of commands to filter 67 * @param serverName Name of the MCP server 68 * @returns Commands belonging to the specified server 69 */ 70export function filterCommandsByServer( 71 commands: Command[], 72 serverName: string, 73): Command[] { 74 return commands.filter(c => commandBelongsToServer(c, serverName)) 75} 76 77/** 78 * Filters MCP **prompts** (not skills) by server. Used by the `/mcp` menu 79 * capabilities display — skills are a separate feature shown in `/skills`, 80 * so they mustn't inflate the "prompts" capability badge. 81 * 82 * The distinguisher is `loadedFrom === 'mcp'`: MCP skills set it, MCP 83 * prompts don't (they use `isMcp: true` instead). 84 */ 85export function filterMcpPromptsByServer( 86 commands: Command[], 87 serverName: string, 88): Command[] { 89 return commands.filter( 90 c => 91 commandBelongsToServer(c, serverName) && 92 !(c.type === 'prompt' && c.loadedFrom === 'mcp'), 93 ) 94} 95 96/** 97 * Filters resources by MCP server name 98 * @param resources Array of resources to filter 99 * @param serverName Name of the MCP server 100 * @returns Resources belonging to the specified server 101 */ 102export function filterResourcesByServer( 103 resources: ServerResource[], 104 serverName: string, 105): ServerResource[] { 106 return resources.filter(resource => resource.server === serverName) 107} 108 109/** 110 * Removes tools belonging to a specific MCP server 111 * @param tools Array of tools 112 * @param serverName Name of the MCP server to exclude 113 * @returns Tools not belonging to the specified server 114 */ 115export function excludeToolsByServer( 116 tools: Tool[], 117 serverName: string, 118): Tool[] { 119 const prefix = `mcp__${normalizeNameForMCP(serverName)}__` 120 return tools.filter(tool => !tool.name?.startsWith(prefix)) 121} 122 123/** 124 * Removes commands belonging to a specific MCP server 125 * @param commands Array of commands 126 * @param serverName Name of the MCP server to exclude 127 * @returns Commands not belonging to the specified server 128 */ 129export function excludeCommandsByServer( 130 commands: Command[], 131 serverName: string, 132): Command[] { 133 return commands.filter(c => !commandBelongsToServer(c, serverName)) 134} 135 136/** 137 * Removes resources belonging to a specific MCP server 138 * @param resources Map of server resources 139 * @param serverName Name of the MCP server to exclude 140 * @returns Resources map without the specified server 141 */ 142export function excludeResourcesByServer( 143 resources: Record<string, ServerResource[]>, 144 serverName: string, 145): Record<string, ServerResource[]> { 146 const result = { ...resources } 147 delete result[serverName] 148 return result 149} 150 151/** 152 * Stable hash of an MCP server config for change detection on /reload-plugins. 153 * Excludes `scope` (provenance, not content — moving a server from .mcp.json 154 * to settings.json shouldn't reconnect it). Keys sorted so `{a:1,b:2}` and 155 * `{b:2,a:1}` hash the same. 156 */ 157export function hashMcpConfig(config: ScopedMcpServerConfig): string { 158 const { scope: _scope, ...rest } = config 159 const stable = jsonStringify(rest, (_k, v: unknown) => { 160 if (v && typeof v === 'object' && !Array.isArray(v)) { 161 const obj = v as Record<string, unknown> 162 const sorted: Record<string, unknown> = {} 163 for (const k of Object.keys(obj).sort()) sorted[k] = obj[k] 164 return sorted 165 } 166 return v 167 }) 168 return createHash('sha256').update(stable).digest('hex').slice(0, 16) 169} 170 171/** 172 * Remove stale MCP clients and their tools/commands/resources. A client is 173 * stale if: 174 * - scope 'dynamic' and name no longer in configs (plugin disabled), or 175 * - config hash changed (args/url/env edited in .mcp.json) — any scope 176 * 177 * The removal case is scoped to 'dynamic' so /reload-plugins can't 178 * accidentally disconnect a user-configured server that's just temporarily 179 * absent from the in-memory config (e.g. during a partial reload). The 180 * config-changed case applies to all scopes — if the config actually changed 181 * on disk, reconnecting is what you want. 182 * 183 * Returns the stale clients so the caller can disconnect them (clearServerCache). 184 */ 185export function excludeStalePluginClients( 186 mcp: { 187 clients: MCPServerConnection[] 188 tools: Tool[] 189 commands: Command[] 190 resources: Record<string, ServerResource[]> 191 }, 192 configs: Record<string, ScopedMcpServerConfig>, 193): { 194 clients: MCPServerConnection[] 195 tools: Tool[] 196 commands: Command[] 197 resources: Record<string, ServerResource[]> 198 stale: MCPServerConnection[] 199} { 200 const stale = mcp.clients.filter(c => { 201 const fresh = configs[c.name] 202 if (!fresh) return c.config.scope === 'dynamic' 203 return hashMcpConfig(c.config) !== hashMcpConfig(fresh) 204 }) 205 if (stale.length === 0) { 206 return { ...mcp, stale: [] } 207 } 208 209 let { tools, commands, resources } = mcp 210 for (const s of stale) { 211 tools = excludeToolsByServer(tools, s.name) 212 commands = excludeCommandsByServer(commands, s.name) 213 resources = excludeResourcesByServer(resources, s.name) 214 } 215 const staleNames = new Set(stale.map(c => c.name)) 216 217 return { 218 clients: mcp.clients.filter(c => !staleNames.has(c.name)), 219 tools, 220 commands, 221 resources, 222 stale, 223 } 224} 225 226/** 227 * Checks if a tool name belongs to a specific MCP server 228 * @param toolName The tool name to check 229 * @param serverName The server name to match against 230 * @returns True if the tool belongs to the specified server 231 */ 232export function isToolFromMcpServer( 233 toolName: string, 234 serverName: string, 235): boolean { 236 const info = mcpInfoFromString(toolName) 237 return info?.serverName === serverName 238} 239 240/** 241 * Checks if a tool belongs to any MCP server 242 * @param tool The tool to check 243 * @returns True if the tool is from an MCP server 244 */ 245export function isMcpTool(tool: Tool): boolean { 246 return tool.name?.startsWith('mcp__') || tool.isMcp === true 247} 248 249/** 250 * Checks if a command belongs to any MCP server 251 * @param command The command to check 252 * @returns True if the command is from an MCP server 253 */ 254export function isMcpCommand(command: Command): boolean { 255 return command.name?.startsWith('mcp__') || command.isMcp === true 256} 257 258/** 259 * Describe the file path for a given MCP config scope. 260 * @param scope The config scope ('user', 'project', 'local', or 'dynamic') 261 * @returns A description of where the config is stored 262 */ 263export function describeMcpConfigFilePath(scope: ConfigScope): string { 264 switch (scope) { 265 case 'user': 266 return getGlobalClaudeFile() 267 case 'project': 268 return join(getCwd(), '.mcp.json') 269 case 'local': 270 return `${getGlobalClaudeFile()} [project: ${getCwd()}]` 271 case 'dynamic': 272 return 'Dynamically configured' 273 case 'enterprise': 274 return getEnterpriseMcpFilePath() 275 case 'claudeai': 276 return 'claude.ai' 277 default: 278 return scope 279 } 280} 281 282export function getScopeLabel(scope: ConfigScope): string { 283 switch (scope) { 284 case 'local': 285 return 'Local config (private to you in this project)' 286 case 'project': 287 return 'Project config (shared via .mcp.json)' 288 case 'user': 289 return 'User config (available in all your projects)' 290 case 'dynamic': 291 return 'Dynamic config (from command line)' 292 case 'enterprise': 293 return 'Enterprise config (managed by your organization)' 294 case 'claudeai': 295 return 'claude.ai config' 296 default: 297 return scope 298 } 299} 300 301export function ensureConfigScope(scope?: string): ConfigScope { 302 if (!scope) return 'local' 303 304 if (!ConfigScopeSchema().options.includes(scope as ConfigScope)) { 305 throw new Error( 306 `Invalid scope: ${scope}. Must be one of: ${ConfigScopeSchema().options.join(', ')}`, 307 ) 308 } 309 310 return scope as ConfigScope 311} 312 313export function ensureTransport(type?: string): 'stdio' | 'sse' | 'http' { 314 if (!type) return 'stdio' 315 316 if (type !== 'stdio' && type !== 'sse' && type !== 'http') { 317 throw new Error( 318 `Invalid transport type: ${type}. Must be one of: stdio, sse, http`, 319 ) 320 } 321 322 return type as 'stdio' | 'sse' | 'http' 323} 324 325export function parseHeaders(headerArray: string[]): Record<string, string> { 326 const headers: Record<string, string> = {} 327 328 for (const header of headerArray) { 329 const colonIndex = header.indexOf(':') 330 if (colonIndex === -1) { 331 throw new Error( 332 `Invalid header format: "${header}". Expected format: "Header-Name: value"`, 333 ) 334 } 335 336 const key = header.substring(0, colonIndex).trim() 337 const value = header.substring(colonIndex + 1).trim() 338 339 if (!key) { 340 throw new Error( 341 `Invalid header: "${header}". Header name cannot be empty.`, 342 ) 343 } 344 345 headers[key] = value 346 } 347 348 return headers 349} 350 351export function getProjectMcpServerStatus( 352 serverName: string, 353): 'approved' | 'rejected' | 'pending' { 354 const settings = getSettings_DEPRECATED() 355 const normalizedName = normalizeNameForMCP(serverName) 356 357 // TODO: This fails an e2e test if the ?. is not present. This is likely a bug in the e2e test. 358 // Will fix this in a follow-up PR. 359 if ( 360 settings?.disabledMcpjsonServers?.some( 361 name => normalizeNameForMCP(name) === normalizedName, 362 ) 363 ) { 364 return 'rejected' 365 } 366 367 if ( 368 settings?.enabledMcpjsonServers?.some( 369 name => normalizeNameForMCP(name) === normalizedName, 370 ) || 371 settings?.enableAllProjectMcpServers 372 ) { 373 return 'approved' 374 } 375 376 // In bypass permissions mode (--dangerously-skip-permissions), there's no way 377 // to show an approval popup. Auto-approve if projectSettings is enabled since 378 // the user has explicitly chosen to bypass all permission checks. 379 // SECURITY: We intentionally only check skipDangerousModePermissionPrompt via 380 // hasSkipDangerousModePermissionPrompt(), which reads from userSettings/localSettings/ 381 // flagSettings/policySettings but NOT projectSettings (repo-level .claude/settings.json). 382 // This is intentional: a repo should not be able to accept the bypass dialog on behalf of 383 // users. We also do NOT check getSessionBypassPermissionsMode() here because 384 // sessionBypassPermissionsMode can be set from project settings before the dialog is shown, 385 // which would allow RCE attacks via malicious project settings. 386 if ( 387 hasSkipDangerousModePermissionPrompt() && 388 isSettingSourceEnabled('projectSettings') 389 ) { 390 return 'approved' 391 } 392 393 // In non-interactive mode (SDK, claude -p, piped input), there's no way to 394 // show an approval popup. Auto-approve if projectSettings is enabled since: 395 // 1. The user/developer explicitly chose to run in this mode 396 // 2. For SDK, projectSettings is off by default - they must explicitly enable it 397 // 3. For -p mode, the help text warns to only use in trusted directories 398 if ( 399 getIsNonInteractiveSession() && 400 isSettingSourceEnabled('projectSettings') 401 ) { 402 return 'approved' 403 } 404 405 return 'pending' 406} 407 408/** 409 * Get the scope/settings source for an MCP server from a tool name 410 * @param toolName MCP tool name (format: mcp__serverName__toolName) 411 * @returns ConfigScope or null if not an MCP tool or server not found 412 */ 413export function getMcpServerScopeFromToolName( 414 toolName: string, 415): ConfigScope | null { 416 if (!isMcpTool({ name: toolName } as Tool)) { 417 return null 418 } 419 420 // Extract server name from tool name (format: mcp__serverName__toolName) 421 const mcpInfo = mcpInfoFromString(toolName) 422 if (!mcpInfo) { 423 return null 424 } 425 426 // Look up server config 427 const serverConfig = getMcpConfigByName(mcpInfo.serverName) 428 429 // Fallback: claude.ai servers have normalized names starting with "claude_ai_" 430 // but aren't in getMcpConfigByName (they're fetched async separately) 431 if (!serverConfig && mcpInfo.serverName.startsWith('claude_ai_')) { 432 return 'claudeai' 433 } 434 435 return serverConfig?.scope ?? null 436} 437 438// Type guards for MCP server config types 439function isStdioConfig( 440 config: McpServerConfig, 441): config is McpStdioServerConfig { 442 return config.type === 'stdio' || config.type === undefined 443} 444 445function isSSEConfig(config: McpServerConfig): config is McpSSEServerConfig { 446 return config.type === 'sse' 447} 448 449function isHTTPConfig(config: McpServerConfig): config is McpHTTPServerConfig { 450 return config.type === 'http' 451} 452 453function isWebSocketConfig( 454 config: McpServerConfig, 455): config is McpWebSocketServerConfig { 456 return config.type === 'ws' 457} 458 459/** 460 * Extracts MCP server definitions from agent frontmatter and groups them by server name. 461 * This is used to show agent-specific MCP servers in the /mcp command. 462 * 463 * @param agents Array of agent definitions 464 * @returns Array of AgentMcpServerInfo, grouped by server name with list of source agents 465 */ 466export function extractAgentMcpServers( 467 agents: AgentDefinition[], 468): AgentMcpServerInfo[] { 469 // Map: server name -> { config, sourceAgents } 470 const serverMap = new Map< 471 string, 472 { 473 config: McpServerConfig & { name: string } 474 sourceAgents: string[] 475 } 476 >() 477 478 for (const agent of agents) { 479 if (!agent.mcpServers?.length) continue 480 481 for (const spec of agent.mcpServers) { 482 // Skip string references - these refer to servers already in global config 483 if (typeof spec === 'string') continue 484 485 // Inline definition as { [name]: config } 486 const entries = Object.entries(spec) 487 if (entries.length !== 1) continue 488 489 const [serverName, serverConfig] = entries[0]! 490 const existing = serverMap.get(serverName) 491 492 if (existing) { 493 // Add this agent as another source 494 if (!existing.sourceAgents.includes(agent.agentType)) { 495 existing.sourceAgents.push(agent.agentType) 496 } 497 } else { 498 // New server 499 serverMap.set(serverName, { 500 config: { ...serverConfig, name: serverName } as McpServerConfig & { 501 name: string 502 }, 503 sourceAgents: [agent.agentType], 504 }) 505 } 506 } 507 } 508 509 // Convert map to array of AgentMcpServerInfo 510 // Only include transport types supported by AgentMcpServerInfo 511 const result: AgentMcpServerInfo[] = [] 512 for (const [name, { config, sourceAgents }] of serverMap) { 513 // Use type guards to properly narrow the discriminated union type 514 // Only include transport types that are supported by AgentMcpServerInfo 515 if (isStdioConfig(config)) { 516 result.push({ 517 name, 518 sourceAgents, 519 transport: 'stdio', 520 command: config.command, 521 needsAuth: false, 522 }) 523 } else if (isSSEConfig(config)) { 524 result.push({ 525 name, 526 sourceAgents, 527 transport: 'sse', 528 url: config.url, 529 needsAuth: true, 530 }) 531 } else if (isHTTPConfig(config)) { 532 result.push({ 533 name, 534 sourceAgents, 535 transport: 'http', 536 url: config.url, 537 needsAuth: true, 538 }) 539 } else if (isWebSocketConfig(config)) { 540 result.push({ 541 name, 542 sourceAgents, 543 transport: 'ws', 544 url: config.url, 545 needsAuth: false, 546 }) 547 } 548 // Skip unsupported transport types (sdk, claudeai-proxy, sse-ide, ws-ide) 549 // These are internal types not meant for agent MCP server display 550 } 551 552 return result.sort((a, b) => a.name.localeCompare(b.name)) 553} 554 555/** 556 * Extracts the MCP server base URL (without query string) for analytics logging. 557 * Query strings are stripped because they can contain access tokens. 558 * Trailing slashes are also removed for normalization. 559 * Returns undefined for stdio/sdk servers or if URL parsing fails. 560 */ 561export function getLoggingSafeMcpBaseUrl( 562 config: McpServerConfig, 563): string | undefined { 564 if (!('url' in config) || typeof config.url !== 'string') { 565 return undefined 566 } 567 568 try { 569 const url = new URL(config.url) 570 url.search = '' 571 return url.toString().replace(/\/$/, '') 572 } catch { 573 return undefined 574 } 575}