source dump of claude code
at main 968 lines 31 kB view raw
1import type { 2 McpbManifest, 3 McpbUserConfigurationOption, 4} from '@anthropic-ai/mcpb' 5import axios from 'axios' 6import { createHash } from 'crypto' 7import { chmod, writeFile } from 'fs/promises' 8import { dirname, join } from 'path' 9import type { McpServerConfig } from '../../services/mcp/types.js' 10import { logForDebugging } from '../debug.js' 11import { parseAndValidateManifestFromBytes } from '../dxt/helpers.js' 12import { parseZipModes, unzipFile } from '../dxt/zip.js' 13import { errorMessage, getErrnoCode, isENOENT, toError } from '../errors.js' 14import { getFsImplementation } from '../fsOperations.js' 15import { logError } from '../log.js' 16import { getSecureStorage } from '../secureStorage/index.js' 17import { 18 getSettings_DEPRECATED, 19 updateSettingsForSource, 20} from '../settings/settings.js' 21import { jsonParse, jsonStringify } from '../slowOperations.js' 22import { getSystemDirectories } from '../systemDirectories.js' 23import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js' 24/** 25 * User configuration values for MCPB 26 */ 27export type UserConfigValues = Record< 28 string, 29 string | number | boolean | string[] 30> 31 32/** 33 * User configuration schema from DXT manifest 34 */ 35export type UserConfigSchema = Record<string, McpbUserConfigurationOption> 36 37/** 38 * Result of loading an MCPB file (success case) 39 */ 40export 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 */ 50export 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 */ 63export 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 */ 74export type ProgressCallback = (status: string) => void 75 76/** 77 * Check if a source string is an MCPB file reference 78 */ 79export 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 */ 86function isUrl(source: string): boolean { 87 return source.startsWith('http://') || source.startsWith('https://') 88} 89 90/** 91 * Generate content hash for an MCPB file 92 */ 93function 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 */ 100function getMcpbCacheDir(pluginPath: string): string { 101 return join(pluginPath, '.mcpb-cache') 102} 103 104/** 105 * Get metadata file path for cached MCPB 106 */ 107function 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 */ 124function 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 */ 141export 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 */ 193export 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 */ 346export 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 */ 413async 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 */ 443async 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 */ 468async 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 */ 482async 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 */ 550async 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 */ 622export 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 */ 698export 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}