/ utils / plugins / mcpPluginIntegration.ts
mcpPluginIntegration.ts
  1  import { join } from 'path'
  2  import { expandEnvVarsInString } from '../../services/mcp/envExpansion.js'
  3  import {
  4    type McpServerConfig,
  5    McpServerConfigSchema,
  6    type ScopedMcpServerConfig,
  7  } from '../../services/mcp/types.js'
  8  import type { LoadedPlugin, PluginError } from '../../types/plugin.js'
  9  import { logForDebugging } from '../debug.js'
 10  import { errorMessage, isENOENT } from '../errors.js'
 11  import { getFsImplementation } from '../fsOperations.js'
 12  import { jsonParse } from '../slowOperations.js'
 13  import {
 14    isMcpbSource,
 15    loadMcpbFile,
 16    loadMcpServerUserConfig,
 17    type McpbLoadResult,
 18    type UserConfigSchema,
 19    type UserConfigValues,
 20    validateUserConfig,
 21  } from './mcpbHandler.js'
 22  import { getPluginDataDir } from './pluginDirectories.js'
 23  import {
 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   */
 34  async 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   */
131  export 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   */
219  async 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   */
272  export 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   */
290  export 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   */
326  function 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   */
341  export 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   */
366  export 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   */
440  function 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   */
465  export 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   */
589  export 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  }