406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
/**
|
|
* 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<OperationResult> {
|
|
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...] <name> -- npx -y <package>[@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 <transport> [-H "Key: val"...] <name> <url>
|
|
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<OperationResult> {
|
|
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<OperationResult> {
|
|
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<string, string>,
|
|
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;
|
|
}
|