import { clearMcpClientConfig, clearServerTokensFromLocalStorage, } from '../../services/mcp/auth.js' import { clearServerCache, connectToServer, reconnectMcpServerImpl, } from '../../services/mcp/client.js' import { addMcpConfig, getClaudeCodeMcpConfigs, getMcpConfigByName, isMcpServerDisabled, removeMcpConfig, setMcpServerEnabled, } from '../../services/mcp/config.js' import { inspectMcpHostCommand } from '../services/mcpHostPreflight.js' import type { ConfigScope, McpHTTPServerConfig, McpSSEServerConfig, McpServerConfig, McpStdioServerConfig, ScopedMcpServerConfig, } from '../../services/mcp/types.js' import { describeMcpConfigFilePath, ensureConfigScope } from '../../services/mcp/utils.js' import { enableConfigs } from '../../utils/config.js' import { getCwd, runWithCwdOverride } from '../../utils/cwd.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' type McpEditableConfigDto = | { type: 'stdio' command: string args: string[] env: Record } | { type: 'http' | 'sse' url: string headers: Record headersHelper?: string oauth?: { clientId?: string callbackPort?: number } } | { type: string } type McpServerDto = { name: string scope: string transport: string enabled: boolean status: 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking' statusLabel: string statusDetail?: string configLocation: string summary: string canEdit: boolean canRemove: boolean canReconnect: boolean canToggle: boolean config: McpEditableConfigDto } type McpMutationBody = { cwd?: string scope?: string config?: unknown } const EDITABLE_SCOPES = new Set(['local', 'project', 'user']) function parseJsonBody(req: Request): Promise> { return req .json() .then((body) => body as Record) .catch(() => { throw ApiError.badRequest('Invalid JSON body') }) } function resolveRequestCwd(url: URL, body?: Record): string { const cwd = url.searchParams.get('cwd') || (typeof body?.cwd === 'string' ? body.cwd : undefined) return cwd || getCwd() } function stripScope(config: ScopedMcpServerConfig): McpServerConfig { const { scope: _scope, pluginSource: _pluginSource, ...rest } = config return rest } function isVisibleServer(name: string, config: ScopedMcpServerConfig): boolean { if (name === 'ide') return false if (config.type === 'sse-ide' || config.type === 'ws-ide') return false return true } function serializeEditableConfig(config: ScopedMcpServerConfig): McpEditableConfigDto { if (!config.type || config.type === 'stdio') { const stdioConfig = config as McpStdioServerConfig return { type: 'stdio', command: stdioConfig.command, args: Array.isArray(stdioConfig.args) ? stdioConfig.args : [], env: stdioConfig.env ?? {}, } } if (config.type === 'http' || config.type === 'sse') { const remoteConfig = config as McpHTTPServerConfig | McpSSEServerConfig return { type: config.type, url: remoteConfig.url, headers: remoteConfig.headers ?? {}, headersHelper: remoteConfig.headersHelper, oauth: remoteConfig.oauth ? { clientId: remoteConfig.oauth.clientId, callbackPort: remoteConfig.oauth.callbackPort, } : undefined, } } return { type: config.type } } function getSummary(config: ScopedMcpServerConfig): string { if (!config.type || config.type === 'stdio') { const stdioConfig = config as McpStdioServerConfig return [stdioConfig.command, ...(stdioConfig.args ?? [])].join(' ').trim() } if ('url' in config && typeof config.url === 'string') { return config.url } return config.type } function getStatusLabel(status: McpServerDto['status']): string { switch (status) { case 'connected': return 'Connected' case 'needs-auth': return 'Needs auth' case 'failed': return 'Unavailable' case 'disabled': return 'Disabled' case 'checking': return 'Checking' default: return status } } function getInitialStatus( enabled: boolean, ): Pick { if (!enabled) { return { status: 'disabled', statusLabel: getStatusLabel('disabled'), statusDetail: 'Server disabled for the current project', } } return { status: 'checking', statusLabel: getStatusLabel('checking'), } } async function getHostPreflightStatus( config: ScopedMcpServerConfig | McpServerConfig, enabled: boolean, ): Promise | null> { if (!enabled) { return null } if ((config.type ?? 'stdio') !== 'stdio') { return null } const stdioConfig = config as McpStdioServerConfig const result = await inspectMcpHostCommand( stdioConfig.command, getCwd(), stdioConfig.env, ) if (result.ok) { return null } return { status: 'failed', statusLabel: getStatusLabel('failed'), statusDetail: result.message, } } async function inspectServerStatus( name: string, config: ScopedMcpServerConfig, enabled: boolean, ): Promise> { if (!enabled) { return { status: 'disabled', statusLabel: getStatusLabel('disabled'), statusDetail: 'Server disabled for the current project', } } const hostPreflightStatus = await getHostPreflightStatus(config, enabled) if (hostPreflightStatus) { return hostPreflightStatus } try { const client = await connectToServer(name, config) await clearServerCache(name, config).catch(() => {}) const status: McpServerDto['status'] = client.type === 'connected' ? 'connected' : client.type === 'needs-auth' ? 'needs-auth' : 'failed' return { status, statusLabel: getStatusLabel(status), statusDetail: 'error' in client ? client.error : undefined, } } catch (error) { await clearServerCache(name, config).catch(() => {}) return { status: 'failed', statusLabel: getStatusLabel('failed'), statusDetail: error instanceof Error ? error.message : String(error), } } } function buildServerDto( name: string, config: ScopedMcpServerConfig, status: Pick, ): McpServerDto { const enabled = !isMcpServerDisabled(name) const transport = config.type ?? 'stdio' const canEdit = EDITABLE_SCOPES.has(config.scope) && (transport === 'stdio' || transport === 'http' || transport === 'sse') return { name, scope: config.scope, transport, enabled: !isMcpServerDisabled(name), status: status.status, statusLabel: status.statusLabel, statusDetail: status.statusDetail, configLocation: describeMcpConfigFilePath(config.scope), summary: getSummary(config), canEdit, canRemove: EDITABLE_SCOPES.has(config.scope), canReconnect: enabled, canToggle: true, config: serializeEditableConfig(config), } } function serializeServerSnapshot( name: string, config: ScopedMcpServerConfig, ): McpServerDto { return buildServerDto(name, config, getInitialStatus(!isMcpServerDisabled(name))) } async function serializeServerWithLiveStatus( name: string, config: ScopedMcpServerConfig, ): Promise { const enabled = !isMcpServerDisabled(name) const status = await inspectServerStatus(name, config, enabled) return buildServerDto(name, config, status) } async function resolveServerForRuntimeAction( name: string, ): Promise { const configured = getMcpConfigByName(name) if (configured) { return configured } const { servers } = await getClaudeCodeMcpConfigs() return servers[name] ?? null } function buildServerConfig(config: unknown): McpServerConfig { if (!config || typeof config !== 'object') { throw ApiError.badRequest('Missing or invalid "config" in request body') } const raw = config as Record const type = raw.type if (!type || type === 'stdio') { const command = typeof raw.command === 'string' ? raw.command.trim() : '' if (!command) { throw ApiError.badRequest('Command is required for stdio MCP servers') } const args = Array.isArray(raw.args) ? raw.args.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) : [] const envEntries = raw.env && typeof raw.env === 'object' ? Object.entries(raw.env as Record).filter( (entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string' && entry[0].trim().length > 0, ) : [] return { type: 'stdio', command, args, ...(envEntries.length > 0 ? { env: Object.fromEntries(envEntries) } : {}), } } if (type === 'http' || type === 'sse') { const url = typeof raw.url === 'string' ? raw.url.trim() : '' if (!url) { throw ApiError.badRequest('URL is required for remote MCP servers') } const headersEntries = raw.headers && typeof raw.headers === 'object' ? Object.entries(raw.headers as Record).filter( (entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string' && entry[0].trim().length > 0, ) : [] const oauthRaw = raw.oauth && typeof raw.oauth === 'object' ? (raw.oauth as Record) : undefined const clientId = typeof oauthRaw?.clientId === 'string' ? oauthRaw.clientId.trim() : '' const callbackPort = typeof oauthRaw?.callbackPort === 'number' ? oauthRaw.callbackPort : typeof oauthRaw?.callbackPort === 'string' && oauthRaw.callbackPort.trim() ? Number(oauthRaw.callbackPort) : undefined return { type, url, ...(headersEntries.length > 0 ? { headers: Object.fromEntries(headersEntries) } : {}), ...(typeof raw.headersHelper === 'string' && raw.headersHelper.trim() ? { headersHelper: raw.headersHelper.trim() } : {}), ...(clientId || callbackPort ? { oauth: { ...(clientId ? { clientId } : {}), ...(callbackPort ? { callbackPort } : {}), }, } : {}), } } throw ApiError.badRequest(`Unsupported MCP transport: ${String(type)}`) } function cleanupSecureStorage(name: string, config: ScopedMcpServerConfig) { if (config.type !== 'sse' && config.type !== 'http') return clearServerTokensFromLocalStorage(name, config) clearMcpClientConfig(name, config) } async function listServers(): Promise { const { servers } = await getClaudeCodeMcpConfigs() const visibleServers = Object.entries(servers) .filter(([name, config]) => isVisibleServer(name, config)) .sort((a, b) => a[0].localeCompare(b[0])) return Response.json({ servers: visibleServers.map(([name, config]) => serializeServerSnapshot(name, config)), }) } async function getServerStatus(name: string): Promise { const existing = await resolveServerForRuntimeAction(name) if (!existing) { throw ApiError.notFound(`MCP server not found: ${name}`) } return Response.json({ server: await serializeServerWithLiveStatus(name, existing), }) } async function assertHostPrerequisites(config: McpServerConfig) { const hostPreflightStatus = await getHostPreflightStatus(config, true) if (hostPreflightStatus?.statusDetail) { throw ApiError.badRequest(hostPreflightStatus.statusDetail) } } async function createServer(body: Record): Promise { const name = typeof body.name === 'string' ? body.name.trim() : '' if (!name) { throw ApiError.badRequest('Missing or invalid "name" in request body') } const scope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : undefined) const config = buildServerConfig(body.config) await assertHostPrerequisites(config) try { await addMcpConfig(name, config, scope) } catch (error) { const message = error instanceof Error ? error.message : String(error) if (message.includes('already exists')) { throw ApiError.conflict(message) } throw ApiError.badRequest(message) } const created = getMcpConfigByName(name) if (!created) { throw ApiError.internal(`Created MCP server "${name}" could not be reloaded`) } return Response.json({ server: serializeServerSnapshot(name, created) }, { status: 201 }) } async function updateServer(name: string, body: Record): Promise { const existing = getMcpConfigByName(name) if (!existing) { throw ApiError.notFound(`MCP server not found: ${name}`) } if (!EDITABLE_SCOPES.has(existing.scope)) { throw ApiError.badRequest(`MCP server "${name}" cannot be edited from scope "${existing.scope}"`) } const nextScope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : existing.scope) const nextConfig = buildServerConfig(body.config) await assertHostPrerequisites(nextConfig) const previousConfig = stripScope(existing) const previousScope = existing.scope try { await removeMcpConfig(name, previousScope) await addMcpConfig(name, nextConfig, nextScope) } catch (error) { try { const restored = getMcpConfigByName(name) if (!restored) { await addMcpConfig(name, previousConfig, previousScope) } } catch { // Preserve the original update error below. } const message = error instanceof Error ? error.message : String(error) if (message.includes('already exists')) { throw ApiError.conflict(message) } throw ApiError.badRequest(message) } const updated = getMcpConfigByName(name) if (!updated) { throw ApiError.internal(`Updated MCP server "${name}" could not be reloaded`) } return Response.json({ server: serializeServerSnapshot(name, updated) }) } async function deleteServer(name: string, url: URL): Promise { const scope = ensureConfigScope(url.searchParams.get('scope') || undefined) const existing = getMcpConfigByName(name) if (!existing) { throw ApiError.notFound(`MCP server not found: ${name}`) } await removeMcpConfig(name, scope) cleanupSecureStorage(name, existing) await clearServerCache(name, existing).catch(() => {}) return Response.json({ ok: true }) } async function toggleServer(name: string): Promise { const existing = await resolveServerForRuntimeAction(name) if (!existing) { throw ApiError.notFound(`MCP server not found: ${name}`) } const enabled = isMcpServerDisabled(name) setMcpServerEnabled(name, enabled) if (!enabled) { await clearServerCache(name, existing).catch(() => {}) const updated = serializeServerSnapshot(name, existing) return Response.json({ server: updated }) } const hostPreflightStatus = await getHostPreflightStatus(existing, true) if (hostPreflightStatus) { await clearServerCache(name, existing).catch(() => {}) return Response.json({ server: buildServerDto(name, existing, hostPreflightStatus), }) } const result = await reconnectMcpServerImpl(name, existing) await clearServerCache(name, existing).catch(() => {}) const updated = await serializeServerWithLiveStatus(name, existing) const statusDetail = result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined return Response.json({ server: { ...updated, ...(statusDetail ? { statusDetail } : {}), }, }) } async function reconnectServer(name: string): Promise { const existing = await resolveServerForRuntimeAction(name) if (!existing) { throw ApiError.notFound(`MCP server not found: ${name}`) } const hostPreflightStatus = await getHostPreflightStatus(existing, !isMcpServerDisabled(name)) if (hostPreflightStatus) { await clearServerCache(name, existing).catch(() => {}) return Response.json({ server: buildServerDto(name, existing, hostPreflightStatus), }) } const result = await reconnectMcpServerImpl(name, existing) await clearServerCache(name, existing).catch(() => {}) const server = await serializeServerWithLiveStatus(name, existing) const statusDetail = result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined return Response.json({ server: { ...server, ...(statusDetail ? { statusDetail } : {}), }, }) } export async function handleMcpApi( req: Request, url: URL, segments: string[], ): Promise { try { enableConfigs() const serverName = segments[2] ? decodeURIComponent(segments[2]) : undefined const action = segments[3] const body = req.method === 'POST' || req.method === 'PUT' ? await parseJsonBody(req) : undefined return await runWithCwdOverride(resolveRequestCwd(url, body), async () => { if (req.method === 'GET' && !serverName) { return listServers() } if (req.method === 'GET' && serverName && action === 'status') { return getServerStatus(serverName) } if (req.method === 'POST' && !serverName) { return createServer(body ?? {}) } if (req.method === 'PUT' && serverName) { return updateServer(serverName, body ?? {}) } if (req.method === 'DELETE' && serverName && !action) { return deleteServer(serverName, url) } if (req.method === 'POST' && serverName && action === 'toggle') { return toggleServer(serverName) } if (req.method === 'POST' && serverName && action === 'reconnect') { return reconnectServer(serverName) } throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED') }) } catch (error) { return errorResponse(error) } }