Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
31 kB · 968 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969import type { McpbManifest, McpbUserConfigurationOption,} from '@anthropic-ai/mcpb'import axios from 'axios'import { createHash } from 'crypto'import { chmod, writeFile } from 'fs/promises'import { dirname, join } from 'path'import type { McpServerConfig } from '../../services/mcp/types.js'import { logForDebugging } from '../debug.js'import { parseAndValidateManifestFromBytes } from '../dxt/helpers.js'import { parseZipModes, unzipFile } from '../dxt/zip.js'import { errorMessage, getErrnoCode, isENOENT, toError } from '../errors.js'import { getFsImplementation } from '../fsOperations.js'import { logError } from '../log.js'import { getSecureStorage } from '../secureStorage/index.js'import { getSettings_DEPRECATED, updateSettingsForSource,} from '../settings/settings.js'import { jsonParse, jsonStringify } from '../slowOperations.js'import { getSystemDirectories } from '../systemDirectories.js'import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'/** * User configuration values for MCPB */export type UserConfigValues = Record< string, string | number | boolean | string[]>
/** * User configuration schema from DXT manifest */export type UserConfigSchema = Record<string, McpbUserConfigurationOption>
/** * Result of loading an MCPB file (success case) */export type McpbLoadResult = { manifest: McpbManifest mcpConfig: McpServerConfig extractedPath: string contentHash: string}
/** * Result when MCPB needs user configuration */export type McpbNeedsConfigResult = { status: 'needs-config' manifest: McpbManifest extractedPath: string contentHash: string configSchema: UserConfigSchema existingConfig: UserConfigValues validationErrors: string[]}
/** * Metadata stored for each cached MCPB */export type McpbCacheMetadata = { source: string contentHash: string extractedPath: string cachedAt: string lastChecked: string}
/** * Progress callback for download and extraction operations */export type ProgressCallback = (status: string) => void
/** * Check if a source string is an MCPB file reference */export function isMcpbSource(source: string): boolean { return source.endsWith('.mcpb') || source.endsWith('.dxt')}
/** * Check if a source is a URL */function isUrl(source: string): boolean { return source.startsWith('http://') || source.startsWith('https://')}
/** * Generate content hash for an MCPB file */function generateContentHash(data: Uint8Array): string { return createHash('sha256').update(data).digest('hex').substring(0, 16)}
/** * Get cache directory for MCPB files */function getMcpbCacheDir(pluginPath: string): string { return join(pluginPath, '.mcpb-cache')}
/** * Get metadata file path for cached MCPB */function getMetadataPath(cacheDir: string, source: string): string { const sourceHash = createHash('md5') .update(source) .digest('hex') .substring(0, 8) return join(cacheDir, `${sourceHash}.metadata.json`)}
/** * Compose the secureStorage key for a per-server secret bucket. * `pluginSecrets` is a flat map — per-server secrets share it with top-level * plugin options (pluginOptionsStorage.ts) using a `${pluginId}/${server}` * composite key. `/` can't appear in plugin IDs (`name@marketplace`) or * server names (MCP identifier constraints), so it's unambiguous. Keeps the * SecureStorageData schema unchanged and the single-keychain-entry size * budget (~2KB stdin-safe, see INC-3028) shared across all plugin secrets. */function serverSecretsKey(pluginId: string, serverName: string): string { return `${pluginId}/${serverName}`}
/** * Load user configuration for an MCP server, merging non-sensitive values * (from settings.json) with sensitive values (from secureStorage keychain). * secureStorage wins on collision — schema determines destination so * collision shouldn't happen, but if a user hand-edits settings.json we * trust the more secure source. * * Returns null only if NEITHER source has anything — callers skip * ${user_config.X} substitution in that case. * * @param pluginId - Plugin identifier in "plugin@marketplace" format * @param serverName - MCP server name from DXT manifest */export function loadMcpServerUserConfig( pluginId: string, serverName: string,): UserConfigValues | null { try { const settings = getSettings_DEPRECATED() const nonSensitive = settings.pluginConfigs?.[pluginId]?.mcpServers?.[serverName]
const sensitive = getSecureStorage().read()?.pluginSecrets?.[ serverSecretsKey(pluginId, serverName) ]
if (!nonSensitive && !sensitive) { return null }
logForDebugging( `Loaded user config for ${pluginId}/${serverName} (settings + secureStorage)`, ) return { ...nonSensitive, ...sensitive } } catch (error) { const errorObj = toError(error) logError(errorObj) logForDebugging( `Failed to load user config for ${pluginId}/${serverName}: ${error}`, { level: 'error' }, ) return null }}
/** * Save user configuration for an MCP server, splitting by `schema[key].sensitive`. * Mirrors savePluginOptions (pluginOptionsStorage.ts:90) for top-level options: * - `sensitive: true` → secureStorage (keychain on macOS, .credentials.json 0600 elsewhere) * - everything else → settings.json pluginConfigs[pluginId].mcpServers[serverName] * * Without this split, per-channel `sensitive: true` was a false sense of * security — the dialog masked the input but the save went to plaintext * settings.json anyway. H1 #3617646 (Telegram/Discord bot tokens in * world-readable .env) surfaced this as the gap to close. * * Writes are skipped if nothing in that category is present. * * @param pluginId - Plugin identifier in "plugin@marketplace" format * @param serverName - MCP server name from DXT manifest * @param config - User configuration values * @param schema - The userConfig schema for this server (manifest.user_config * or channels[].userConfig) — drives the sensitive/non-sensitive split */export function saveMcpServerUserConfig( pluginId: string, serverName: string, config: UserConfigValues, schema: UserConfigSchema,): void { try { const nonSensitive: UserConfigValues = {} const sensitive: Record<string, string> = {}
for (const [key, value] of Object.entries(config)) { if (schema[key]?.sensitive === true) { sensitive[key] = String(value) } else { nonSensitive[key] = value } }
// Scrub ONLY keys we're writing in this call. Covers both directions // across schema-version flips: // - sensitive→secureStorage ⇒ remove stale plaintext from settings.json // - nonSensitive→settings.json ⇒ remove stale entry from secureStorage // (otherwise loadMcpServerUserConfig's {...nonSensitive, ...sensitive} // would let the stale secureStorage value win on next read) // Partial `config` (user only re-enters one field) leaves other fields // untouched in BOTH stores — defense-in-depth against future callers. const sensitiveKeysInThisSave = new Set(Object.keys(sensitive)) const nonSensitiveKeysInThisSave = new Set(Object.keys(nonSensitive))
// Sensitive → secureStorage FIRST. If this fails (keychain locked, // .credentials.json perms), throw before touching settings.json — the // old plaintext stays as a fallback instead of losing BOTH copies. // // Also scrub non-sensitive keys from secureStorage — schema flipped // sensitive→false and they're being written to settings.json now. Without // this, loadMcpServerUserConfig's merge would let the stale secureStorage // value win on next read. const storage = getSecureStorage() const k = serverSecretsKey(pluginId, serverName) const existingInSecureStorage = storage.read()?.pluginSecrets?.[k] ?? undefined const secureScrubbed = existingInSecureStorage ? Object.fromEntries( Object.entries(existingInSecureStorage).filter( ([key]) => !nonSensitiveKeysInThisSave.has(key), ), ) : undefined const needSecureScrub = secureScrubbed && existingInSecureStorage && Object.keys(secureScrubbed).length !== Object.keys(existingInSecureStorage).length if (Object.keys(sensitive).length > 0 || needSecureScrub) { const existing = storage.read() ?? {} if (!existing.pluginSecrets) { existing.pluginSecrets = {} } // secureStorage keyvault is a flat object — direct replace, no merge // semantics to worry about (unlike settings.json's mergeWith). existing.pluginSecrets[k] = { ...secureScrubbed, ...sensitive, } const result = storage.update(existing) if (!result.success) { throw new Error( `Failed to save sensitive config to secure storage for ${k}`, ) } if (result.warning) { logForDebugging(`Server secrets save warning: ${result.warning}`, { level: 'warn', }) } if (needSecureScrub) { logForDebugging( `saveMcpServerUserConfig: scrubbed ${ Object.keys(existingInSecureStorage!).length - Object.keys(secureScrubbed!).length } stale non-sensitive key(s) from secureStorage for ${k}`, ) } }
// Non-sensitive → settings.json. Write whenever there are new non-sensitive // values OR existing plaintext sensitive values to scrub — so reconfiguring // a sensitive-only schema still cleans up the old settings.json. Runs // AFTER the secureStorage write succeeded, so the scrub can't leave you // with zero copies of the secret. // // updateSettingsForSource does mergeWith(diskSettings, ourSettings, ...) // which PRESERVES destination keys absent from source — so simply omitting // sensitive keys doesn't scrub them, the disk copy merges back in. Instead: // set each sensitive key to explicit `undefined` — mergeWith (with the // customizer at settings.ts:349) treats explicit undefined as a delete. const settings = getSettings_DEPRECATED() const existingInSettings = settings.pluginConfigs?.[pluginId]?.mcpServers?.[serverName] ?? {} const keysToScrubFromSettings = Object.keys(existingInSettings).filter(k => sensitiveKeysInThisSave.has(k), ) if ( Object.keys(nonSensitive).length > 0 || keysToScrubFromSettings.length > 0 ) { if (!settings.pluginConfigs) { settings.pluginConfigs = {} } if (!settings.pluginConfigs[pluginId]) { settings.pluginConfigs[pluginId] = {} } if (!settings.pluginConfigs[pluginId].mcpServers) { settings.pluginConfigs[pluginId].mcpServers = {} } // Build the scrub-via-undefined map. The UserConfigValues type doesn't // include undefined, but updateSettingsForSource's mergeWith customizer // needs explicit undefined to delete — cast is deliberate internal // plumbing (same rationale as deletePluginOptions in // pluginOptionsStorage.ts:184, see CLAUDE.md's 10% case). const scrubbed = Object.fromEntries( keysToScrubFromSettings.map(k => [k, undefined]), ) as Record<string, undefined> settings.pluginConfigs[pluginId].mcpServers![serverName] = { ...nonSensitive, ...scrubbed, } as UserConfigValues const result = updateSettingsForSource('userSettings', settings) if (result.error) { throw result.error } if (keysToScrubFromSettings.length > 0) { logForDebugging( `saveMcpServerUserConfig: scrubbed ${keysToScrubFromSettings.length} plaintext sensitive key(s) from settings.json for ${pluginId}/${serverName}`, ) } }
logForDebugging( `Saved user config for ${pluginId}/${serverName} (${Object.keys(nonSensitive).length} non-sensitive, ${Object.keys(sensitive).length} sensitive)`, ) } catch (error) { const errorObj = toError(error) logError(errorObj) throw new Error( `Failed to save user configuration for ${pluginId}/${serverName}: ${errorObj.message}`, ) }}
/** * Validate user configuration values against DXT user_config schema */export function validateUserConfig( values: UserConfigValues, schema: UserConfigSchema,): { valid: boolean; errors: string[] } { const errors: string[] = []
// Check each field in the schema for (const [key, fieldSchema] of Object.entries(schema)) { const value = values[key]
// Check required fields if (fieldSchema.required && (value === undefined || value === '')) { errors.push(`${fieldSchema.title || key} is required but not provided`) continue }
// Skip validation for optional fields that aren't provided if (value === undefined || value === '') { continue }
// Type validation if (fieldSchema.type === 'string') { if (Array.isArray(value)) { // String arrays are allowed if multiple: true if (!fieldSchema.multiple) { errors.push( `${fieldSchema.title || key} must be a string, not an array`, ) } else if (!value.every(v => typeof v === 'string')) { errors.push(`${fieldSchema.title || key} must be an array of strings`) } } else if (typeof value !== 'string') { errors.push(`${fieldSchema.title || key} must be a string`) } } else if (fieldSchema.type === 'number' && typeof value !== 'number') { errors.push(`${fieldSchema.title || key} must be a number`) } else if (fieldSchema.type === 'boolean' && typeof value !== 'boolean') { errors.push(`${fieldSchema.title || key} must be a boolean`) } else if ( (fieldSchema.type === 'file' || fieldSchema.type === 'directory') && typeof value !== 'string' ) { errors.push(`${fieldSchema.title || key} must be a path string`) }
// Number range validation if (fieldSchema.type === 'number' && typeof value === 'number') { if (fieldSchema.min !== undefined && value < fieldSchema.min) { errors.push( `${fieldSchema.title || key} must be at least ${fieldSchema.min}`, ) } if (fieldSchema.max !== undefined && value > fieldSchema.max) { errors.push( `${fieldSchema.title || key} must be at most ${fieldSchema.max}`, ) } } }
return { valid: errors.length === 0, errors }}
/** * Generate MCP server configuration from DXT manifest */async function generateMcpConfig( manifest: McpbManifest, extractedPath: string, userConfig: UserConfigValues = {},): Promise<McpServerConfig> { // Lazy import: @anthropic-ai/mcpb barrel pulls in zod v3 schemas (~700KB of // bound closures). See dxt/helpers.ts for details. const { getMcpConfigForManifest } = await import('@anthropic-ai/mcpb') const mcpConfig = await getMcpConfigForManifest({ manifest, extensionPath: extractedPath, systemDirs: getSystemDirectories(), userConfig, pathSeparator: '/', })
if (!mcpConfig) { const error = new Error( `Failed to generate MCP server configuration from manifest "${manifest.name}"`, ) logError(error) throw error }
return mcpConfig as McpServerConfig}
/** * Load cache metadata for an MCPB source */async function loadCacheMetadata( cacheDir: string, source: string,): Promise<McpbCacheMetadata | null> { const fs = getFsImplementation() const metadataPath = getMetadataPath(cacheDir, source)
try { const content = await fs.readFile(metadataPath, { encoding: 'utf-8' }) return jsonParse(content) as McpbCacheMetadata } catch (error) { const code = getErrnoCode(error) if (code === 'ENOENT') return null const errorObj = toError(error) logError(errorObj) logForDebugging(`Failed to load MCPB cache metadata: ${error}`, { level: 'error', }) return null }}
/** * Save cache metadata for an MCPB source */async function saveCacheMetadata( cacheDir: string, source: string, metadata: McpbCacheMetadata,): Promise<void> { const metadataPath = getMetadataPath(cacheDir, source)
await getFsImplementation().mkdir(cacheDir) await writeFile(metadataPath, jsonStringify(metadata, null, 2), 'utf-8')}
/** * Download MCPB file from URL */async function downloadMcpb( url: string, destPath: string, onProgress?: ProgressCallback,): Promise<Uint8Array> { logForDebugging(`Downloading MCPB from ${url}`) if (onProgress) { onProgress(`Downloading ${url}...`) }
const started = performance.now() let fetchTelemetryFired = false try { const response = await axios.get(url, { timeout: 120000, // 2 minute timeout responseType: 'arraybuffer', maxRedirects: 5, // Follow redirects (like curl -L) onDownloadProgress: progressEvent => { if (progressEvent.total && onProgress) { const percent = Math.round( (progressEvent.loaded / progressEvent.total) * 100, ) onProgress(`Downloading... ${percent}%`) } }, })
const data = new Uint8Array(response.data) // Fire telemetry before writeFile — the event measures the network // fetch, not disk I/O. A writeFile EACCES would otherwise match // classifyFetchError's /permission denied/ → misreport as auth. logPluginFetch('mcpb', url, 'success', performance.now() - started) fetchTelemetryFired = true
// Save to disk (binary data) await writeFile(destPath, Buffer.from(data))
logForDebugging(`Downloaded ${data.length} bytes to ${destPath}`) if (onProgress) { onProgress('Download complete') }
return data } catch (error) { if (!fetchTelemetryFired) { logPluginFetch( 'mcpb', url, 'failure', performance.now() - started, classifyFetchError(error), ) } const errorMsg = errorMessage(error) const fullError = new Error( `Failed to download MCPB file from ${url}: ${errorMsg}`, ) logError(fullError) throw fullError }}
/** * Extract MCPB file and write contents to extraction directory. * * @param modes - name→mode map from `parseZipModes`. MCPB bundles can ship * native MCP server binaries, so preserving the exec bit matters here. */async function extractMcpbContents( unzipped: Record<string, Uint8Array>, extractPath: string, modes: Record<string, number>, onProgress?: ProgressCallback,): Promise<void> { if (onProgress) { onProgress('Extracting files...') }
// Create extraction directory await getFsImplementation().mkdir(extractPath)
// Write all files. Filter directory entries from the count so progress // messages use the same denominator as filesWritten (which skips them). let filesWritten = 0 const entries = Object.entries(unzipped).filter(([k]) => !k.endsWith('/')) const totalFiles = entries.length
for (const [filePath, fileData] of entries) { // Directory entries (common in zip -r, Python zipfile, Java ZipOutputStream) // are filtered above — writeFile would create `bin/` as an empty regular // file, then mkdir for `bin/server` would fail with ENOTDIR. The // mkdir(dirname(fullPath)) below creates parent dirs implicitly.
const fullPath = join(extractPath, filePath) const dir = dirname(fullPath)
// Ensure directory exists (recursive handles already-existing) if (dir !== extractPath) { await getFsImplementation().mkdir(dir) }
// Determine if text or binary const isTextFile = filePath.endsWith('.json') || filePath.endsWith('.js') || filePath.endsWith('.ts') || filePath.endsWith('.txt') || filePath.endsWith('.md') || filePath.endsWith('.yml') || filePath.endsWith('.yaml')
if (isTextFile) { const content = new TextDecoder().decode(fileData) await writeFile(fullPath, content, 'utf-8') } else { await writeFile(fullPath, Buffer.from(fileData)) }
const mode = modes[filePath] if (mode && mode & 0o111) { // Swallow EPERM/ENOTSUP (NFS root_squash, some FUSE mounts) — losing +x // is the pre-PR behavior and better than aborting mid-extraction. await chmod(fullPath, mode & 0o777).catch(() => {}) }
filesWritten++ if (onProgress && filesWritten % 10 === 0) { onProgress(`Extracted ${filesWritten}/${totalFiles} files`) } }
logForDebugging(`Extracted ${filesWritten} files to ${extractPath}`) if (onProgress) { onProgress(`Extraction complete (${filesWritten} files)`) }}
/** * Check if an MCPB source has changed and needs re-extraction */export async function checkMcpbChanged( source: string, pluginPath: string,): Promise<boolean> { const fs = getFsImplementation() const cacheDir = getMcpbCacheDir(pluginPath) const metadata = await loadCacheMetadata(cacheDir, source)
if (!metadata) { // No cache metadata, needs loading return true }
// Check if extraction directory still exists try { await fs.stat(metadata.extractedPath) } catch (error) { const code = getErrnoCode(error) if (code === 'ENOENT') { logForDebugging(`MCPB extraction path missing: ${metadata.extractedPath}`) } else { logForDebugging( `MCPB extraction path inaccessible: ${metadata.extractedPath}: ${error}`, { level: 'error' }, ) } return true }
// For local files, check mtime if (!isUrl(source)) { const localPath = join(pluginPath, source) let stats try { stats = await fs.stat(localPath) } catch (error) { const code = getErrnoCode(error) if (code === 'ENOENT') { logForDebugging(`MCPB source file missing: ${localPath}`) } else { logForDebugging( `MCPB source file inaccessible: ${localPath}: ${error}`, { level: 'error' }, ) } return true }
const cachedTime = new Date(metadata.cachedAt).getTime() // Floor to match the ms precision of cachedAt (ISO string). Sub-ms // precision on mtimeMs would make a freshly-cached file appear "newer" // than its own cache timestamp when both happen in the same millisecond. const fileTime = Math.floor(stats.mtimeMs)
if (fileTime > cachedTime) { logForDebugging( `MCPB file modified: ${new Date(fileTime)} > ${new Date(cachedTime)}`, ) return true } }
// For URLs, we'll re-check on explicit update (handled elsewhere) return false}
/** * Load and extract an MCPB file, with caching and user configuration support * * @param source - MCPB file path or URL * @param pluginPath - Plugin directory path * @param pluginId - Plugin identifier in "plugin@marketplace" format (for config storage) * @param onProgress - Progress callback * @param providedUserConfig - User configuration values (for initial setup or reconfiguration) * @returns Success with MCP config, or needs-config status with schema */export async function loadMcpbFile( source: string, pluginPath: string, pluginId: string, onProgress?: ProgressCallback, providedUserConfig?: UserConfigValues, forceConfigDialog?: boolean,): Promise<McpbLoadResult | McpbNeedsConfigResult> { const fs = getFsImplementation() const cacheDir = getMcpbCacheDir(pluginPath) await fs.mkdir(cacheDir)
logForDebugging(`Loading MCPB from source: ${source}`)
// Check cache first const metadata = await loadCacheMetadata(cacheDir, source) if (metadata && !(await checkMcpbChanged(source, pluginPath))) { logForDebugging( `Using cached MCPB from ${metadata.extractedPath} (hash: ${metadata.contentHash})`, )
// Load manifest from cache const manifestPath = join(metadata.extractedPath, 'manifest.json') let manifestContent: string try { manifestContent = await fs.readFile(manifestPath, { encoding: 'utf-8' }) } catch (error) { if (isENOENT(error)) { const err = new Error(`Cached manifest not found: ${manifestPath}`) logError(err) throw err } throw error }
const manifestData = new TextEncoder().encode(manifestContent) const manifest = await parseAndValidateManifestFromBytes(manifestData)
// Check for user_config requirement if (manifest.user_config && Object.keys(manifest.user_config).length > 0) { // Server name from DXT manifest const serverName = manifest.name
// Try to load existing config from settings.json or use provided config const savedConfig = loadMcpServerUserConfig(pluginId, serverName) const userConfig = providedUserConfig || savedConfig || {}
// Validate we have all required fields const validation = validateUserConfig(userConfig, manifest.user_config)
// Return needs-config if: forced (reconfiguration) OR validation failed if (forceConfigDialog || !validation.valid) { return { status: 'needs-config', manifest, extractedPath: metadata.extractedPath, contentHash: metadata.contentHash, configSchema: manifest.user_config, existingConfig: savedConfig || {}, validationErrors: validation.valid ? [] : validation.errors, } }
// Save config if it was provided (first time or reconfiguration) if (providedUserConfig) { saveMcpServerUserConfig( pluginId, serverName, providedUserConfig, manifest.user_config ?? {}, ) }
// Generate MCP config WITH user config const mcpConfig = await generateMcpConfig( manifest, metadata.extractedPath, userConfig, )
return { manifest, mcpConfig, extractedPath: metadata.extractedPath, contentHash: metadata.contentHash, } }
// No user_config required - generate config without it const mcpConfig = await generateMcpConfig(manifest, metadata.extractedPath)
return { manifest, mcpConfig, extractedPath: metadata.extractedPath, contentHash: metadata.contentHash, } }
// Not cached or changed - need to download/load and extract let mcpbData: Uint8Array let mcpbFilePath: string
if (isUrl(source)) { // Download from URL const sourceHash = createHash('md5') .update(source) .digest('hex') .substring(0, 8) mcpbFilePath = join(cacheDir, `${sourceHash}.mcpb`) mcpbData = await downloadMcpb(source, mcpbFilePath, onProgress) } else { // Load from local path const localPath = join(pluginPath, source)
if (onProgress) { onProgress(`Loading ${source}...`) }
try { mcpbData = await fs.readFileBytes(localPath) mcpbFilePath = localPath } catch (error) { if (isENOENT(error)) { const err = new Error(`MCPB file not found: ${localPath}`) logError(err) throw err } throw error } }
// Generate content hash const contentHash = generateContentHash(mcpbData) logForDebugging(`MCPB content hash: ${contentHash}`)
// Extract ZIP if (onProgress) { onProgress('Extracting MCPB archive...') }
const unzipped = await unzipFile(Buffer.from(mcpbData)) // fflate doesn't surface external_attr — parse the central directory so // native MCP server binaries keep their exec bit after extraction. const modes = parseZipModes(mcpbData)
// Check for manifest.json const manifestData = unzipped['manifest.json'] if (!manifestData) { const error = new Error('No manifest.json found in MCPB file') logError(error) throw error }
// Parse and validate manifest const manifest = await parseAndValidateManifestFromBytes(manifestData) logForDebugging( `MCPB manifest: ${manifest.name} v${manifest.version} by ${manifest.author.name}`, )
// Check if manifest has server config if (!manifest.server) { const error = new Error( `MCPB manifest for "${manifest.name}" does not define a server configuration`, ) logError(error) throw error }
// Extract to cache directory const extractPath = join(cacheDir, contentHash) await extractMcpbContents(unzipped, extractPath, modes, onProgress)
// Check for user_config requirement if (manifest.user_config && Object.keys(manifest.user_config).length > 0) { // Server name from DXT manifest const serverName = manifest.name
// Try to load existing config from settings.json or use provided config const savedConfig = loadMcpServerUserConfig(pluginId, serverName) const userConfig = providedUserConfig || savedConfig || {}
// Validate we have all required fields const validation = validateUserConfig(userConfig, manifest.user_config)
if (!validation.valid) { // Save cache metadata even though config is incomplete const newMetadata: McpbCacheMetadata = { source, contentHash, extractedPath: extractPath, cachedAt: new Date().toISOString(), lastChecked: new Date().toISOString(), } await saveCacheMetadata(cacheDir, source, newMetadata)
// Return "needs configuration" status return { status: 'needs-config', manifest, extractedPath: extractPath, contentHash, configSchema: manifest.user_config, existingConfig: savedConfig || {}, validationErrors: validation.errors, } }
// Save config if it was provided (first time or reconfiguration) if (providedUserConfig) { saveMcpServerUserConfig( pluginId, serverName, providedUserConfig, manifest.user_config ?? {}, ) }
// Generate MCP config WITH user config if (onProgress) { onProgress('Generating MCP server configuration...') }
const mcpConfig = await generateMcpConfig(manifest, extractPath, userConfig)
// Save cache metadata const newMetadata: McpbCacheMetadata = { source, contentHash, extractedPath: extractPath, cachedAt: new Date().toISOString(), lastChecked: new Date().toISOString(), } await saveCacheMetadata(cacheDir, source, newMetadata)
return { manifest, mcpConfig, extractedPath: extractPath, contentHash, } }
// No user_config required - generate config without it if (onProgress) { onProgress('Generating MCP server configuration...') }
const mcpConfig = await generateMcpConfig(manifest, extractPath)
// Save cache metadata const newMetadata: McpbCacheMetadata = { source, contentHash, extractedPath: extractPath, cachedAt: new Date().toISOString(), lastChecked: new Date().toISOString(), } await saveCacheMetadata(cacheDir, source, newMetadata)
logForDebugging( `Successfully loaded MCPB: ${manifest.name} (extracted to ${extractPath})`, )
return { manifest, mcpConfig: mcpConfig as McpServerConfig, extractedPath: extractPath, contentHash, }}