mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
The settings page could create project-scoped MCP servers for an explicit target path, but subsequent reloads only fetched the active session cwd. That made newly-added local or project MCP entries disappear when the target project differed from the active session or was only represented in the user config. Load MCP settings from the active project, recent projects, and project keys that already contain user-private MCP config. Project-scoped rows now render their project path and use project-aware identity keys so same-name servers in different projects stay distinct. Constraint: MCP local scope is keyed by project path in the user config, while project scope still resolves through the request cwd Rejected: Filesystem scan for every possible .mcp.json | unbounded and too expensive for settings load Confidence: high Scope-risk: moderate Directive: Do not collapse settings MCP fetches back to a single cwd; project/local scopes are cwd-sensitive Tested: bun test src/server/__tests__/mcp.test.ts Tested: cd desktop && bun run test -- mcpSettings.test.tsx Tested: bun run check:server Tested: bun run check:desktop Tested: git diff --check Not-tested: Discovery of project-shared .mcp.json files in directories that are neither recent projects nor active workdirs
678 lines
20 KiB
TypeScript
678 lines
20 KiB
TypeScript
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, getGlobalConfig } from '../../utils/config.js'
|
|
import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'
|
|
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
|
import { conversationService } from '../services/conversationService.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
|
|
previousCwd?: string
|
|
scope?: string
|
|
sessionId?: string
|
|
config?: unknown
|
|
}
|
|
|
|
type McpSessionSyncDto = {
|
|
applied: boolean
|
|
reason?: 'not_running' | 'failed'
|
|
error?: string
|
|
}
|
|
|
|
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 optionalString(value: unknown): string | undefined {
|
|
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
}
|
|
|
|
async function syncMcpToggleToSession(
|
|
sessionId: string | undefined,
|
|
serverName: string,
|
|
enabled: boolean,
|
|
): Promise<McpSessionSyncDto | undefined> {
|
|
if (!sessionId) return undefined
|
|
if (!conversationService.hasSession(sessionId)) {
|
|
return { applied: false, reason: 'not_running' }
|
|
}
|
|
|
|
try {
|
|
await conversationService.requestControl(
|
|
sessionId,
|
|
{ subtype: 'mcp_toggle', serverName, enabled },
|
|
120_000,
|
|
)
|
|
return { applied: true }
|
|
} catch (error) {
|
|
return {
|
|
applied: false,
|
|
reason: 'failed',
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}
|
|
}
|
|
}
|
|
|
|
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)),
|
|
})
|
|
}
|
|
|
|
function listProjectPathsWithPrivateMcp(): Response {
|
|
const projects = getGlobalConfig().projects ?? {}
|
|
const projectPaths = Object.entries(projects)
|
|
.filter(([, projectConfig]) => Object.keys(projectConfig.mcpServers ?? {}).length > 0)
|
|
.map(([projectPath]) => projectPath)
|
|
.sort((a, b) => a.localeCompare(b))
|
|
|
|
return Response.json({ projectPaths })
|
|
}
|
|
|
|
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 targetCwd = getCwd()
|
|
const previousCwd = optionalString(body.previousCwd)
|
|
const previousLookupCwd = previousCwd ?? targetCwd
|
|
const existing = runWithCwdOverride(previousLookupCwd, () => 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 runWithCwdOverride(previousLookupCwd, () => removeMcpConfig(name, previousScope))
|
|
await addMcpConfig(name, nextConfig, nextScope)
|
|
} catch (error) {
|
|
try {
|
|
const restored = runWithCwdOverride(previousLookupCwd, () => getMcpConfigByName(name))
|
|
if (!restored) {
|
|
await runWithCwdOverride(previousLookupCwd, () => 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, sessionId?: 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)
|
|
const sessionSync = await syncMcpToggleToSession(sessionId, name, enabled)
|
|
|
|
if (!enabled) {
|
|
await clearServerCache(name, existing).catch(() => {})
|
|
const updated = serializeServerSnapshot(name, existing)
|
|
return Response.json({ server: updated, ...(sessionSync ? { sessionSync } : {}) })
|
|
}
|
|
|
|
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 } : {}),
|
|
},
|
|
...(sessionSync ? { sessionSync } : {}),
|
|
})
|
|
}
|
|
|
|
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 === 'project-paths' && !action) {
|
|
return listProjectPathsWithPrivateMcp()
|
|
}
|
|
|
|
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, optionalString(body?.sessionId))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|