/ utils / plugins / mcpbHandler.ts
mcpbHandler.ts
  1  import type {
  2    McpbManifest,
  3    McpbUserConfigurationOption,
  4  } from '@anthropic-ai/mcpb'
  5  import axios from 'axios'
  6  import { createHash } from 'crypto'
  7  import { chmod, writeFile } from 'fs/promises'
  8  import { dirname, join } from 'path'
  9  import type { McpServerConfig } from '../../services/mcp/types.js'
 10  import { logForDebugging } from '../debug.js'
 11  import { parseAndValidateManifestFromBytes } from '../dxt/helpers.js'
 12  import { parseZipModes, unzipFile } from '../dxt/zip.js'
 13  import { errorMessage, getErrnoCode, isENOENT, toError } from '../errors.js'
 14  import { getFsImplementation } from '../fsOperations.js'
 15  import { logError } from '../log.js'
 16  import { getSecureStorage } from '../secureStorage/index.js'
 17  import {
 18    getSettings_DEPRECATED,
 19    updateSettingsForSource,
 20  } from '../settings/settings.js'
 21  import { jsonParse, jsonStringify } from '../slowOperations.js'
 22  import { getSystemDirectories } from '../systemDirectories.js'
 23  import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'
 24  /**
 25   * User configuration values for MCPB
 26   */
 27  export type UserConfigValues = Record<
 28    string,
 29    string | number | boolean | string[]
 30  >
 31  
 32  /**
 33   * User configuration schema from DXT manifest
 34   */
 35  export type UserConfigSchema = Record<string, McpbUserConfigurationOption>
 36  
 37  /**
 38   * Result of loading an MCPB file (success case)
 39   */
 40  export type McpbLoadResult = {
 41    manifest: McpbManifest
 42    mcpConfig: McpServerConfig
 43    extractedPath: string
 44    contentHash: string
 45  }
 46  
 47  /**
 48   * Result when MCPB needs user configuration
 49   */
 50  export type McpbNeedsConfigResult = {
 51    status: 'needs-config'
 52    manifest: McpbManifest
 53    extractedPath: string
 54    contentHash: string
 55    configSchema: UserConfigSchema
 56    existingConfig: UserConfigValues
 57    validationErrors: string[]
 58  }
 59  
 60  /**
 61   * Metadata stored for each cached MCPB
 62   */
 63  export type McpbCacheMetadata = {
 64    source: string
 65    contentHash: string
 66    extractedPath: string
 67    cachedAt: string
 68    lastChecked: string
 69  }
 70  
 71  /**
 72   * Progress callback for download and extraction operations
 73   */
 74  export type ProgressCallback = (status: string) => void
 75  
 76  /**
 77   * Check if a source string is an MCPB file reference
 78   */
 79  export function isMcpbSource(source: string): boolean {
 80    return source.endsWith('.mcpb') || source.endsWith('.dxt')
 81  }
 82  
 83  /**
 84   * Check if a source is a URL
 85   */
 86  function isUrl(source: string): boolean {
 87    return source.startsWith('http://') || source.startsWith('https://')
 88  }
 89  
 90  /**
 91   * Generate content hash for an MCPB file
 92   */
 93  function generateContentHash(data: Uint8Array): string {
 94    return createHash('sha256').update(data).digest('hex').substring(0, 16)
 95  }
 96  
 97  /**
 98   * Get cache directory for MCPB files
 99   */
100  function getMcpbCacheDir(pluginPath: string): string {
101    return join(pluginPath, '.mcpb-cache')
102  }
103  
104  /**
105   * Get metadata file path for cached MCPB
106   */
107  function getMetadataPath(cacheDir: string, source: string): string {
108    const sourceHash = createHash('md5')
109      .update(source)
110      .digest('hex')
111      .substring(0, 8)
112    return join(cacheDir, `${sourceHash}.metadata.json`)
113  }
114  
115  /**
116   * Compose the secureStorage key for a per-server secret bucket.
117   * `pluginSecrets` is a flat map — per-server secrets share it with top-level
118   * plugin options (pluginOptionsStorage.ts) using a `${pluginId}/${server}`
119   * composite key. `/` can't appear in plugin IDs (`name@marketplace`) or
120   * server names (MCP identifier constraints), so it's unambiguous. Keeps the
121   * SecureStorageData schema unchanged and the single-keychain-entry size
122   * budget (~2KB stdin-safe, see INC-3028) shared across all plugin secrets.
123   */
124  function serverSecretsKey(pluginId: string, serverName: string): string {
125    return `${pluginId}/${serverName}`
126  }
127  
128  /**
129   * Load user configuration for an MCP server, merging non-sensitive values
130   * (from settings.json) with sensitive values (from secureStorage keychain).
131   * secureStorage wins on collision — schema determines destination so
132   * collision shouldn't happen, but if a user hand-edits settings.json we
133   * trust the more secure source.
134   *
135   * Returns null only if NEITHER source has anything — callers skip
136   * ${user_config.X} substitution in that case.
137   *
138   * @param pluginId - Plugin identifier in "plugin@marketplace" format
139   * @param serverName - MCP server name from DXT manifest
140   */
141  export function loadMcpServerUserConfig(
142    pluginId: string,
143    serverName: string,
144  ): UserConfigValues | null {
145    try {
146      const settings = getSettings_DEPRECATED()
147      const nonSensitive =
148        settings.pluginConfigs?.[pluginId]?.mcpServers?.[serverName]
149  
150      const sensitive =
151        getSecureStorage().read()?.pluginSecrets?.[
152          serverSecretsKey(pluginId, serverName)
153        ]
154  
155      if (!nonSensitive && !sensitive) {
156        return null
157      }
158  
159      logForDebugging(
160        `Loaded user config for ${pluginId}/${serverName} (settings + secureStorage)`,
161      )
162      return { ...nonSensitive, ...sensitive }
163    } catch (error) {
164      const errorObj = toError(error)
165      logError(errorObj)
166      logForDebugging(
167        `Failed to load user config for ${pluginId}/${serverName}: ${error}`,
168        { level: 'error' },
169      )
170      return null
171    }
172  }
173  
174  /**
175   * Save user configuration for an MCP server, splitting by `schema[key].sensitive`.
176   * Mirrors savePluginOptions (pluginOptionsStorage.ts:90) for top-level options:
177   *   - `sensitive: true` → secureStorage (keychain on macOS, .credentials.json 0600 elsewhere)
178   *   - everything else   → settings.json pluginConfigs[pluginId].mcpServers[serverName]
179   *
180   * Without this split, per-channel `sensitive: true` was a false sense of
181   * security — the dialog masked the input but the save went to plaintext
182   * settings.json anyway. H1 #3617646 (Telegram/Discord bot tokens in
183   * world-readable .env) surfaced this as the gap to close.
184   *
185   * Writes are skipped if nothing in that category is present.
186   *
187   * @param pluginId - Plugin identifier in "plugin@marketplace" format
188   * @param serverName - MCP server name from DXT manifest
189   * @param config - User configuration values
190   * @param schema - The userConfig schema for this server (manifest.user_config
191   *   or channels[].userConfig) — drives the sensitive/non-sensitive split
192   */
193  export function saveMcpServerUserConfig(
194    pluginId: string,
195    serverName: string,
196    config: UserConfigValues,
197    schema: UserConfigSchema,
198  ): void {
199    try {
200      const nonSensitive: UserConfigValues = {}
201      const sensitive: Record<string, string> = {}
202  
203      for (const [key, value] of Object.entries(config)) {
204        if (schema[key]?.sensitive === true) {
205          sensitive[key] = String(value)
206        } else {
207          nonSensitive[key] = value
208        }
209      }
210  
211      // Scrub ONLY keys we're writing in this call. Covers both directions
212      // across schema-version flips:
213      //  - sensitive→secureStorage ⇒ remove stale plaintext from settings.json
214      //  - nonSensitive→settings.json ⇒ remove stale entry from secureStorage
215      //    (otherwise loadMcpServerUserConfig's {...nonSensitive, ...sensitive}
216      //    would let the stale secureStorage value win on next read)
217      // Partial `config` (user only re-enters one field) leaves other fields
218      // untouched in BOTH stores — defense-in-depth against future callers.
219      const sensitiveKeysInThisSave = new Set(Object.keys(sensitive))
220      const nonSensitiveKeysInThisSave = new Set(Object.keys(nonSensitive))
221  
222      // Sensitive → secureStorage FIRST. If this fails (keychain locked,
223      // .credentials.json perms), throw before touching settings.json — the
224      // old plaintext stays as a fallback instead of losing BOTH copies.
225      //
226      // Also scrub non-sensitive keys from secureStorage — schema flipped
227      // sensitive→false and they're being written to settings.json now. Without
228      // this, loadMcpServerUserConfig's merge would let the stale secureStorage
229      // value win on next read.
230      const storage = getSecureStorage()
231      const k = serverSecretsKey(pluginId, serverName)
232      const existingInSecureStorage =
233        storage.read()?.pluginSecrets?.[k] ?? undefined
234      const secureScrubbed = existingInSecureStorage
235        ? Object.fromEntries(
236            Object.entries(existingInSecureStorage).filter(
237              ([key]) => !nonSensitiveKeysInThisSave.has(key),
238            ),
239          )
240        : undefined
241      const needSecureScrub =
242        secureScrubbed &&
243        existingInSecureStorage &&
244        Object.keys(secureScrubbed).length !==
245          Object.keys(existingInSecureStorage).length
246      if (Object.keys(sensitive).length > 0 || needSecureScrub) {
247        const existing = storage.read() ?? {}
248        if (!existing.pluginSecrets) {
249          existing.pluginSecrets = {}
250        }
251        // secureStorage keyvault is a flat object — direct replace, no merge
252        // semantics to worry about (unlike settings.json's mergeWith).
253        existing.pluginSecrets[k] = {
254          ...secureScrubbed,
255          ...sensitive,
256        }
257        const result = storage.update(existing)
258        if (!result.success) {
259          throw new Error(
260            `Failed to save sensitive config to secure storage for ${k}`,
261          )
262        }
263        if (result.warning) {
264          logForDebugging(`Server secrets save warning: ${result.warning}`, {
265            level: 'warn',
266          })
267        }
268        if (needSecureScrub) {
269          logForDebugging(
270            `saveMcpServerUserConfig: scrubbed ${
271              Object.keys(existingInSecureStorage!).length -
272              Object.keys(secureScrubbed!).length
273            } stale non-sensitive key(s) from secureStorage for ${k}`,
274          )
275        }
276      }
277  
278      // Non-sensitive → settings.json. Write whenever there are new non-sensitive
279      // values OR existing plaintext sensitive values to scrub — so reconfiguring
280      // a sensitive-only schema still cleans up the old settings.json. Runs
281      // AFTER the secureStorage write succeeded, so the scrub can't leave you
282      // with zero copies of the secret.
283      //
284      // updateSettingsForSource does mergeWith(diskSettings, ourSettings, ...)
285      // which PRESERVES destination keys absent from source — so simply omitting
286      // sensitive keys doesn't scrub them, the disk copy merges back in. Instead:
287      // set each sensitive key to explicit `undefined` — mergeWith (with the
288      // customizer at settings.ts:349) treats explicit undefined as a delete.
289      const settings = getSettings_DEPRECATED()
290      const existingInSettings =
291        settings.pluginConfigs?.[pluginId]?.mcpServers?.[serverName] ?? {}
292      const keysToScrubFromSettings = Object.keys(existingInSettings).filter(k =>
293        sensitiveKeysInThisSave.has(k),
294      )
295      if (
296        Object.keys(nonSensitive).length > 0 ||
297        keysToScrubFromSettings.length > 0
298      ) {
299        if (!settings.pluginConfigs) {
300          settings.pluginConfigs = {}
301        }
302        if (!settings.pluginConfigs[pluginId]) {
303          settings.pluginConfigs[pluginId] = {}
304        }
305        if (!settings.pluginConfigs[pluginId].mcpServers) {
306          settings.pluginConfigs[pluginId].mcpServers = {}
307        }
308        // Build the scrub-via-undefined map. The UserConfigValues type doesn't
309        // include undefined, but updateSettingsForSource's mergeWith customizer
310        // needs explicit undefined to delete — cast is deliberate internal
311        // plumbing (same rationale as deletePluginOptions in
312        // pluginOptionsStorage.ts:184, see CLAUDE.md's 10% case).
313        const scrubbed = Object.fromEntries(
314          keysToScrubFromSettings.map(k => [k, undefined]),
315        ) as Record<string, undefined>
316        settings.pluginConfigs[pluginId].mcpServers![serverName] = {
317          ...nonSensitive,
318          ...scrubbed,
319        } as UserConfigValues
320        const result = updateSettingsForSource('userSettings', settings)
321        if (result.error) {
322          throw result.error
323        }
324        if (keysToScrubFromSettings.length > 0) {
325          logForDebugging(
326            `saveMcpServerUserConfig: scrubbed ${keysToScrubFromSettings.length} plaintext sensitive key(s) from settings.json for ${pluginId}/${serverName}`,
327          )
328        }
329      }
330  
331      logForDebugging(
332        `Saved user config for ${pluginId}/${serverName} (${Object.keys(nonSensitive).length} non-sensitive, ${Object.keys(sensitive).length} sensitive)`,
333      )
334    } catch (error) {
335      const errorObj = toError(error)
336      logError(errorObj)
337      throw new Error(
338        `Failed to save user configuration for ${pluginId}/${serverName}: ${errorObj.message}`,
339      )
340    }
341  }
342  
343  /**
344   * Validate user configuration values against DXT user_config schema
345   */
346  export function validateUserConfig(
347    values: UserConfigValues,
348    schema: UserConfigSchema,
349  ): { valid: boolean; errors: string[] } {
350    const errors: string[] = []
351  
352    // Check each field in the schema
353    for (const [key, fieldSchema] of Object.entries(schema)) {
354      const value = values[key]
355  
356      // Check required fields
357      if (fieldSchema.required && (value === undefined || value === '')) {
358        errors.push(`${fieldSchema.title || key} is required but not provided`)
359        continue
360      }
361  
362      // Skip validation for optional fields that aren't provided
363      if (value === undefined || value === '') {
364        continue
365      }
366  
367      // Type validation
368      if (fieldSchema.type === 'string') {
369        if (Array.isArray(value)) {
370          // String arrays are allowed if multiple: true
371          if (!fieldSchema.multiple) {
372            errors.push(
373              `${fieldSchema.title || key} must be a string, not an array`,
374            )
375          } else if (!value.every(v => typeof v === 'string')) {
376            errors.push(`${fieldSchema.title || key} must be an array of strings`)
377          }
378        } else if (typeof value !== 'string') {
379          errors.push(`${fieldSchema.title || key} must be a string`)
380        }
381      } else if (fieldSchema.type === 'number' && typeof value !== 'number') {
382        errors.push(`${fieldSchema.title || key} must be a number`)
383      } else if (fieldSchema.type === 'boolean' && typeof value !== 'boolean') {
384        errors.push(`${fieldSchema.title || key} must be a boolean`)
385      } else if (
386        (fieldSchema.type === 'file' || fieldSchema.type === 'directory') &&
387        typeof value !== 'string'
388      ) {
389        errors.push(`${fieldSchema.title || key} must be a path string`)
390      }
391  
392      // Number range validation
393      if (fieldSchema.type === 'number' && typeof value === 'number') {
394        if (fieldSchema.min !== undefined && value < fieldSchema.min) {
395          errors.push(
396            `${fieldSchema.title || key} must be at least ${fieldSchema.min}`,
397          )
398        }
399        if (fieldSchema.max !== undefined && value > fieldSchema.max) {
400          errors.push(
401            `${fieldSchema.title || key} must be at most ${fieldSchema.max}`,
402          )
403        }
404      }
405    }
406  
407    return { valid: errors.length === 0, errors }
408  }
409  
410  /**
411   * Generate MCP server configuration from DXT manifest
412   */
413  async function generateMcpConfig(
414    manifest: McpbManifest,
415    extractedPath: string,
416    userConfig: UserConfigValues = {},
417  ): Promise<McpServerConfig> {
418    // Lazy import: @anthropic-ai/mcpb barrel pulls in zod v3 schemas (~700KB of
419    // bound closures). See dxt/helpers.ts for details.
420    const { getMcpConfigForManifest } = await import('@anthropic-ai/mcpb')
421    const mcpConfig = await getMcpConfigForManifest({
422      manifest,
423      extensionPath: extractedPath,
424      systemDirs: getSystemDirectories(),
425      userConfig,
426      pathSeparator: '/',
427    })
428  
429    if (!mcpConfig) {
430      const error = new Error(
431        `Failed to generate MCP server configuration from manifest "${manifest.name}"`,
432      )
433      logError(error)
434      throw error
435    }
436  
437    return mcpConfig as McpServerConfig
438  }
439  
440  /**
441   * Load cache metadata for an MCPB source
442   */
443  async function loadCacheMetadata(
444    cacheDir: string,
445    source: string,
446  ): Promise<McpbCacheMetadata | null> {
447    const fs = getFsImplementation()
448    const metadataPath = getMetadataPath(cacheDir, source)
449  
450    try {
451      const content = await fs.readFile(metadataPath, { encoding: 'utf-8' })
452      return jsonParse(content) as McpbCacheMetadata
453    } catch (error) {
454      const code = getErrnoCode(error)
455      if (code === 'ENOENT') return null
456      const errorObj = toError(error)
457      logError(errorObj)
458      logForDebugging(`Failed to load MCPB cache metadata: ${error}`, {
459        level: 'error',
460      })
461      return null
462    }
463  }
464  
465  /**
466   * Save cache metadata for an MCPB source
467   */
468  async function saveCacheMetadata(
469    cacheDir: string,
470    source: string,
471    metadata: McpbCacheMetadata,
472  ): Promise<void> {
473    const metadataPath = getMetadataPath(cacheDir, source)
474  
475    await getFsImplementation().mkdir(cacheDir)
476    await writeFile(metadataPath, jsonStringify(metadata, null, 2), 'utf-8')
477  }
478  
479  /**
480   * Download MCPB file from URL
481   */
482  async function downloadMcpb(
483    url: string,
484    destPath: string,
485    onProgress?: ProgressCallback,
486  ): Promise<Uint8Array> {
487    logForDebugging(`Downloading MCPB from ${url}`)
488    if (onProgress) {
489      onProgress(`Downloading ${url}...`)
490    }
491  
492    const started = performance.now()
493    let fetchTelemetryFired = false
494    try {
495      const response = await axios.get(url, {
496        timeout: 120000, // 2 minute timeout
497        responseType: 'arraybuffer',
498        maxRedirects: 5, // Follow redirects (like curl -L)
499        onDownloadProgress: progressEvent => {
500          if (progressEvent.total && onProgress) {
501            const percent = Math.round(
502              (progressEvent.loaded / progressEvent.total) * 100,
503            )
504            onProgress(`Downloading... ${percent}%`)
505          }
506        },
507      })
508  
509      const data = new Uint8Array(response.data)
510      // Fire telemetry before writeFile — the event measures the network
511      // fetch, not disk I/O. A writeFile EACCES would otherwise match
512      // classifyFetchError's /permission denied/ → misreport as auth.
513      logPluginFetch('mcpb', url, 'success', performance.now() - started)
514      fetchTelemetryFired = true
515  
516      // Save to disk (binary data)
517      await writeFile(destPath, Buffer.from(data))
518  
519      logForDebugging(`Downloaded ${data.length} bytes to ${destPath}`)
520      if (onProgress) {
521        onProgress('Download complete')
522      }
523  
524      return data
525    } catch (error) {
526      if (!fetchTelemetryFired) {
527        logPluginFetch(
528          'mcpb',
529          url,
530          'failure',
531          performance.now() - started,
532          classifyFetchError(error),
533        )
534      }
535      const errorMsg = errorMessage(error)
536      const fullError = new Error(
537        `Failed to download MCPB file from ${url}: ${errorMsg}`,
538      )
539      logError(fullError)
540      throw fullError
541    }
542  }
543  
544  /**
545   * Extract MCPB file and write contents to extraction directory.
546   *
547   * @param modes - name→mode map from `parseZipModes`. MCPB bundles can ship
548   *   native MCP server binaries, so preserving the exec bit matters here.
549   */
550  async function extractMcpbContents(
551    unzipped: Record<string, Uint8Array>,
552    extractPath: string,
553    modes: Record<string, number>,
554    onProgress?: ProgressCallback,
555  ): Promise<void> {
556    if (onProgress) {
557      onProgress('Extracting files...')
558    }
559  
560    // Create extraction directory
561    await getFsImplementation().mkdir(extractPath)
562  
563    // Write all files. Filter directory entries from the count so progress
564    // messages use the same denominator as filesWritten (which skips them).
565    let filesWritten = 0
566    const entries = Object.entries(unzipped).filter(([k]) => !k.endsWith('/'))
567    const totalFiles = entries.length
568  
569    for (const [filePath, fileData] of entries) {
570      // Directory entries (common in zip -r, Python zipfile, Java ZipOutputStream)
571      // are filtered above — writeFile would create `bin/` as an empty regular
572      // file, then mkdir for `bin/server` would fail with ENOTDIR. The
573      // mkdir(dirname(fullPath)) below creates parent dirs implicitly.
574  
575      const fullPath = join(extractPath, filePath)
576      const dir = dirname(fullPath)
577  
578      // Ensure directory exists (recursive handles already-existing)
579      if (dir !== extractPath) {
580        await getFsImplementation().mkdir(dir)
581      }
582  
583      // Determine if text or binary
584      const isTextFile =
585        filePath.endsWith('.json') ||
586        filePath.endsWith('.js') ||
587        filePath.endsWith('.ts') ||
588        filePath.endsWith('.txt') ||
589        filePath.endsWith('.md') ||
590        filePath.endsWith('.yml') ||
591        filePath.endsWith('.yaml')
592  
593      if (isTextFile) {
594        const content = new TextDecoder().decode(fileData)
595        await writeFile(fullPath, content, 'utf-8')
596      } else {
597        await writeFile(fullPath, Buffer.from(fileData))
598      }
599  
600      const mode = modes[filePath]
601      if (mode && mode & 0o111) {
602        // Swallow EPERM/ENOTSUP (NFS root_squash, some FUSE mounts) — losing +x
603        // is the pre-PR behavior and better than aborting mid-extraction.
604        await chmod(fullPath, mode & 0o777).catch(() => {})
605      }
606  
607      filesWritten++
608      if (onProgress && filesWritten % 10 === 0) {
609        onProgress(`Extracted ${filesWritten}/${totalFiles} files`)
610      }
611    }
612  
613    logForDebugging(`Extracted ${filesWritten} files to ${extractPath}`)
614    if (onProgress) {
615      onProgress(`Extraction complete (${filesWritten} files)`)
616    }
617  }
618  
619  /**
620   * Check if an MCPB source has changed and needs re-extraction
621   */
622  export async function checkMcpbChanged(
623    source: string,
624    pluginPath: string,
625  ): Promise<boolean> {
626    const fs = getFsImplementation()
627    const cacheDir = getMcpbCacheDir(pluginPath)
628    const metadata = await loadCacheMetadata(cacheDir, source)
629  
630    if (!metadata) {
631      // No cache metadata, needs loading
632      return true
633    }
634  
635    // Check if extraction directory still exists
636    try {
637      await fs.stat(metadata.extractedPath)
638    } catch (error) {
639      const code = getErrnoCode(error)
640      if (code === 'ENOENT') {
641        logForDebugging(`MCPB extraction path missing: ${metadata.extractedPath}`)
642      } else {
643        logForDebugging(
644          `MCPB extraction path inaccessible: ${metadata.extractedPath}: ${error}`,
645          { level: 'error' },
646        )
647      }
648      return true
649    }
650  
651    // For local files, check mtime
652    if (!isUrl(source)) {
653      const localPath = join(pluginPath, source)
654      let stats
655      try {
656        stats = await fs.stat(localPath)
657      } catch (error) {
658        const code = getErrnoCode(error)
659        if (code === 'ENOENT') {
660          logForDebugging(`MCPB source file missing: ${localPath}`)
661        } else {
662          logForDebugging(
663            `MCPB source file inaccessible: ${localPath}: ${error}`,
664            { level: 'error' },
665          )
666        }
667        return true
668      }
669  
670      const cachedTime = new Date(metadata.cachedAt).getTime()
671      // Floor to match the ms precision of cachedAt (ISO string). Sub-ms
672      // precision on mtimeMs would make a freshly-cached file appear "newer"
673      // than its own cache timestamp when both happen in the same millisecond.
674      const fileTime = Math.floor(stats.mtimeMs)
675  
676      if (fileTime > cachedTime) {
677        logForDebugging(
678          `MCPB file modified: ${new Date(fileTime)} > ${new Date(cachedTime)}`,
679        )
680        return true
681      }
682    }
683  
684    // For URLs, we'll re-check on explicit update (handled elsewhere)
685    return false
686  }
687  
688  /**
689   * Load and extract an MCPB file, with caching and user configuration support
690   *
691   * @param source - MCPB file path or URL
692   * @param pluginPath - Plugin directory path
693   * @param pluginId - Plugin identifier in "plugin@marketplace" format (for config storage)
694   * @param onProgress - Progress callback
695   * @param providedUserConfig - User configuration values (for initial setup or reconfiguration)
696   * @returns Success with MCP config, or needs-config status with schema
697   */
698  export async function loadMcpbFile(
699    source: string,
700    pluginPath: string,
701    pluginId: string,
702    onProgress?: ProgressCallback,
703    providedUserConfig?: UserConfigValues,
704    forceConfigDialog?: boolean,
705  ): Promise<McpbLoadResult | McpbNeedsConfigResult> {
706    const fs = getFsImplementation()
707    const cacheDir = getMcpbCacheDir(pluginPath)
708    await fs.mkdir(cacheDir)
709  
710    logForDebugging(`Loading MCPB from source: ${source}`)
711  
712    // Check cache first
713    const metadata = await loadCacheMetadata(cacheDir, source)
714    if (metadata && !(await checkMcpbChanged(source, pluginPath))) {
715      logForDebugging(
716        `Using cached MCPB from ${metadata.extractedPath} (hash: ${metadata.contentHash})`,
717      )
718  
719      // Load manifest from cache
720      const manifestPath = join(metadata.extractedPath, 'manifest.json')
721      let manifestContent: string
722      try {
723        manifestContent = await fs.readFile(manifestPath, { encoding: 'utf-8' })
724      } catch (error) {
725        if (isENOENT(error)) {
726          const err = new Error(`Cached manifest not found: ${manifestPath}`)
727          logError(err)
728          throw err
729        }
730        throw error
731      }
732  
733      const manifestData = new TextEncoder().encode(manifestContent)
734      const manifest = await parseAndValidateManifestFromBytes(manifestData)
735  
736      // Check for user_config requirement
737      if (manifest.user_config && Object.keys(manifest.user_config).length > 0) {
738        // Server name from DXT manifest
739        const serverName = manifest.name
740  
741        // Try to load existing config from settings.json or use provided config
742        const savedConfig = loadMcpServerUserConfig(pluginId, serverName)
743        const userConfig = providedUserConfig || savedConfig || {}
744  
745        // Validate we have all required fields
746        const validation = validateUserConfig(userConfig, manifest.user_config)
747  
748        // Return needs-config if: forced (reconfiguration) OR validation failed
749        if (forceConfigDialog || !validation.valid) {
750          return {
751            status: 'needs-config',
752            manifest,
753            extractedPath: metadata.extractedPath,
754            contentHash: metadata.contentHash,
755            configSchema: manifest.user_config,
756            existingConfig: savedConfig || {},
757            validationErrors: validation.valid ? [] : validation.errors,
758          }
759        }
760  
761        // Save config if it was provided (first time or reconfiguration)
762        if (providedUserConfig) {
763          saveMcpServerUserConfig(
764            pluginId,
765            serverName,
766            providedUserConfig,
767            manifest.user_config ?? {},
768          )
769        }
770  
771        // Generate MCP config WITH user config
772        const mcpConfig = await generateMcpConfig(
773          manifest,
774          metadata.extractedPath,
775          userConfig,
776        )
777  
778        return {
779          manifest,
780          mcpConfig,
781          extractedPath: metadata.extractedPath,
782          contentHash: metadata.contentHash,
783        }
784      }
785  
786      // No user_config required - generate config without it
787      const mcpConfig = await generateMcpConfig(manifest, metadata.extractedPath)
788  
789      return {
790        manifest,
791        mcpConfig,
792        extractedPath: metadata.extractedPath,
793        contentHash: metadata.contentHash,
794      }
795    }
796  
797    // Not cached or changed - need to download/load and extract
798    let mcpbData: Uint8Array
799    let mcpbFilePath: string
800  
801    if (isUrl(source)) {
802      // Download from URL
803      const sourceHash = createHash('md5')
804        .update(source)
805        .digest('hex')
806        .substring(0, 8)
807      mcpbFilePath = join(cacheDir, `${sourceHash}.mcpb`)
808      mcpbData = await downloadMcpb(source, mcpbFilePath, onProgress)
809    } else {
810      // Load from local path
811      const localPath = join(pluginPath, source)
812  
813      if (onProgress) {
814        onProgress(`Loading ${source}...`)
815      }
816  
817      try {
818        mcpbData = await fs.readFileBytes(localPath)
819        mcpbFilePath = localPath
820      } catch (error) {
821        if (isENOENT(error)) {
822          const err = new Error(`MCPB file not found: ${localPath}`)
823          logError(err)
824          throw err
825        }
826        throw error
827      }
828    }
829  
830    // Generate content hash
831    const contentHash = generateContentHash(mcpbData)
832    logForDebugging(`MCPB content hash: ${contentHash}`)
833  
834    // Extract ZIP
835    if (onProgress) {
836      onProgress('Extracting MCPB archive...')
837    }
838  
839    const unzipped = await unzipFile(Buffer.from(mcpbData))
840    // fflate doesn't surface external_attr — parse the central directory so
841    // native MCP server binaries keep their exec bit after extraction.
842    const modes = parseZipModes(mcpbData)
843  
844    // Check for manifest.json
845    const manifestData = unzipped['manifest.json']
846    if (!manifestData) {
847      const error = new Error('No manifest.json found in MCPB file')
848      logError(error)
849      throw error
850    }
851  
852    // Parse and validate manifest
853    const manifest = await parseAndValidateManifestFromBytes(manifestData)
854    logForDebugging(
855      `MCPB manifest: ${manifest.name} v${manifest.version} by ${manifest.author.name}`,
856    )
857  
858    // Check if manifest has server config
859    if (!manifest.server) {
860      const error = new Error(
861        `MCPB manifest for "${manifest.name}" does not define a server configuration`,
862      )
863      logError(error)
864      throw error
865    }
866  
867    // Extract to cache directory
868    const extractPath = join(cacheDir, contentHash)
869    await extractMcpbContents(unzipped, extractPath, modes, onProgress)
870  
871    // Check for user_config requirement
872    if (manifest.user_config && Object.keys(manifest.user_config).length > 0) {
873      // Server name from DXT manifest
874      const serverName = manifest.name
875  
876      // Try to load existing config from settings.json or use provided config
877      const savedConfig = loadMcpServerUserConfig(pluginId, serverName)
878      const userConfig = providedUserConfig || savedConfig || {}
879  
880      // Validate we have all required fields
881      const validation = validateUserConfig(userConfig, manifest.user_config)
882  
883      if (!validation.valid) {
884        // Save cache metadata even though config is incomplete
885        const newMetadata: McpbCacheMetadata = {
886          source,
887          contentHash,
888          extractedPath: extractPath,
889          cachedAt: new Date().toISOString(),
890          lastChecked: new Date().toISOString(),
891        }
892        await saveCacheMetadata(cacheDir, source, newMetadata)
893  
894        // Return "needs configuration" status
895        return {
896          status: 'needs-config',
897          manifest,
898          extractedPath: extractPath,
899          contentHash,
900          configSchema: manifest.user_config,
901          existingConfig: savedConfig || {},
902          validationErrors: validation.errors,
903        }
904      }
905  
906      // Save config if it was provided (first time or reconfiguration)
907      if (providedUserConfig) {
908        saveMcpServerUserConfig(
909          pluginId,
910          serverName,
911          providedUserConfig,
912          manifest.user_config ?? {},
913        )
914      }
915  
916      // Generate MCP config WITH user config
917      if (onProgress) {
918        onProgress('Generating MCP server configuration...')
919      }
920  
921      const mcpConfig = await generateMcpConfig(manifest, extractPath, userConfig)
922  
923      // Save cache metadata
924      const newMetadata: McpbCacheMetadata = {
925        source,
926        contentHash,
927        extractedPath: extractPath,
928        cachedAt: new Date().toISOString(),
929        lastChecked: new Date().toISOString(),
930      }
931      await saveCacheMetadata(cacheDir, source, newMetadata)
932  
933      return {
934        manifest,
935        mcpConfig,
936        extractedPath: extractPath,
937        contentHash,
938      }
939    }
940  
941    // No user_config required - generate config without it
942    if (onProgress) {
943      onProgress('Generating MCP server configuration...')
944    }
945  
946    const mcpConfig = await generateMcpConfig(manifest, extractPath)
947  
948    // Save cache metadata
949    const newMetadata: McpbCacheMetadata = {
950      source,
951      contentHash,
952      extractedPath: extractPath,
953      cachedAt: new Date().toISOString(),
954      lastChecked: new Date().toISOString(),
955    }
956    await saveCacheMetadata(cacheDir, source, newMetadata)
957  
958    logForDebugging(
959      `Successfully loaded MCPB: ${manifest.name} (extracted to ${extractPath})`,
960    )
961  
962    return {
963      manifest,
964      mcpConfig: mcpConfig as McpServerConfig,
965      extractedPath: extractPath,
966      contentHash,
967    }
968  }