From 467debcd8b09f29116ccdf48e6306833491bfd5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 22 Apr 2026 18:06:29 +0800 Subject: [PATCH] feat: add a desktop setup terminal without blocking shell restarts 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 --- desktop/src/api/client.ts | 14 +- desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + desktop/src/pages/McpSettings.tsx | 3 + src/server/__tests__/mcp.test.ts | 119 +++++++++++++++++ src/server/api/mcp.ts | 60 +++++++++ src/server/services/mcpHostPreflight.ts | 166 ++++++++++++++++++++++++ 7 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 src/server/services/mcpHostPreflight.ts diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index 78468751..3e6ba7fb 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -9,6 +9,18 @@ const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456' let baseUrl = DEFAULT_BASE_URL +function getErrorMessage(status: number, body: unknown) { + if (body && typeof body === 'object' && 'message' in body && typeof body.message === 'string') { + return body.message + } + + if (typeof body === 'string' && body.trim().length > 0) { + return body + } + + return `API error ${status}` +} + export function setBaseUrl(url: string) { baseUrl = url.replace(/\/$/, '') } @@ -26,7 +38,7 @@ export class ApiError extends Error { public status: number, public body: unknown, ) { - super(`API error ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`) + super(getErrorMessage(status, body)) this.name = 'ApiError' } } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 33004574..51999a77 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -207,6 +207,7 @@ export const en = { 'settings.mcp.form.rawConfig': 'Raw config', 'settings.mcp.form.command': 'Command to launch', 'settings.mcp.form.commandPlaceholder': 'npx', + 'settings.mcp.form.commandHostHint': 'STDIO MCP commands run on the host machine. Install runtimes like Node.js, Python, Bun, or uv yourself and make sure the command is available in PATH.', 'settings.mcp.form.arguments': 'Arguments', 'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest', 'settings.mcp.form.addArgument': 'Add argument', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index ca792fbb..54295417 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -209,6 +209,7 @@ export const zh: Record = { 'settings.mcp.form.rawConfig': '原始配置', 'settings.mcp.form.command': '启动命令', 'settings.mcp.form.commandPlaceholder': 'npx', + 'settings.mcp.form.commandHostHint': 'STDIO MCP 命令会直接在宿主机上运行。像 Node.js、Python、Bun、uv 这类运行时需要用户自己安装,并确保这个命令在 PATH 里可用。', 'settings.mcp.form.arguments': '参数', 'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest', 'settings.mcp.form.addArgument': '添加参数', diff --git a/desktop/src/pages/McpSettings.tsx b/desktop/src/pages/McpSettings.tsx index a808e88b..f63eb2a3 100644 --- a/desktop/src/pages/McpSettings.tsx +++ b/desktop/src/pages/McpSettings.tsx @@ -844,6 +844,9 @@ export function McpSettings() { placeholder={t('settings.mcp.form.commandPlaceholder')} required /> +

+ {t('settings.mcp.form.commandHostHint')} +

| undefined let getClaudeCodeMcpConfigsSpy: ReturnType | undefined let reconnectSpy: ReturnType | undefined +let hostPreflightSpy: ReturnType | undefined async function setup() { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-')) @@ -55,6 +57,11 @@ describe('MCP API', () => { beforeEach(async () => { await setup() + hostPreflightSpy = spyOn(mcpHostPreflight, 'inspectMcpHostCommand').mockResolvedValue({ + ok: true, + resolvedCommand: '/usr/bin/mock-command', + }) + connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({ name, type: 'connected', @@ -72,6 +79,8 @@ describe('MCP API', () => { getClaudeCodeMcpConfigsSpy = undefined reconnectSpy?.mockRestore() reconnectSpy = undefined + hostPreflightSpy?.mockRestore() + hostPreflightSpy = undefined await teardown() }) @@ -132,6 +141,65 @@ describe('MCP API', () => { expect(connectSpy).toHaveBeenCalled() }) + it('rejects stdio MCP creation when the host command is unavailable', async () => { + hostPreflightSpy?.mockResolvedValueOnce({ + ok: false, + message: 'Host command "npx" is not available in PATH.', + }) + + const create = makeRequest('POST', '/api/mcp', { + cwd: projectRoot, + name: 'chrome-devtools', + scope: 'local', + config: { + type: 'stdio', + command: 'npx', + args: ['chrome-devtools-mcp@latest'], + env: {}, + }, + }) + + const createRes = await handleMcpApi(create.req, create.url, create.segments) + + expect(createRes.status).toBe(400) + await expect(createRes.json()).resolves.toMatchObject({ + message: 'Host command "npx" is not available in PATH.', + }) + }) + + it('surfaces host preflight failures in live status checks without connecting', async () => { + const create = makeRequest('POST', '/api/mcp', { + cwd: projectRoot, + name: 'chrome-devtools', + scope: 'local', + config: { + type: 'stdio', + command: 'npx', + args: ['chrome-devtools-mcp@latest'], + env: {}, + }, + }) + await handleMcpApi(create.req, create.url, create.segments) + + hostPreflightSpy?.mockResolvedValueOnce({ + ok: false, + message: 'Host command "npx" is not available in PATH.', + }) + + const status = makeRequest('GET', `/api/mcp/chrome-devtools/status?cwd=${encodeURIComponent(projectRoot)}`) + const statusRes = await handleMcpApi(status.req, status.url, status.segments) + + expect(statusRes.status).toBe(200) + await expect(statusRes.json()).resolves.toMatchObject({ + server: { + name: 'chrome-devtools', + status: 'failed', + statusDetail: 'Host command "npx" is not available in PATH.', + }, + }) + expect(connectSpy).not.toHaveBeenCalled() + }) + it('updates, toggles, and deletes MCP servers', async () => { const create = makeRequest('POST', '/api/mcp', { cwd: projectRoot, @@ -230,4 +298,55 @@ describe('MCP API', () => { expect(body.server.name).toBe(pluginServerName) expect(body.server.scope).toBe('dynamic') }) + + it('returns a failed server state when reconnect preflight fails on the host machine', async () => { + const pluginServerName = 'plugin:telegram:telegram' + const pluginServerConfig = { + scope: 'dynamic', + type: 'stdio', + command: 'npx', + args: ['telegram-mcp'], + env: {}, + pluginSource: 'telegram@claude-plugins-official', + } as const + + getClaudeCodeMcpConfigsSpy = spyOn(mcpConfig, 'getClaudeCodeMcpConfigs').mockResolvedValue({ + servers: { + [pluginServerName]: pluginServerConfig, + }, + errors: [], + }) + + hostPreflightSpy?.mockResolvedValueOnce({ + ok: false, + message: 'Host command "npx" is not available in PATH.', + }) + + reconnectSpy = spyOn(mcpClient, 'reconnectMcpServerImpl').mockResolvedValue({ + name: pluginServerName, + client: { + name: pluginServerName, + type: 'connected', + client: {} as never, + capabilities: {}, + config: pluginServerConfig, + cleanup: mock(async () => {}), + }, + }) + + const reconnect = makeRequest('POST', `/api/mcp/${encodeURIComponent(pluginServerName)}/reconnect`, { + cwd: projectRoot, + }) + const reconnectRes = await handleMcpApi(reconnect.req, reconnect.url, reconnect.segments) + + expect(reconnectRes.status).toBe(200) + expect(reconnectSpy).not.toHaveBeenCalled() + await expect(reconnectRes.json()).resolves.toMatchObject({ + server: { + name: pluginServerName, + status: 'failed', + statusDetail: 'Host command "npx" is not available in PATH.', + }, + }) + }) }) diff --git a/src/server/api/mcp.ts b/src/server/api/mcp.ts index b48989e7..e5bc9545 100644 --- a/src/server/api/mcp.ts +++ b/src/server/api/mcp.ts @@ -15,6 +15,7 @@ import { removeMcpConfig, setMcpServerEnabled, } from '../../services/mcp/config.js' +import { inspectMcpHostCommand } from '../services/mcpHostPreflight.js' import type { ConfigScope, McpHTTPServerConfig, @@ -176,6 +177,35 @@ function getInitialStatus( } } +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, @@ -189,6 +219,11 @@ async function inspectServerStatus( } } + const hostPreflightStatus = await getHostPreflightStatus(config, enabled) + if (hostPreflightStatus) { + return hostPreflightStatus + } + try { const client = await connectToServer(name, config) await clearServerCache(name, config).catch(() => {}) @@ -371,6 +406,13 @@ async function getServerStatus(name: string): Promise { }) } +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) { @@ -379,6 +421,7 @@ async function createServer(body: Record): Promise { const scope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : undefined) const config = buildServerConfig(body.config) + await assertHostPrerequisites(config) try { await addMcpConfig(name, config, scope) @@ -410,6 +453,7 @@ async function updateServer(name: string, body: Record): Promis 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 @@ -470,6 +514,14 @@ async function toggleServer(name: string): Promise { 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(() => {}) @@ -491,6 +543,14 @@ async function reconnectServer(name: string): Promise { 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(() => {}) diff --git a/src/server/services/mcpHostPreflight.ts b/src/server/services/mcpHostPreflight.ts new file mode 100644 index 00000000..57ddca2b --- /dev/null +++ b/src/server/services/mcpHostPreflight.ts @@ -0,0 +1,166 @@ +import { constants } from 'node:fs' +import { access } from 'node:fs/promises' +import path from 'node:path' +import { getCwd } from '../../utils/cwd.js' +import { which } from '../../utils/which.js' + +type HostCommandCheckResult = + | { + ok: true + resolvedCommand: string + } + | { + ok: false + message: string + } + +function getPathSearchList(envPath?: string) { + return (envPath ?? process.env.PATH ?? '') + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function getWindowsExecutableCandidates(command: string) { + if (process.platform !== 'win32') { + return [command] + } + + const ext = path.extname(command) + if (ext) { + return [command] + } + + const pathext = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM') + .split(';') + .map((entry) => entry.trim()) + .filter(Boolean) + + return [command, ...pathext.map((entry) => `${command}${entry.toLowerCase()}`)] +} + +function isPathLikeCommand(command: string) { + return ( + path.isAbsolute(command) || + command.startsWith('./') || + command.startsWith('../') || + command.startsWith('.\\') || + command.startsWith('..\\') || + command.includes('/') || + command.includes('\\') + ) +} + +function buildRuntimeHint(command: string) { + const normalized = path.basename(command).toLowerCase() + + if (['node', 'npm', 'npx', 'pnpm', 'yarn'].includes(normalized)) { + return `Install Node.js on this machine so "${command}" is available in PATH, then retry.` + } + + if (['python', 'python3', 'pip', 'pip3'].includes(normalized)) { + return `Install Python on this machine so "${command}" is available in PATH, then retry.` + } + + if (normalized === 'uv') { + return 'Install uv on this machine so "uv" is available in PATH, then retry.' + } + + if (normalized === 'bun') { + return 'Install Bun on this machine so "bun" is available in PATH, then retry.' + } + + return `Install "${command}" on this machine or update PATH, then retry.` +} + +function buildMissingCommandMessage(command: string) { + return `Host command "${command}" is not available in PATH. This STDIO MCP runs on the host machine. ${buildRuntimeHint(command)}` +} + +function buildMissingPathMessage(command: string, resolvedPath: string) { + return `Host command path "${command}" could not be found at "${resolvedPath}". This STDIO MCP runs on the host machine, so the configured executable path must exist locally.` +} + +function buildNonExecutablePathMessage(command: string, resolvedPath: string) { + return `Host command path "${command}" exists at "${resolvedPath}" but is not executable. This STDIO MCP runs on the host machine, so the configured executable must be runnable by the local OS user.` +} + +async function resolveCommandFromPath( + command: string, + envPath?: string, +): Promise { + const pathEntries = getPathSearchList(envPath) + + for (const entry of pathEntries) { + for (const candidate of getWindowsExecutableCandidates(command)) { + const resolvedPath = path.join(entry, candidate) + try { + await access( + resolvedPath, + process.platform === 'win32' ? constants.F_OK : constants.X_OK, + ) + return resolvedPath + } catch { + // Continue searching other PATH entries. + } + } + } + + return null +} + +export async function inspectMcpHostCommand( + command: string, + cwd: string = getCwd(), + env?: Record, +): Promise { + const trimmedCommand = command.trim() + if (!trimmedCommand) { + return { + ok: false, + message: 'STDIO MCP command is empty.', + } + } + + if (isPathLikeCommand(trimmedCommand)) { + const resolvedPath = path.isAbsolute(trimmedCommand) + ? trimmedCommand + : path.resolve(cwd, trimmedCommand) + + try { + await access( + resolvedPath, + process.platform === 'win32' ? constants.F_OK : constants.X_OK, + ) + return { + ok: true, + resolvedCommand: resolvedPath, + } + } catch (error) { + const maybeErr = error as NodeJS.ErrnoException + return { + ok: false, + message: + maybeErr.code === 'ENOENT' + ? buildMissingPathMessage(trimmedCommand, resolvedPath) + : buildNonExecutablePathMessage(trimmedCommand, resolvedPath), + } + } + } + + const resolvedCommand = + env?.PATH + ? await resolveCommandFromPath(trimmedCommand, env.PATH) + : await which(trimmedCommand) + if (resolvedCommand) { + return { + ok: true, + resolvedCommand, + } + } + + return { + ok: false, + message: buildMissingCommandMessage(trimmedCommand), + } +}