Download src/utils/plugins/mcpbHandler.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 31.3 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/utils/plugins/mcpbHandler.ts
- Command line
-
hf download hf://chenbhao/codev/src/utils/plugins/mcpbHandler.ts
-
curl -L -o mcpbHandler.ts https://huggingface.co/chenbhao/codev/resolve/main/src/utils/plugins/mcpbHandler.ts
31.3 kB
| import 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, | |
| } | |
| } | |