mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-24 14:43:35 +08:00
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
This commit is contained in:
parent
8c99efca20
commit
467debcd8b
@ -9,6 +9,18 @@ const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
|
|||||||
|
|
||||||
let baseUrl = DEFAULT_BASE_URL
|
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) {
|
export function setBaseUrl(url: string) {
|
||||||
baseUrl = url.replace(/\/$/, '')
|
baseUrl = url.replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
@ -26,7 +38,7 @@ export class ApiError extends Error {
|
|||||||
public status: number,
|
public status: number,
|
||||||
public body: unknown,
|
public body: unknown,
|
||||||
) {
|
) {
|
||||||
super(`API error ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`)
|
super(getErrorMessage(status, body))
|
||||||
this.name = 'ApiError'
|
this.name = 'ApiError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -207,6 +207,7 @@ export const en = {
|
|||||||
'settings.mcp.form.rawConfig': 'Raw config',
|
'settings.mcp.form.rawConfig': 'Raw config',
|
||||||
'settings.mcp.form.command': 'Command to launch',
|
'settings.mcp.form.command': 'Command to launch',
|
||||||
'settings.mcp.form.commandPlaceholder': 'npx',
|
'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.arguments': 'Arguments',
|
||||||
'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest',
|
'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest',
|
||||||
'settings.mcp.form.addArgument': 'Add argument',
|
'settings.mcp.form.addArgument': 'Add argument',
|
||||||
|
|||||||
@ -209,6 +209,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.mcp.form.rawConfig': '原始配置',
|
'settings.mcp.form.rawConfig': '原始配置',
|
||||||
'settings.mcp.form.command': '启动命令',
|
'settings.mcp.form.command': '启动命令',
|
||||||
'settings.mcp.form.commandPlaceholder': 'npx',
|
'settings.mcp.form.commandPlaceholder': 'npx',
|
||||||
|
'settings.mcp.form.commandHostHint': 'STDIO MCP 命令会直接在宿主机上运行。像 Node.js、Python、Bun、uv 这类运行时需要用户自己安装,并确保这个命令在 PATH 里可用。',
|
||||||
'settings.mcp.form.arguments': '参数',
|
'settings.mcp.form.arguments': '参数',
|
||||||
'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest',
|
'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest',
|
||||||
'settings.mcp.form.addArgument': '添加参数',
|
'settings.mcp.form.addArgument': '添加参数',
|
||||||
|
|||||||
@ -844,6 +844,9 @@ export function McpSettings() {
|
|||||||
placeholder={t('settings.mcp.form.commandPlaceholder')}
|
placeholder={t('settings.mcp.form.commandPlaceholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||||
|
{t('settings.mcp.form.commandHostHint')}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ArraySection
|
<ArraySection
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import * as os from 'os'
|
|||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as mcpClient from '../../services/mcp/client.js'
|
import * as mcpClient from '../../services/mcp/client.js'
|
||||||
import * as mcpConfig from '../../services/mcp/config.js'
|
import * as mcpConfig from '../../services/mcp/config.js'
|
||||||
|
import * as mcpHostPreflight from '../services/mcpHostPreflight.js'
|
||||||
import { handleMcpApi } from '../api/mcp.js'
|
import { handleMcpApi } from '../api/mcp.js'
|
||||||
|
|
||||||
let tmpDir: string
|
let tmpDir: string
|
||||||
@ -12,6 +13,7 @@ let originalConfigDir: string | undefined
|
|||||||
let connectSpy: ReturnType<typeof spyOn> | undefined
|
let connectSpy: ReturnType<typeof spyOn> | undefined
|
||||||
let getClaudeCodeMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
|
let getClaudeCodeMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
|
||||||
let reconnectSpy: ReturnType<typeof spyOn> | undefined
|
let reconnectSpy: ReturnType<typeof spyOn> | undefined
|
||||||
|
let hostPreflightSpy: ReturnType<typeof spyOn> | undefined
|
||||||
|
|
||||||
async function setup() {
|
async function setup() {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
|
||||||
@ -55,6 +57,11 @@ describe('MCP API', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await setup()
|
await setup()
|
||||||
|
|
||||||
|
hostPreflightSpy = spyOn(mcpHostPreflight, 'inspectMcpHostCommand').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
resolvedCommand: '/usr/bin/mock-command',
|
||||||
|
})
|
||||||
|
|
||||||
connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({
|
connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({
|
||||||
name,
|
name,
|
||||||
type: 'connected',
|
type: 'connected',
|
||||||
@ -72,6 +79,8 @@ describe('MCP API', () => {
|
|||||||
getClaudeCodeMcpConfigsSpy = undefined
|
getClaudeCodeMcpConfigsSpy = undefined
|
||||||
reconnectSpy?.mockRestore()
|
reconnectSpy?.mockRestore()
|
||||||
reconnectSpy = undefined
|
reconnectSpy = undefined
|
||||||
|
hostPreflightSpy?.mockRestore()
|
||||||
|
hostPreflightSpy = undefined
|
||||||
await teardown()
|
await teardown()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -132,6 +141,65 @@ describe('MCP API', () => {
|
|||||||
expect(connectSpy).toHaveBeenCalled()
|
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 () => {
|
it('updates, toggles, and deletes MCP servers', async () => {
|
||||||
const create = makeRequest('POST', '/api/mcp', {
|
const create = makeRequest('POST', '/api/mcp', {
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
@ -230,4 +298,55 @@ describe('MCP API', () => {
|
|||||||
expect(body.server.name).toBe(pluginServerName)
|
expect(body.server.name).toBe(pluginServerName)
|
||||||
expect(body.server.scope).toBe('dynamic')
|
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.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
removeMcpConfig,
|
removeMcpConfig,
|
||||||
setMcpServerEnabled,
|
setMcpServerEnabled,
|
||||||
} from '../../services/mcp/config.js'
|
} from '../../services/mcp/config.js'
|
||||||
|
import { inspectMcpHostCommand } from '../services/mcpHostPreflight.js'
|
||||||
import type {
|
import type {
|
||||||
ConfigScope,
|
ConfigScope,
|
||||||
McpHTTPServerConfig,
|
McpHTTPServerConfig,
|
||||||
@ -176,6 +177,35 @@ function getInitialStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
async function inspectServerStatus(
|
||||||
name: string,
|
name: string,
|
||||||
config: ScopedMcpServerConfig,
|
config: ScopedMcpServerConfig,
|
||||||
@ -189,6 +219,11 @@ async function inspectServerStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostPreflightStatus = await getHostPreflightStatus(config, enabled)
|
||||||
|
if (hostPreflightStatus) {
|
||||||
|
return hostPreflightStatus
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = await connectToServer(name, config)
|
const client = await connectToServer(name, config)
|
||||||
await clearServerCache(name, config).catch(() => {})
|
await clearServerCache(name, config).catch(() => {})
|
||||||
@ -371,6 +406,13 @@ async function getServerStatus(name: string): Promise<Response> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
async function createServer(body: Record<string, unknown>): Promise<Response> {
|
||||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@ -379,6 +421,7 @@ async function createServer(body: Record<string, unknown>): Promise<Response> {
|
|||||||
|
|
||||||
const scope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : undefined)
|
const scope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : undefined)
|
||||||
const config = buildServerConfig(body.config)
|
const config = buildServerConfig(body.config)
|
||||||
|
await assertHostPrerequisites(config)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addMcpConfig(name, config, scope)
|
await addMcpConfig(name, config, scope)
|
||||||
@ -410,6 +453,7 @@ async function updateServer(name: string, body: Record<string, unknown>): Promis
|
|||||||
|
|
||||||
const nextScope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : existing.scope)
|
const nextScope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : existing.scope)
|
||||||
const nextConfig = buildServerConfig(body.config)
|
const nextConfig = buildServerConfig(body.config)
|
||||||
|
await assertHostPrerequisites(nextConfig)
|
||||||
const previousConfig = stripScope(existing)
|
const previousConfig = stripScope(existing)
|
||||||
const previousScope = existing.scope
|
const previousScope = existing.scope
|
||||||
|
|
||||||
@ -470,6 +514,14 @@ async function toggleServer(name: string): Promise<Response> {
|
|||||||
return Response.json({ server: updated })
|
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)
|
const result = await reconnectMcpServerImpl(name, existing)
|
||||||
await clearServerCache(name, existing).catch(() => {})
|
await clearServerCache(name, existing).catch(() => {})
|
||||||
|
|
||||||
@ -491,6 +543,14 @@ async function reconnectServer(name: string): Promise<Response> {
|
|||||||
throw ApiError.notFound(`MCP server not found: ${name}`)
|
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)
|
const result = await reconnectMcpServerImpl(name, existing)
|
||||||
await clearServerCache(name, existing).catch(() => {})
|
await clearServerCache(name, existing).catch(() => {})
|
||||||
|
|
||||||
|
|||||||
166
src/server/services/mcpHostPreflight.ts
Normal file
166
src/server/services/mcpHostPreflight.ts
Normal file
@ -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<string | null> {
|
||||||
|
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<string, string>,
|
||||||
|
): Promise<HostCommandCheckResult> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user