/** * McpInstallService — installs/uninstalls MCP servers via Claude CLI. * * Security model: renderer sends ONLY registryId + user inputs (env values, * headers, server name). Main re-fetches server spec from registry via getById() * and builds CLI args from the fresh registry data (never trusts install spec * from renderer). */ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli } from '@main/utils/childProcess'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { createLogger } from '@shared/utils/logger'; import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import path from 'path'; import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator'; import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; import type { McpCustomInstallRequest, McpInstallRequest, OperationResult, } from '@shared/types/extensions'; const logger = createLogger('Extensions:McpInstall'); /** Validate server name: alphanumeric, dashes, underscores, dots */ const SERVER_NAME_RE = /^[\w.-]{1,100}$/; /** Allowed scope values (prevent command injection) */ const VALID_SCOPES = new Set(['local', 'user', 'project', 'global']); /** Env var key must be safe shell identifier */ const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i; /** HTTP header key must be safe (RFC 7230 token) */ const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/; const TIMEOUT_MS = 30_000; function scopeRequiresProjectPath(scope?: string): boolean { return isProjectScopedMcpScope(scope); } export class McpInstallService { constructor( private readonly aggregator: McpCatalogAggregator, private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() ) {} async install(request: McpInstallRequest): Promise { const { registryId, serverName, scope, projectPath, envValues, headers } = request; // 1. Validate server name if (!SERVER_NAME_RE.test(serverName)) { return { state: 'error', error: `Invalid server name: "${serverName}". Use alphanumeric, dashes, underscores, dots.`, }; } // 2. Validate scope if (scope && !VALID_SCOPES.has(scope)) { return { state: 'error', error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`, }; } if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', error: `projectPath is required for ${scope} scope`, }; } // 3. Validate env var keys (prevent command injection) for (const key of Object.keys(envValues)) { if (!ENV_KEY_RE.test(key)) { return { state: 'error', error: `Invalid environment variable name: "${key}". Use uppercase alphanumeric and underscores.`, }; } } // 4. Validate header keys (prevent header injection) for (const header of headers) { if (header.key && !HEADER_KEY_RE.test(header.key)) { return { state: 'error', error: `Invalid header name: "${header.key}". Use alphanumeric, dashes, underscores.`, }; } } // 5. Validate projectPath (if provided, must be absolute) if (projectPath && !path.isAbsolute(projectPath)) { return { state: 'error', error: 'projectPath must be an absolute path', }; } // 6. Re-fetch from registry (don't trust renderer-provided install spec) const server = await this.aggregator.getById(registryId); if (!server) { return { state: 'error', error: `MCP server "${registryId}" not found in registry`, }; } if (!server.installSpec) { return { state: 'error', error: `MCP server "${server.name}" does not have an automatic install spec. Manual setup required.`, }; } // 7. Build CLI args based on install spec type const args: string[] = ['mcp', 'add']; // Scope flag (-s) if (scope && scope !== 'local') { args.push('-s', scope); } if (server.installSpec.type === 'stdio') { // Stdio: claude mcp add [-s scope] [-e KEY=val...] -- npx -y [@version] // Add env flags for (const [key, value] of Object.entries(envValues)) { if (key && value) { args.push('-e', `${key}=${value}`); } } args.push(serverName); args.push('--'); args.push('npx', '-y'); const pkg = server.installSpec.npmVersion ? `${server.installSpec.npmPackage}@${server.installSpec.npmVersion}` : server.installSpec.npmPackage; args.push(pkg); } else if (server.installSpec.type === 'http') { // HTTP/SSE: claude mcp add [-s scope] -t [-H "Key: val"...] const transport = server.installSpec.transportType === 'sse' ? 'sse' : 'http'; args.push('-t', transport); // Add header flags for (const header of headers) { if (header.key && header.value) { args.push('-H', `${header.key}: ${header.value}`); } } // Add env flags (some HTTP servers also need env vars) for (const [key, value] of Object.entries(envValues)) { if (key && value) { args.push('-e', `${key}=${value}`); } } args.push(serverName); args.push(server.installSpec.url); } else { return { state: 'error', error: `Unsupported install spec type: ${(server.installSpec as { type: string }).type}`, }; } logger.info( `Installing MCP server: ${serverName} (type: ${server.installSpec.type}, scope: ${scope ?? 'local'})` ); // Don't log env values or header values (may contain secrets) try { const claudeBinary = await ClaudeBinaryResolver.resolve(); if (!claudeBinary) { return { state: 'error', error: CLI_NOT_FOUND_MESSAGE, }; } const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); const { stderr } = await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, env, }); if (stderr) { logger.warn(`MCP install stderr: ${stderr.slice(0, 200)}`); } return { state: 'success' }; } catch (err) { const message = err instanceof Error ? err.message : String(err); // Mask potential secrets in error output const safeMessage = maskSecrets( message, envValues, headers.map((h) => h.value) ); logger.error(`MCP install failed: ${safeMessage}`); return { state: 'error', error: safeMessage }; } } /** * Install a custom MCP server — user provides installSpec directly (bypasses registry). */ async installCustom(request: McpCustomInstallRequest): Promise { const { serverName, scope, projectPath, installSpec, envValues, headers } = request; // Validate inputs (same rules as registry install) if (!SERVER_NAME_RE.test(serverName)) { return { state: 'error', error: `Invalid server name: "${serverName}". Use alphanumeric, dashes, underscores, dots.`, }; } if (scope && !VALID_SCOPES.has(scope)) { return { state: 'error', error: `Invalid scope: "${scope}".` }; } if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', error: `projectPath is required for ${scope} scope` }; } for (const key of Object.keys(envValues)) { if (!ENV_KEY_RE.test(key)) { return { state: 'error', error: `Invalid env var name: "${key}".` }; } } for (const header of headers) { if (header.key && !HEADER_KEY_RE.test(header.key)) { return { state: 'error', error: `Invalid header name: "${header.key}".` }; } } if (projectPath && !path.isAbsolute(projectPath)) { return { state: 'error', error: 'projectPath must be an absolute path' }; } // Build CLI args from provided installSpec const args: string[] = ['mcp', 'add']; if (scope && scope !== 'local') { args.push('-s', scope); } if (installSpec.type === 'stdio') { for (const [key, value] of Object.entries(envValues)) { if (key && value) args.push('-e', `${key}=${value}`); } args.push(serverName); args.push('--'); args.push('npx', '-y'); const pkg = installSpec.npmVersion ? `${installSpec.npmPackage}@${installSpec.npmVersion}` : installSpec.npmPackage; args.push(pkg); } else if (installSpec.type === 'http') { const transport = installSpec.transportType === 'sse' ? 'sse' : 'http'; args.push('-t', transport); for (const header of headers) { if (header.key && header.value) { args.push('-H', `${header.key}: ${header.value}`); } } for (const [key, value] of Object.entries(envValues)) { if (key && value) args.push('-e', `${key}=${value}`); } args.push(serverName); args.push(installSpec.url); } else { return { state: 'error', error: 'Unsupported install spec type' }; } logger.info( `Installing custom MCP server: ${serverName} (type: ${installSpec.type}, scope: ${scope ?? 'local'})` ); try { const claudeBinary = await ClaudeBinaryResolver.resolve(); if (!claudeBinary) { return { state: 'error', error: CLI_NOT_FOUND_MESSAGE, }; } const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); const { stderr } = await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, env, }); if (stderr) { logger.warn(`Custom MCP install stderr: ${stderr.slice(0, 200)}`); } return { state: 'success' }; } catch (err) { const message = err instanceof Error ? err.message : String(err); const safeMessage = maskSecrets( message, envValues, headers.map((h) => h.value) ); logger.error(`Custom MCP install failed: ${safeMessage}`); return { state: 'error', error: safeMessage }; } } async uninstall(name: string, scope?: string, projectPath?: string): Promise { if (!SERVER_NAME_RE.test(name)) { return { state: 'error', error: `Invalid server name: "${name}"`, }; } if (scope && !VALID_SCOPES.has(scope)) { return { state: 'error', error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`, }; } if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', error: `projectPath is required for ${scope} scope`, }; } if (projectPath && !path.isAbsolute(projectPath)) { return { state: 'error', error: 'projectPath must be an absolute path', }; } const args = ['mcp', 'remove']; if (scope && scope !== 'local') { args.push('-s', scope); } args.push(name); logger.info(`Removing MCP server: ${name} (scope: ${scope ?? 'local'})`); try { const claudeBinary = await ClaudeBinaryResolver.resolve(); if (!claudeBinary) { return { state: 'error', error: CLI_NOT_FOUND_MESSAGE, }; } const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, env, }); return { state: 'success' }; } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error(`MCP uninstall failed: ${message}`); return { state: 'error', error: message }; } } } /** Replace secret values in error messages with [REDACTED] */ function maskSecrets( message: string, envValues: Record, headerValues: string[] ): string { let result = message; const secrets = [ ...Object.values(envValues).filter((v) => v.length > 3), ...headerValues.filter((v) => v.length > 3), ]; for (const secret of secrets) { result = result.replaceAll(secret, '[REDACTED]'); } return result; }