Download src/utils/plugins/mcpPluginIntegration.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 20.1 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/utils/plugins/mcpPluginIntegration.ts
- Command line
-
hf download hf://chenbhao/codev/src/utils/plugins/mcpPluginIntegration.ts
-
curl -L -o mcpPluginIntegration.ts https://huggingface.co/chenbhao/codev/resolve/main/src/utils/plugins/mcpPluginIntegration.ts
20.1 kB
| import { join } from 'path' | |
| import { expandEnvVarsInString } from '../../services/mcp/envExpansion.js' | |
| import { | |
| type McpServerConfig, | |
| McpServerConfigSchema, | |
| type ScopedMcpServerConfig, | |
| } from '../../services/mcp/types.js' | |
| import type { LoadedPlugin, PluginError } from '../../types/plugin.js' | |
| import { logForDebugging } from '../debug.js' | |
| import { errorMessage, isENOENT } from '../errors.js' | |
| import { getFsImplementation } from '../fsOperations.js' | |
| import { jsonParse } from '../slowOperations.js' | |
| import { | |
| isMcpbSource, | |
| loadMcpbFile, | |
| loadMcpServerUserConfig, | |
| type McpbLoadResult, | |
| type UserConfigSchema, | |
| type UserConfigValues, | |
| validateUserConfig, | |
| } from './mcpbHandler.js' | |
| import { getPluginDataDir } from './pluginDirectories.js' | |
| import { | |
| getPluginStorageId, | |
| loadPluginOptions, | |
| substitutePluginVariables, | |
| substituteUserConfigVariables, | |
| } from './pluginOptionsStorage.js' | |
| /** | |
| * Load MCP servers from an MCPB file | |
| * Handles downloading, extracting, and converting DXT manifest to MCP config | |
| */ | |
| async function loadMcpServersFromMcpb( | |
| plugin: LoadedPlugin, | |
| mcpbPath: string, | |
| errors: PluginError[], | |
| ): Promise<Record<string, McpServerConfig> | null> { | |
| try { | |
| logForDebugging(`Loading MCP servers from MCPB: ${mcpbPath}`) | |
| // Use plugin.repository directly - it's already in "plugin@marketplace" format | |
| const pluginId = plugin.repository | |
| const result = await loadMcpbFile( | |
| mcpbPath, | |
| plugin.path, | |
| pluginId, | |
| status => { | |
| logForDebugging(`MCPB [${plugin.name}]: ${status}`) | |
| }, | |
| ) | |
| // Check if MCPB needs user configuration | |
| if ('status' in result && result.status === 'needs-config') { | |
| // User config needed - this is normal for unconfigured plugins | |
| // Don't load the MCP server yet - user can configure via /plugin menu | |
| logForDebugging( | |
| `MCPB ${mcpbPath} requires user configuration. ` + | |
| `User can configure via: /plugin → Manage plugins → ${plugin.name} → Configure`, | |
| ) | |
| // Return null to skip this server for now (not an error) | |
| return null | |
| } | |
| // Type guard passed - result is success type | |
| const successResult = result as McpbLoadResult | |
| // Use the DXT manifest name as the server name | |
| const serverName = successResult.manifest.name | |
| // Check for server name conflicts with existing servers | |
| // This will be checked later when merging all servers, but we log here for debugging | |
| logForDebugging( | |
| `Loaded MCP server "${serverName}" from MCPB (extracted to ${successResult.extractedPath})`, | |
| ) | |
| return { [serverName]: successResult.mcpConfig } | |
| } catch (error) { | |
| const errorMsg = errorMessage(error) | |
| logForDebugging(`Failed to load MCPB ${mcpbPath}: ${errorMsg}`, { | |
| level: 'error', | |
| }) | |
| // Use plugin@repository as source (consistent with other plugin errors) | |
| const source = `${plugin.name}@${plugin.repository}` | |
| // Determine error type based on error message | |
| const isUrl = mcpbPath.startsWith('http') | |
| if ( | |
| isUrl && | |
| (errorMsg.includes('download') || errorMsg.includes('network')) | |
| ) { | |
| errors.push({ | |
| type: 'mcpb-download-failed', | |
| source, | |
| plugin: plugin.name, | |
| url: mcpbPath, | |
| reason: errorMsg, | |
| }) | |
| } else if ( | |
| errorMsg.includes('manifest') || | |
| errorMsg.includes('user configuration') | |
| ) { | |
| errors.push({ | |
| type: 'mcpb-invalid-manifest', | |
| source, | |
| plugin: plugin.name, | |
| mcpbPath, | |
| validationError: errorMsg, | |
| }) | |
| } else { | |
| errors.push({ | |
| type: 'mcpb-extract-failed', | |
| source, | |
| plugin: plugin.name, | |
| mcpbPath, | |
| reason: errorMsg, | |
| }) | |
| } | |
| return null | |
| } | |
| } | |
| /** | |
| * Load MCP servers from a plugin's manifest | |
| * This function loads MCP server configurations from various sources within the plugin | |
| * including manifest entries, .mcp.json files, and .mcpb files | |
| */ | |
| export async function loadPluginMcpServers( | |
| plugin: LoadedPlugin, | |
| errors: PluginError[] = [], | |
| ): Promise<Record<string, McpServerConfig> | undefined> { | |
| let servers: Record<string, McpServerConfig> = {} | |
| // Check for .mcp.json in plugin directory first (lowest priority) | |
| const defaultMcpServers = await loadMcpServersFromFile( | |
| plugin.path, | |
| '.mcp.json', | |
| ) | |
| if (defaultMcpServers) { | |
| servers = { ...servers, ...defaultMcpServers } | |
| } | |
| // Handle manifest mcpServers if present (higher priority) | |
| if (plugin.manifest.mcpServers) { | |
| const mcpServersSpec = plugin.manifest.mcpServers | |
| // Handle different mcpServers formats | |
| if (typeof mcpServersSpec === 'string') { | |
| // Check if it's an MCPB file | |
| if (isMcpbSource(mcpServersSpec)) { | |
| const mcpbServers = await loadMcpServersFromMcpb( | |
| plugin, | |
| mcpServersSpec, | |
| errors, | |
| ) | |
| if (mcpbServers) { | |
| servers = { ...servers, ...mcpbServers } | |
| } | |
| } else { | |
| // Path to JSON file | |
| const mcpServers = await loadMcpServersFromFile( | |
| plugin.path, | |
| mcpServersSpec, | |
| ) | |
| if (mcpServers) { | |
| servers = { ...servers, ...mcpServers } | |
| } | |
| } | |
| } else if (Array.isArray(mcpServersSpec)) { | |
| // Array of paths or inline configs. | |
| // Load all specs in parallel, then merge in original order so | |
| // last-wins collision semantics are preserved. | |
| const results = await Promise.all( | |
| mcpServersSpec.map(async spec => { | |
| try { | |
| if (typeof spec === 'string') { | |
| // Check if it's an MCPB file | |
| if (isMcpbSource(spec)) { | |
| return await loadMcpServersFromMcpb(plugin, spec, errors) | |
| } | |
| // Path to JSON file | |
| return await loadMcpServersFromFile(plugin.path, spec) | |
| } | |
| // Inline MCP server configs (sync) | |
| return spec | |
| } catch (e) { | |
| // Defensive: if one spec throws, don't lose results from the | |
| // others. The previous serial loop implicitly tolerated this. | |
| logForDebugging( | |
| `Failed to load MCP servers from spec for plugin ${plugin.name}: ${e}`, | |
| { level: 'error' }, | |
| ) | |
| return null | |
| } | |
| }), | |
| ) | |
| for (const result of results) { | |
| if (result) { | |
| servers = { ...servers, ...result } | |
| } | |
| } | |
| } else { | |
| // Direct MCP server configs | |
| servers = { ...servers, ...mcpServersSpec } | |
| } | |
| } | |
| return Object.keys(servers).length > 0 ? servers : undefined | |
| } | |
| /** | |
| * Load MCP servers from a JSON file within a plugin | |
| * This is a simplified version that doesn't expand environment variables | |
| * and is specifically for plugin MCP configs | |
| */ | |
| async function loadMcpServersFromFile( | |
| pluginPath: string, | |
| relativePath: string, | |
| ): Promise<Record<string, McpServerConfig> | null> { | |
| const fs = getFsImplementation() | |
| const filePath = join(pluginPath, relativePath) | |
| let content: string | |
| try { | |
| content = await fs.readFile(filePath, { encoding: 'utf-8' }) | |
| } catch (e: unknown) { | |
| if (isENOENT(e)) { | |
| return null | |
| } | |
| logForDebugging(`Failed to load MCP servers from ${filePath}: ${e}`, { | |
| level: 'error', | |
| }) | |
| return null | |
| } | |
| try { | |
| const parsed = jsonParse(content) | |
| // Check if it's in the .mcp.json format with mcpServers key | |
| const mcpServers = parsed.mcpServers || parsed | |
| // Validate each server config | |
| const validatedServers: Record<string, McpServerConfig> = {} | |
| for (const [name, config] of Object.entries(mcpServers)) { | |
| const result = McpServerConfigSchema().safeParse(config) | |
| if (result.success) { | |
| validatedServers[name] = result.data | |
| } else { | |
| logForDebugging( | |
| `Invalid MCP server config for ${name} in ${filePath}: ${result.error.message}`, | |
| { level: 'error' }, | |
| ) | |
| } | |
| } | |
| return validatedServers | |
| } catch (error) { | |
| logForDebugging(`Failed to load MCP servers from ${filePath}: ${error}`, { | |
| level: 'error', | |
| }) | |
| return null | |
| } | |
| } | |
| /** | |
| * A channel entry from a plugin's manifest whose userConfig has not yet been | |
| * filled in (required fields are missing from saved settings). | |
| */ | |
| export type UnconfiguredChannel = { | |
| server: string | |
| displayName: string | |
| configSchema: UserConfigSchema | |
| } | |
| /** | |
| * Find channel entries in a plugin's manifest whose required userConfig | |
| * fields are not yet saved. Pure function — no React, no prompting. | |
| * ManagePlugins.tsx calls this after a plugin is enabled to decide whether | |
| * to show the config dialog. | |
| * | |
| * Entries without a `userConfig` schema are skipped (nothing to prompt for). | |
| * Entries whose saved config already satisfies `validateUserConfig` are | |
| * skipped. The `configSchema` in the return value is structurally a | |
| * `UserConfigSchema` because the Zod schema in schemas.ts matches | |
| * `McpbUserConfigurationOption` field-for-field. | |
| */ | |
| export function getUnconfiguredChannels( | |
| plugin: LoadedPlugin, | |
| ): UnconfiguredChannel[] { | |
| const channels = plugin.manifest.channels | |
| if (!channels || channels.length === 0) { | |
| return [] | |
| } | |
| // plugin.repository is already in "plugin@marketplace" format — same key | |
| // loadMcpServerUserConfig / saveMcpServerUserConfig use. | |
| const pluginId = plugin.repository | |
| const unconfigured: UnconfiguredChannel[] = [] | |
| for (const channel of channels) { | |
| if (!channel.userConfig || Object.keys(channel.userConfig).length === 0) { | |
| continue | |
| } | |
| const saved = loadMcpServerUserConfig(pluginId, channel.server) ?? {} | |
| const validation = validateUserConfig(saved, channel.userConfig) | |
| if (!validation.valid) { | |
| unconfigured.push({ | |
| server: channel.server, | |
| displayName: channel.displayName ?? channel.server, | |
| configSchema: channel.userConfig, | |
| }) | |
| } | |
| } | |
| return unconfigured | |
| } | |
| /** | |
| * Look up saved user config for a server, if this server is declared as a | |
| * channel in the plugin's manifest. Returns undefined for non-channel servers | |
| * or channels without a userConfig schema — resolvePluginMcpEnvironment will | |
| * then skip ${user_config.X} substitution for that server. | |
| */ | |
| function loadChannelUserConfig( | |
| plugin: LoadedPlugin, | |
| serverName: string, | |
| ): UserConfigValues | undefined { | |
| const channel = plugin.manifest.channels?.find(c => c.server === serverName) | |
| if (!channel?.userConfig) { | |
| return undefined | |
| } | |
| return loadMcpServerUserConfig(plugin.repository, serverName) ?? undefined | |
| } | |
| /** | |
| * Add plugin scope to MCP server configs | |
| * This adds a prefix to server names to avoid conflicts between plugins | |
| */ | |
| export function addPluginScopeToServers( | |
| servers: Record<string, McpServerConfig>, | |
| pluginName: string, | |
| pluginSource: string, | |
| ): Record<string, ScopedMcpServerConfig> { | |
| const scopedServers: Record<string, ScopedMcpServerConfig> = {} | |
| for (const [name, config] of Object.entries(servers)) { | |
| // Add plugin prefix to server name to avoid conflicts | |
| const scopedName = `plugin:${pluginName}:${name}` | |
| const scoped: ScopedMcpServerConfig = { | |
| ...config, | |
| scope: 'dynamic', // Use dynamic scope for plugin servers | |
| pluginSource, | |
| } | |
| scopedServers[scopedName] = scoped | |
| } | |
| return scopedServers | |
| } | |
| /** | |
| * Extract all MCP servers from loaded plugins | |
| * NOTE: Resolves environment variables for all servers before returning | |
| */ | |
| export async function extractMcpServersFromPlugins( | |
| plugins: LoadedPlugin[], | |
| errors: PluginError[] = [], | |
| ): Promise<Record<string, ScopedMcpServerConfig>> { | |
| const allServers: Record<string, ScopedMcpServerConfig> = {} | |
| const scopedResults = await Promise.all( | |
| plugins.map(async plugin => { | |
| if (!plugin.enabled) return null | |
| const servers = await loadPluginMcpServers(plugin, errors) | |
| if (!servers) return null | |
| // Resolve environment variables before scoping. When a saved channel | |
| // config is missing a key (plugin update added a required field, or a | |
| // hand-edited settings.json), substituteUserConfigVariables throws | |
| // inside resolvePluginMcpEnvironment — catch per-server so one bad | |
| // config doesn't crash the whole plugin load via Promise.all. | |
| const resolvedServers: Record<string, McpServerConfig> = {} | |
| for (const [name, config] of Object.entries(servers)) { | |
| const userConfig = buildMcpUserConfig(plugin, name) | |
| try { | |
| resolvedServers[name] = resolvePluginMcpEnvironment( | |
| config, | |
| plugin, | |
| userConfig, | |
| errors, | |
| plugin.name, | |
| name, | |
| ) | |
| } catch (err) { | |
| errors?.push({ | |
| type: 'generic-error', | |
| source: name, | |
| plugin: plugin.name, | |
| error: errorMessage(err), | |
| }) | |
| } | |
| } | |
| // Store the UNRESOLVED servers on the plugin for caching | |
| // (Environment variables will be resolved fresh each time they're needed) | |
| plugin.mcpServers = servers | |
| logForDebugging( | |
| `Loaded ${Object.keys(servers).length} MCP servers from plugin ${plugin.name}`, | |
| ) | |
| return addPluginScopeToServers( | |
| resolvedServers, | |
| plugin.name, | |
| plugin.source, | |
| ) | |
| }), | |
| ) | |
| for (const scopedServers of scopedResults) { | |
| if (scopedServers) { | |
| Object.assign(allServers, scopedServers) | |
| } | |
| } | |
| return allServers | |
| } | |
| /** | |
| * Build the userConfig map for a single MCP server by merging the plugin's | |
| * top-level manifest.userConfig values with the channel-specific per-server | |
| * config (assistant-mode channels). Channel-specific wins on collision so | |
| * plugins that declare the same key at both levels get the more specific value. | |
| * | |
| * Returns undefined when neither source has anything — resolvePluginMcpEnvironment | |
| * skips substituteUserConfigVariables in that case. | |
| */ | |
| function buildMcpUserConfig( | |
| plugin: LoadedPlugin, | |
| serverName: string, | |
| ): UserConfigValues | undefined { | |
| // Gate on manifest.userConfig. loadPluginOptions always returns at least {} | |
| // (it spreads two `?? {}` fallbacks), so without this guard topLevel is never | |
| // undefined — the `!topLevel` check below is dead, we return {} for | |
| // unconfigured plugins, and resolvePluginMcpEnvironment runs | |
| // substituteUserConfigVariables against an empty map → throws on any | |
| // ${user_config.X} ref. The manifest check also skips the unconditional | |
| // keychain read (~50-100ms on macOS) for plugins that don't use options. | |
| const topLevel = plugin.manifest.userConfig | |
| ? loadPluginOptions(getPluginStorageId(plugin)) | |
| : undefined | |
| const channelSpecific = loadChannelUserConfig(plugin, serverName) | |
| if (!topLevel && !channelSpecific) return undefined | |
| return { ...topLevel, ...channelSpecific } | |
| } | |
| /** | |
| * Resolve environment variables for plugin MCP servers | |
| * Handles ${CLAUDE_PLUGIN_ROOT}, ${user_config.X}, and general ${VAR} substitution | |
| * Tracks missing environment variables for error reporting | |
| */ | |
| export function resolvePluginMcpEnvironment( | |
| config: McpServerConfig, | |
| plugin: { path: string; source: string }, | |
| userConfig?: UserConfigValues, | |
| errors?: PluginError[], | |
| pluginName?: string, | |
| serverName?: string, | |
| ): McpServerConfig { | |
| const allMissingVars: string[] = [] | |
| const resolveValue = (value: string): string => { | |
| // First substitute plugin-specific variables | |
| let resolved = substitutePluginVariables(value, plugin) | |
| // Then substitute user config variables if provided | |
| if (userConfig) { | |
| resolved = substituteUserConfigVariables(resolved, userConfig) | |
| } | |
| // Finally expand general environment variables | |
| // This is done last so plugin-specific and user config vars take precedence | |
| const { expanded, missingVars } = expandEnvVarsInString(resolved) | |
| allMissingVars.push(...missingVars) | |
| return expanded | |
| } | |
| let resolved: McpServerConfig | |
| // Handle different server types | |
| switch (config.type) { | |
| case undefined: | |
| case 'stdio': { | |
| const stdioConfig = { ...config } | |
| // Resolve command path | |
| if (stdioConfig.command) { | |
| stdioConfig.command = resolveValue(stdioConfig.command) | |
| } | |
| // Resolve args | |
| if (stdioConfig.args) { | |
| stdioConfig.args = stdioConfig.args.map(arg => resolveValue(arg)) | |
| } | |
| // Resolve environment variables and add CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA | |
| const resolvedEnv: Record<string, string> = { | |
| CLAUDE_PLUGIN_ROOT: plugin.path, | |
| CLAUDE_PLUGIN_DATA: getPluginDataDir(plugin.source), | |
| ...(stdioConfig.env || {}), | |
| } | |
| for (const [key, value] of Object.entries(resolvedEnv)) { | |
| if (key !== 'CLAUDE_PLUGIN_ROOT' && key !== 'CLAUDE_PLUGIN_DATA') { | |
| resolvedEnv[key] = resolveValue(value) | |
| } | |
| } | |
| stdioConfig.env = resolvedEnv | |
| resolved = stdioConfig | |
| break | |
| } | |
| case 'sse': | |
| case 'http': | |
| case 'ws': { | |
| const remoteConfig = { ...config } | |
| // Resolve URL | |
| if (remoteConfig.url) { | |
| remoteConfig.url = resolveValue(remoteConfig.url) | |
| } | |
| // Resolve headers | |
| if (remoteConfig.headers) { | |
| const resolvedHeaders: Record<string, string> = {} | |
| for (const [key, value] of Object.entries(remoteConfig.headers)) { | |
| resolvedHeaders[key] = resolveValue(value) | |
| } | |
| remoteConfig.headers = resolvedHeaders | |
| } | |
| resolved = remoteConfig | |
| break | |
| } | |
| // For other types (sse-ide, ws-ide, sdk, claudeai-proxy), pass through unchanged | |
| case 'sse-ide': | |
| case 'ws-ide': | |
| case 'sdk': | |
| case 'claudeai-proxy': | |
| resolved = config | |
| break | |
| } | |
| // Log and track missing variables if any were found and errors array provided | |
| if (errors && allMissingVars.length > 0) { | |
| const uniqueMissingVars = [...new Set(allMissingVars)] | |
| const varList = uniqueMissingVars.join(', ') | |
| logForDebugging( | |
| `Missing environment variables in plugin MCP config: ${varList}`, | |
| { level: 'warn' }, | |
| ) | |
| // Add error to the errors array if plugin and server names are provided | |
| if (pluginName && serverName) { | |
| errors.push({ | |
| type: 'mcp-config-invalid', | |
| source: `plugin:${pluginName}`, | |
| plugin: pluginName, | |
| serverName, | |
| validationError: `Missing environment variables: ${varList}`, | |
| }) | |
| } | |
| } | |
| return resolved | |
| } | |
| /** | |
| * Get MCP servers from a specific plugin with environment variable resolution and scoping | |
| * This function is called when the MCP servers need to be activated and ensures they have | |
| * the proper environment variables and scope applied | |
| */ | |
| export async function getPluginMcpServers( | |
| plugin: LoadedPlugin, | |
| errors: PluginError[] = [], | |
| ): Promise<Record<string, ScopedMcpServerConfig> | undefined> { | |
| if (!plugin.enabled) { | |
| return undefined | |
| } | |
| // Use cached servers if available | |
| const servers = | |
| plugin.mcpServers || (await loadPluginMcpServers(plugin, errors)) | |
| if (!servers) { | |
| return undefined | |
| } | |
| // Resolve environment variables. Same per-server try/catch as | |
| // extractMcpServersFromPlugins above: a partial saved channel config | |
| // (plugin update added a required field) would make | |
| // substituteUserConfigVariables throw inside resolvePluginMcpEnvironment, | |
| // and this function runs inside Promise.all at config.ts:911 — one | |
| // uncaught throw crashes all plugin MCP loading. | |
| const resolvedServers: Record<string, McpServerConfig> = {} | |
| for (const [name, config] of Object.entries(servers)) { | |
| const userConfig = buildMcpUserConfig(plugin, name) | |
| try { | |
| resolvedServers[name] = resolvePluginMcpEnvironment( | |
| config, | |
| plugin, | |
| userConfig, | |
| errors, | |
| plugin.name, | |
| name, | |
| ) | |
| } catch (err) { | |
| errors?.push({ | |
| type: 'generic-error', | |
| source: name, | |
| plugin: plugin.name, | |
| error: errorMessage(err), | |
| }) | |
| } | |
| } | |
| // Add plugin scope | |
| return addPluginScopeToServers(resolvedServers, plugin.name, plugin.source) | |
| } | |