import { once } from 'node:events'; import path from 'node:path'; import spawn from 'cross-spawn'; import type { IProviderMcp } from '@/shared/interfaces.js'; import type { LLMProvider, McpScope, McpTransport, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; import { AppError } from '@/shared/utils.js'; const resolveWorkspacePath = (workspacePath?: string): string => path.resolve(workspacePath ?? process.cwd()); const normalizeServerName = (name: string): string => { const normalized = name.trim(); if (!normalized) { throw new AppError('MCP server name is required.', { code: 'MCP_SERVER_NAME_REQUIRED', statusCode: 400, }); } return normalized; }; const runStdioServerProbe = async ( server: ProviderMcpServer, workspacePath: string, ): Promise<{ reachable: boolean; error?: string }> => { if (!server.command) { return { reachable: false, error: 'Missing stdio command.' }; } try { const child = spawn(server.command, server.args ?? [], { cwd: server.cwd ? path.resolve(workspacePath, server.cwd) : workspacePath, env: { ...process.env, ...(server.env ?? {}), }, stdio: ['ignore', 'pipe', 'pipe'], }); const timeout = setTimeout(() => { if (!child.killed && child.exitCode === null) { child.kill('SIGTERM'); } }, 1_500); const errorPromise = once(child, 'error').then(([error]) => { throw error; }); const closePromise = once(child, 'close'); await Promise.race([closePromise, errorPromise]); clearTimeout(timeout); if (typeof child.exitCode === 'number' && child.exitCode !== 0) { return { reachable: false, error: `Process exited with code ${child.exitCode}.`, }; } return { reachable: true }; } catch (error) { return { reachable: false, error: error instanceof Error ? error.message : 'Failed to start stdio process', }; } }; const runHttpServerProbe = async ( url: string, ): Promise<{ reachable: boolean; statusCode?: number; error?: string }> => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 3_000); try { const response = await fetch(url, { method: 'GET', signal: controller.signal }); clearTimeout(timeout); return { reachable: true, statusCode: response.status, }; } catch (error) { clearTimeout(timeout); return { reachable: false, error: error instanceof Error ? error.message : 'Network probe failed', }; } }; /** * Shared MCP provider for provider-specific config readers/writers. */ export abstract class McpProvider implements IProviderMcp { protected readonly provider: LLMProvider; protected readonly supportedScopes: McpScope[]; protected readonly supportedTransports: McpTransport[]; protected constructor( provider: LLMProvider, supportedScopes: McpScope[], supportedTransports: McpTransport[], ) { this.provider = provider; this.supportedScopes = supportedScopes; this.supportedTransports = supportedTransports; } async listServers(options?: { workspacePath?: string }): Promise> { const grouped: Record = { user: [], local: [], project: [], }; for (const scope of this.supportedScopes) { grouped[scope] = await this.listServersForScope(scope, options); } return grouped; } async listServersForScope( scope: McpScope, options?: { workspacePath?: string }, ): Promise { if (!this.supportedScopes.includes(scope)) { return []; } const workspacePath = resolveWorkspacePath(options?.workspacePath); const scopedServers = await this.readScopedServers(scope, workspacePath); return Object.entries(scopedServers) .map(([name, rawConfig]) => this.normalizeServerConfig(scope, name, rawConfig)) .filter((entry): entry is ProviderMcpServer => entry !== null); } async upsertServer(input: UpsertProviderMcpServerInput): Promise { const scope = input.scope ?? 'project'; this.assertScopeAndTransport(scope, input.transport); const workspacePath = resolveWorkspacePath(input.workspacePath); const normalizedName = normalizeServerName(input.name); const scopedServers = await this.readScopedServers(scope, workspacePath); scopedServers[normalizedName] = this.buildServerConfig(input); await this.writeScopedServers(scope, workspacePath, scopedServers); return { provider: this.provider, name: normalizedName, scope, transport: input.transport, command: input.command, args: input.args, env: input.env, cwd: input.cwd, url: input.url, headers: input.headers, envVars: input.envVars, bearerTokenEnvVar: input.bearerTokenEnvVar, envHttpHeaders: input.envHttpHeaders, }; } async removeServer( input: { name: string; scope?: McpScope; workspacePath?: string }, ): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> { const scope = input.scope ?? 'project'; this.assertScope(scope); const workspacePath = resolveWorkspacePath(input.workspacePath); const normalizedName = normalizeServerName(input.name); const scopedServers = await this.readScopedServers(scope, workspacePath); const removed = Object.prototype.hasOwnProperty.call(scopedServers, normalizedName); if (removed) { delete scopedServers[normalizedName]; await this.writeScopedServers(scope, workspacePath, scopedServers); } return { removed, provider: this.provider, name: normalizedName, scope }; } async runServer( input: { name: string; scope?: McpScope; workspacePath?: string }, ): Promise<{ provider: LLMProvider; name: string; scope: McpScope; transport: McpTransport; reachable: boolean; statusCode?: number; error?: string; }> { const scope = input.scope ?? 'project'; this.assertScope(scope); const workspacePath = resolveWorkspacePath(input.workspacePath); const normalizedName = normalizeServerName(input.name); const scopedServers = await this.readScopedServers(scope, workspacePath); const rawConfig = scopedServers[normalizedName]; if (!rawConfig || typeof rawConfig !== 'object') { throw new AppError(`MCP server "${normalizedName}" was not found.`, { code: 'MCP_SERVER_NOT_FOUND', statusCode: 404, }); } const normalized = this.normalizeServerConfig(scope, normalizedName, rawConfig); if (!normalized) { throw new AppError(`MCP server "${normalizedName}" has an invalid configuration.`, { code: 'MCP_SERVER_INVALID_CONFIG', statusCode: 400, }); } if (normalized.transport === 'stdio') { const result = await runStdioServerProbe(normalized, workspacePath); return { provider: this.provider, name: normalizedName, scope, transport: normalized.transport, reachable: result.reachable, error: result.error, }; } const result = await runHttpServerProbe(normalized.url ?? ''); return { provider: this.provider, name: normalizedName, scope, transport: normalized.transport, reachable: result.reachable, statusCode: result.statusCode, error: result.error, }; } protected abstract readScopedServers( scope: McpScope, workspacePath: string, ): Promise>; protected abstract writeScopedServers( scope: McpScope, workspacePath: string, servers: Record, ): Promise; protected abstract buildServerConfig(input: UpsertProviderMcpServerInput): Record; protected abstract normalizeServerConfig( scope: McpScope, name: string, rawConfig: unknown, ): ProviderMcpServer | null; protected assertScope(scope: McpScope): void { if (!this.supportedScopes.includes(scope)) { throw new AppError(`Provider "${this.provider}" does not support "${scope}" MCP scope.`, { code: 'MCP_SCOPE_NOT_SUPPORTED', statusCode: 400, }); } } protected assertScopeAndTransport(scope: McpScope, transport: McpTransport): void { this.assertScope(scope); if (!this.supportedTransports.includes(transport)) { throw new AppError(`Provider "${this.provider}" does not support "${transport}" MCP transport.`, { code: 'MCP_TRANSPORT_NOT_SUPPORTED', statusCode: 400, }); } } }