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), + } +}