mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
The desktop Settings flow needed a real shell for bundled CLI setup, but the restart path could hang behind old PTY teardown. Wire an xterm.js panel to a portable-pty backend, inject the bundled CLI into the shell bootstrap, and switch to the new session before cleaning up the previous one so restart work stays off the frontend critical path. Constraint: The desktop app must ship its own CLI entrypoint instead of depending on a global Claude install Constraint: PTY teardown must not block the Tauri invoke path Rejected: Reuse the install chat for arbitrary shell commands | it does not provide a real interactive PTY Rejected: Close the old PTY before adopting the new session | it keeps restart vulnerable to hung child shutdown Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep PTY teardown off the invoke path and preserve session handoff ordering when changing terminal lifecycle code Tested: bunx vitest run src/__tests__/terminalPanel.test.tsx src/components/settings/TerminalPanel.restart.test.tsx; bun run lint; cargo check Not-tested: End-to-end command echo inside the packaged desktop terminal still needs follow-up runtime verification
619 lines
18 KiB
TypeScript
619 lines
18 KiB
TypeScript
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<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 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<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 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<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)
|
|
}
|
|
}
|