codev / src /server /api /mcp.ts
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame Contribute Delete
18.2 kB
import {
clearMcpClientConfig,
clearServerTokensFromLocalStorage,
} from '../../services/mcp/auth.js'
import {
clearServerCache,
connectToServer,
reconnectMcpServerImpl,
} from '../../services/mcp/client.js'
import {
addMcpConfig,
getAllMcpConfigs,
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<string, string>
}
| {
type: 'http' | 'sse'
url: string
headers: Record<string, string>
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<ConfigScope>(['local', 'project', 'user'])
function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
return req
.json()
.then((body) => body as Record<string, unknown>)
.catch(() => {
throw ApiError.badRequest('Invalid JSON body')
})
}
function resolveRequestCwd(url: URL, body?: Record<string, unknown>): 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<McpServerDto, 'status' | 'statusDetail' | 'statusLabel'> {
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<Pick<McpServerDto, 'status' | 'statusDetail' | 'statusLabel'> | 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<Pick<McpServerDto, 'status' | 'statusDetail' | 'statusLabel'>> {
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, 'status' | 'statusDetail' | 'statusLabel'>,
): 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<McpServerDto> {
const enabled = !isMcpServerDisabled(name)
const status = await inspectServerStatus(name, config, enabled)
return buildServerDto(name, config, status)
}
async function resolveServerForRuntimeAction(
name: string,
): Promise<ScopedMcpServerConfig | null> {
const configured = getMcpConfigByName(name)
if (configured) {
return configured
}
const { servers } = await getAllMcpConfigs()
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<string, unknown>
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<string, unknown>).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<string, unknown>).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<string, unknown>) : 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<Response> {
const { servers } = await getAllMcpConfigs()
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<Response> {
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<string, unknown>): Promise<Response> {
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<string, unknown>): Promise<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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)
}
}