source dump of claude code
at main 634 lines 20 kB view raw
1import { join } from 'path' 2import { expandEnvVarsInString } from '../../services/mcp/envExpansion.js' 3import { 4 type McpServerConfig, 5 McpServerConfigSchema, 6 type ScopedMcpServerConfig, 7} from '../../services/mcp/types.js' 8import type { LoadedPlugin, PluginError } from '../../types/plugin.js' 9import { logForDebugging } from '../debug.js' 10import { errorMessage, isENOENT } from '../errors.js' 11import { getFsImplementation } from '../fsOperations.js' 12import { jsonParse } from '../slowOperations.js' 13import { 14 isMcpbSource, 15 loadMcpbFile, 16 loadMcpServerUserConfig, 17 type McpbLoadResult, 18 type UserConfigSchema, 19 type UserConfigValues, 20 validateUserConfig, 21} from './mcpbHandler.js' 22import { getPluginDataDir } from './pluginDirectories.js' 23import { 24 getPluginStorageId, 25 loadPluginOptions, 26 substitutePluginVariables, 27 substituteUserConfigVariables, 28} from './pluginOptionsStorage.js' 29 30/** 31 * Load MCP servers from an MCPB file 32 * Handles downloading, extracting, and converting DXT manifest to MCP config 33 */ 34async function loadMcpServersFromMcpb( 35 plugin: LoadedPlugin, 36 mcpbPath: string, 37 errors: PluginError[], 38): Promise<Record<string, McpServerConfig> | null> { 39 try { 40 logForDebugging(`Loading MCP servers from MCPB: ${mcpbPath}`) 41 42 // Use plugin.repository directly - it's already in "plugin@marketplace" format 43 const pluginId = plugin.repository 44 45 const result = await loadMcpbFile( 46 mcpbPath, 47 plugin.path, 48 pluginId, 49 status => { 50 logForDebugging(`MCPB [${plugin.name}]: ${status}`) 51 }, 52 ) 53 54 // Check if MCPB needs user configuration 55 if ('status' in result && result.status === 'needs-config') { 56 // User config needed - this is normal for unconfigured plugins 57 // Don't load the MCP server yet - user can configure via /plugin menu 58 logForDebugging( 59 `MCPB ${mcpbPath} requires user configuration. ` + 60 `User can configure via: /plugin → Manage plugins → ${plugin.name} → Configure`, 61 ) 62 // Return null to skip this server for now (not an error) 63 return null 64 } 65 66 // Type guard passed - result is success type 67 const successResult = result as McpbLoadResult 68 69 // Use the DXT manifest name as the server name 70 const serverName = successResult.manifest.name 71 72 // Check for server name conflicts with existing servers 73 // This will be checked later when merging all servers, but we log here for debugging 74 logForDebugging( 75 `Loaded MCP server "${serverName}" from MCPB (extracted to ${successResult.extractedPath})`, 76 ) 77 78 return { [serverName]: successResult.mcpConfig } 79 } catch (error) { 80 const errorMsg = errorMessage(error) 81 logForDebugging(`Failed to load MCPB ${mcpbPath}: ${errorMsg}`, { 82 level: 'error', 83 }) 84 85 // Use plugin@repository as source (consistent with other plugin errors) 86 const source = `${plugin.name}@${plugin.repository}` 87 88 // Determine error type based on error message 89 const isUrl = mcpbPath.startsWith('http') 90 if ( 91 isUrl && 92 (errorMsg.includes('download') || errorMsg.includes('network')) 93 ) { 94 errors.push({ 95 type: 'mcpb-download-failed', 96 source, 97 plugin: plugin.name, 98 url: mcpbPath, 99 reason: errorMsg, 100 }) 101 } else if ( 102 errorMsg.includes('manifest') || 103 errorMsg.includes('user configuration') 104 ) { 105 errors.push({ 106 type: 'mcpb-invalid-manifest', 107 source, 108 plugin: plugin.name, 109 mcpbPath, 110 validationError: errorMsg, 111 }) 112 } else { 113 errors.push({ 114 type: 'mcpb-extract-failed', 115 source, 116 plugin: plugin.name, 117 mcpbPath, 118 reason: errorMsg, 119 }) 120 } 121 122 return null 123 } 124} 125 126/** 127 * Load MCP servers from a plugin's manifest 128 * This function loads MCP server configurations from various sources within the plugin 129 * including manifest entries, .mcp.json files, and .mcpb files 130 */ 131export async function loadPluginMcpServers( 132 plugin: LoadedPlugin, 133 errors: PluginError[] = [], 134): Promise<Record<string, McpServerConfig> | undefined> { 135 let servers: Record<string, McpServerConfig> = {} 136 137 // Check for .mcp.json in plugin directory first (lowest priority) 138 const defaultMcpServers = await loadMcpServersFromFile( 139 plugin.path, 140 '.mcp.json', 141 ) 142 if (defaultMcpServers) { 143 servers = { ...servers, ...defaultMcpServers } 144 } 145 146 // Handle manifest mcpServers if present (higher priority) 147 if (plugin.manifest.mcpServers) { 148 const mcpServersSpec = plugin.manifest.mcpServers 149 150 // Handle different mcpServers formats 151 if (typeof mcpServersSpec === 'string') { 152 // Check if it's an MCPB file 153 if (isMcpbSource(mcpServersSpec)) { 154 const mcpbServers = await loadMcpServersFromMcpb( 155 plugin, 156 mcpServersSpec, 157 errors, 158 ) 159 if (mcpbServers) { 160 servers = { ...servers, ...mcpbServers } 161 } 162 } else { 163 // Path to JSON file 164 const mcpServers = await loadMcpServersFromFile( 165 plugin.path, 166 mcpServersSpec, 167 ) 168 if (mcpServers) { 169 servers = { ...servers, ...mcpServers } 170 } 171 } 172 } else if (Array.isArray(mcpServersSpec)) { 173 // Array of paths or inline configs. 174 // Load all specs in parallel, then merge in original order so 175 // last-wins collision semantics are preserved. 176 const results = await Promise.all( 177 mcpServersSpec.map(async spec => { 178 try { 179 if (typeof spec === 'string') { 180 // Check if it's an MCPB file 181 if (isMcpbSource(spec)) { 182 return await loadMcpServersFromMcpb(plugin, spec, errors) 183 } 184 // Path to JSON file 185 return await loadMcpServersFromFile(plugin.path, spec) 186 } 187 // Inline MCP server configs (sync) 188 return spec 189 } catch (e) { 190 // Defensive: if one spec throws, don't lose results from the 191 // others. The previous serial loop implicitly tolerated this. 192 logForDebugging( 193 `Failed to load MCP servers from spec for plugin ${plugin.name}: ${e}`, 194 { level: 'error' }, 195 ) 196 return null 197 } 198 }), 199 ) 200 for (const result of results) { 201 if (result) { 202 servers = { ...servers, ...result } 203 } 204 } 205 } else { 206 // Direct MCP server configs 207 servers = { ...servers, ...mcpServersSpec } 208 } 209 } 210 211 return Object.keys(servers).length > 0 ? servers : undefined 212} 213 214/** 215 * Load MCP servers from a JSON file within a plugin 216 * This is a simplified version that doesn't expand environment variables 217 * and is specifically for plugin MCP configs 218 */ 219async function loadMcpServersFromFile( 220 pluginPath: string, 221 relativePath: string, 222): Promise<Record<string, McpServerConfig> | null> { 223 const fs = getFsImplementation() 224 const filePath = join(pluginPath, relativePath) 225 226 let content: string 227 try { 228 content = await fs.readFile(filePath, { encoding: 'utf-8' }) 229 } catch (e: unknown) { 230 if (isENOENT(e)) { 231 return null 232 } 233 logForDebugging(`Failed to load MCP servers from ${filePath}: ${e}`, { 234 level: 'error', 235 }) 236 return null 237 } 238 239 try { 240 const parsed = jsonParse(content) 241 242 // Check if it's in the .mcp.json format with mcpServers key 243 const mcpServers = parsed.mcpServers || parsed 244 245 // Validate each server config 246 const validatedServers: Record<string, McpServerConfig> = {} 247 for (const [name, config] of Object.entries(mcpServers)) { 248 const result = McpServerConfigSchema().safeParse(config) 249 if (result.success) { 250 validatedServers[name] = result.data 251 } else { 252 logForDebugging( 253 `Invalid MCP server config for ${name} in ${filePath}: ${result.error.message}`, 254 { level: 'error' }, 255 ) 256 } 257 } 258 259 return validatedServers 260 } catch (error) { 261 logForDebugging(`Failed to load MCP servers from ${filePath}: ${error}`, { 262 level: 'error', 263 }) 264 return null 265 } 266} 267 268/** 269 * A channel entry from a plugin's manifest whose userConfig has not yet been 270 * filled in (required fields are missing from saved settings). 271 */ 272export type UnconfiguredChannel = { 273 server: string 274 displayName: string 275 configSchema: UserConfigSchema 276} 277 278/** 279 * Find channel entries in a plugin's manifest whose required userConfig 280 * fields are not yet saved. Pure function — no React, no prompting. 281 * ManagePlugins.tsx calls this after a plugin is enabled to decide whether 282 * to show the config dialog. 283 * 284 * Entries without a `userConfig` schema are skipped (nothing to prompt for). 285 * Entries whose saved config already satisfies `validateUserConfig` are 286 * skipped. The `configSchema` in the return value is structurally a 287 * `UserConfigSchema` because the Zod schema in schemas.ts matches 288 * `McpbUserConfigurationOption` field-for-field. 289 */ 290export function getUnconfiguredChannels( 291 plugin: LoadedPlugin, 292): UnconfiguredChannel[] { 293 const channels = plugin.manifest.channels 294 if (!channels || channels.length === 0) { 295 return [] 296 } 297 298 // plugin.repository is already in "plugin@marketplace" format — same key 299 // loadMcpServerUserConfig / saveMcpServerUserConfig use. 300 const pluginId = plugin.repository 301 302 const unconfigured: UnconfiguredChannel[] = [] 303 for (const channel of channels) { 304 if (!channel.userConfig || Object.keys(channel.userConfig).length === 0) { 305 continue 306 } 307 const saved = loadMcpServerUserConfig(pluginId, channel.server) ?? {} 308 const validation = validateUserConfig(saved, channel.userConfig) 309 if (!validation.valid) { 310 unconfigured.push({ 311 server: channel.server, 312 displayName: channel.displayName ?? channel.server, 313 configSchema: channel.userConfig, 314 }) 315 } 316 } 317 return unconfigured 318} 319 320/** 321 * Look up saved user config for a server, if this server is declared as a 322 * channel in the plugin's manifest. Returns undefined for non-channel servers 323 * or channels without a userConfig schema — resolvePluginMcpEnvironment will 324 * then skip ${user_config.X} substitution for that server. 325 */ 326function loadChannelUserConfig( 327 plugin: LoadedPlugin, 328 serverName: string, 329): UserConfigValues | undefined { 330 const channel = plugin.manifest.channels?.find(c => c.server === serverName) 331 if (!channel?.userConfig) { 332 return undefined 333 } 334 return loadMcpServerUserConfig(plugin.repository, serverName) ?? undefined 335} 336 337/** 338 * Add plugin scope to MCP server configs 339 * This adds a prefix to server names to avoid conflicts between plugins 340 */ 341export function addPluginScopeToServers( 342 servers: Record<string, McpServerConfig>, 343 pluginName: string, 344 pluginSource: string, 345): Record<string, ScopedMcpServerConfig> { 346 const scopedServers: Record<string, ScopedMcpServerConfig> = {} 347 348 for (const [name, config] of Object.entries(servers)) { 349 // Add plugin prefix to server name to avoid conflicts 350 const scopedName = `plugin:${pluginName}:${name}` 351 const scoped: ScopedMcpServerConfig = { 352 ...config, 353 scope: 'dynamic', // Use dynamic scope for plugin servers 354 pluginSource, 355 } 356 scopedServers[scopedName] = scoped 357 } 358 359 return scopedServers 360} 361 362/** 363 * Extract all MCP servers from loaded plugins 364 * NOTE: Resolves environment variables for all servers before returning 365 */ 366export async function extractMcpServersFromPlugins( 367 plugins: LoadedPlugin[], 368 errors: PluginError[] = [], 369): Promise<Record<string, ScopedMcpServerConfig>> { 370 const allServers: Record<string, ScopedMcpServerConfig> = {} 371 372 const scopedResults = await Promise.all( 373 plugins.map(async plugin => { 374 if (!plugin.enabled) return null 375 376 const servers = await loadPluginMcpServers(plugin, errors) 377 if (!servers) return null 378 379 // Resolve environment variables before scoping. When a saved channel 380 // config is missing a key (plugin update added a required field, or a 381 // hand-edited settings.json), substituteUserConfigVariables throws 382 // inside resolvePluginMcpEnvironment — catch per-server so one bad 383 // config doesn't crash the whole plugin load via Promise.all. 384 const resolvedServers: Record<string, McpServerConfig> = {} 385 for (const [name, config] of Object.entries(servers)) { 386 const userConfig = buildMcpUserConfig(plugin, name) 387 try { 388 resolvedServers[name] = resolvePluginMcpEnvironment( 389 config, 390 plugin, 391 userConfig, 392 errors, 393 plugin.name, 394 name, 395 ) 396 } catch (err) { 397 errors?.push({ 398 type: 'generic-error', 399 source: name, 400 plugin: plugin.name, 401 error: errorMessage(err), 402 }) 403 } 404 } 405 406 // Store the UNRESOLVED servers on the plugin for caching 407 // (Environment variables will be resolved fresh each time they're needed) 408 plugin.mcpServers = servers 409 410 logForDebugging( 411 `Loaded ${Object.keys(servers).length} MCP servers from plugin ${plugin.name}`, 412 ) 413 414 return addPluginScopeToServers( 415 resolvedServers, 416 plugin.name, 417 plugin.source, 418 ) 419 }), 420 ) 421 422 for (const scopedServers of scopedResults) { 423 if (scopedServers) { 424 Object.assign(allServers, scopedServers) 425 } 426 } 427 428 return allServers 429} 430 431/** 432 * Build the userConfig map for a single MCP server by merging the plugin's 433 * top-level manifest.userConfig values with the channel-specific per-server 434 * config (assistant-mode channels). Channel-specific wins on collision so 435 * plugins that declare the same key at both levels get the more specific value. 436 * 437 * Returns undefined when neither source has anything — resolvePluginMcpEnvironment 438 * skips substituteUserConfigVariables in that case. 439 */ 440function buildMcpUserConfig( 441 plugin: LoadedPlugin, 442 serverName: string, 443): UserConfigValues | undefined { 444 // Gate on manifest.userConfig. loadPluginOptions always returns at least {} 445 // (it spreads two `?? {}` fallbacks), so without this guard topLevel is never 446 // undefined — the `!topLevel` check below is dead, we return {} for 447 // unconfigured plugins, and resolvePluginMcpEnvironment runs 448 // substituteUserConfigVariables against an empty map → throws on any 449 // ${user_config.X} ref. The manifest check also skips the unconditional 450 // keychain read (~50-100ms on macOS) for plugins that don't use options. 451 const topLevel = plugin.manifest.userConfig 452 ? loadPluginOptions(getPluginStorageId(plugin)) 453 : undefined 454 const channelSpecific = loadChannelUserConfig(plugin, serverName) 455 456 if (!topLevel && !channelSpecific) return undefined 457 return { ...topLevel, ...channelSpecific } 458} 459 460/** 461 * Resolve environment variables for plugin MCP servers 462 * Handles ${CLAUDE_PLUGIN_ROOT}, ${user_config.X}, and general ${VAR} substitution 463 * Tracks missing environment variables for error reporting 464 */ 465export function resolvePluginMcpEnvironment( 466 config: McpServerConfig, 467 plugin: { path: string; source: string }, 468 userConfig?: UserConfigValues, 469 errors?: PluginError[], 470 pluginName?: string, 471 serverName?: string, 472): McpServerConfig { 473 const allMissingVars: string[] = [] 474 475 const resolveValue = (value: string): string => { 476 // First substitute plugin-specific variables 477 let resolved = substitutePluginVariables(value, plugin) 478 479 // Then substitute user config variables if provided 480 if (userConfig) { 481 resolved = substituteUserConfigVariables(resolved, userConfig) 482 } 483 484 // Finally expand general environment variables 485 // This is done last so plugin-specific and user config vars take precedence 486 const { expanded, missingVars } = expandEnvVarsInString(resolved) 487 allMissingVars.push(...missingVars) 488 489 return expanded 490 } 491 492 let resolved: McpServerConfig 493 494 // Handle different server types 495 switch (config.type) { 496 case undefined: 497 case 'stdio': { 498 const stdioConfig = { ...config } 499 500 // Resolve command path 501 if (stdioConfig.command) { 502 stdioConfig.command = resolveValue(stdioConfig.command) 503 } 504 505 // Resolve args 506 if (stdioConfig.args) { 507 stdioConfig.args = stdioConfig.args.map(arg => resolveValue(arg)) 508 } 509 510 // Resolve environment variables and add CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA 511 const resolvedEnv: Record<string, string> = { 512 CLAUDE_PLUGIN_ROOT: plugin.path, 513 CLAUDE_PLUGIN_DATA: getPluginDataDir(plugin.source), 514 ...(stdioConfig.env || {}), 515 } 516 for (const [key, value] of Object.entries(resolvedEnv)) { 517 if (key !== 'CLAUDE_PLUGIN_ROOT' && key !== 'CLAUDE_PLUGIN_DATA') { 518 resolvedEnv[key] = resolveValue(value) 519 } 520 } 521 stdioConfig.env = resolvedEnv 522 523 resolved = stdioConfig 524 break 525 } 526 527 case 'sse': 528 case 'http': 529 case 'ws': { 530 const remoteConfig = { ...config } 531 532 // Resolve URL 533 if (remoteConfig.url) { 534 remoteConfig.url = resolveValue(remoteConfig.url) 535 } 536 537 // Resolve headers 538 if (remoteConfig.headers) { 539 const resolvedHeaders: Record<string, string> = {} 540 for (const [key, value] of Object.entries(remoteConfig.headers)) { 541 resolvedHeaders[key] = resolveValue(value) 542 } 543 remoteConfig.headers = resolvedHeaders 544 } 545 546 resolved = remoteConfig 547 break 548 } 549 550 // For other types (sse-ide, ws-ide, sdk, claudeai-proxy), pass through unchanged 551 case 'sse-ide': 552 case 'ws-ide': 553 case 'sdk': 554 case 'claudeai-proxy': 555 resolved = config 556 break 557 } 558 559 // Log and track missing variables if any were found and errors array provided 560 if (errors && allMissingVars.length > 0) { 561 const uniqueMissingVars = [...new Set(allMissingVars)] 562 const varList = uniqueMissingVars.join(', ') 563 564 logForDebugging( 565 `Missing environment variables in plugin MCP config: ${varList}`, 566 { level: 'warn' }, 567 ) 568 569 // Add error to the errors array if plugin and server names are provided 570 if (pluginName && serverName) { 571 errors.push({ 572 type: 'mcp-config-invalid', 573 source: `plugin:${pluginName}`, 574 plugin: pluginName, 575 serverName, 576 validationError: `Missing environment variables: ${varList}`, 577 }) 578 } 579 } 580 581 return resolved 582} 583 584/** 585 * Get MCP servers from a specific plugin with environment variable resolution and scoping 586 * This function is called when the MCP servers need to be activated and ensures they have 587 * the proper environment variables and scope applied 588 */ 589export async function getPluginMcpServers( 590 plugin: LoadedPlugin, 591 errors: PluginError[] = [], 592): Promise<Record<string, ScopedMcpServerConfig> | undefined> { 593 if (!plugin.enabled) { 594 return undefined 595 } 596 597 // Use cached servers if available 598 const servers = 599 plugin.mcpServers || (await loadPluginMcpServers(plugin, errors)) 600 if (!servers) { 601 return undefined 602 } 603 604 // Resolve environment variables. Same per-server try/catch as 605 // extractMcpServersFromPlugins above: a partial saved channel config 606 // (plugin update added a required field) would make 607 // substituteUserConfigVariables throw inside resolvePluginMcpEnvironment, 608 // and this function runs inside Promise.all at config.ts:911 — one 609 // uncaught throw crashes all plugin MCP loading. 610 const resolvedServers: Record<string, McpServerConfig> = {} 611 for (const [name, config] of Object.entries(servers)) { 612 const userConfig = buildMcpUserConfig(plugin, name) 613 try { 614 resolvedServers[name] = resolvePluginMcpEnvironment( 615 config, 616 plugin, 617 userConfig, 618 errors, 619 plugin.name, 620 name, 621 ) 622 } catch (err) { 623 errors?.push({ 624 type: 'generic-error', 625 source: name, 626 plugin: plugin.name, 627 error: errorMessage(err), 628 }) 629 } 630 } 631 632 // Add plugin scope 633 return addPluginScopeToServers(resolvedServers, plugin.name, plugin.source) 634}