🐛 fix(mcp): fix installation check hanging issue in desktop app (#11524)

*  feat(mcp): enhance error handling and logging for MCP connections

- Introduced MCPConnectionError class to capture and log stderr output during MCP connections.
- Updated McpCtr to handle MCPConnectionError and provide enhanced error messages with stderr logs.
- Modified MCPClient to collect stderr logs and throw enhanced errors when connection issues occur.
- Improved error display in MCPManifestForm to show detailed error information when connection tests fail.
- Added utility functions to parse and extract STDIO process output from error messages.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(mcp): remove npx check to prevent hanging during installation check

- Remove `npx -y` package check that could download packages or start MCP servers
- This was causing the UI to hang on "Checking installation environment"
- npm packages don't require pre-installation, npx handles on-demand download

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-01-15 23:30:05 +08:00
committed by GitHub
parent 4dad2ec5ad
commit b9341c3183
7 changed files with 253 additions and 119 deletions

View File

@@ -21,7 +21,6 @@ export default defineConfig({
},
sourcemap: isDev ? 'inline' : false,
},
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.UPDATE_CHANNEL': JSON.stringify(process.env.UPDATE_CHANNEL),

View File

@@ -7,7 +7,7 @@ import superjson from 'superjson';
import FileService from '@/services/fileSrv';
import { createLogger } from '@/utils/logger';
import { MCPClient } from '../libs/mcp/client';
import { MCPClient, MCPConnectionError } from '../libs/mcp/client';
import type { MCPClientParams, ToolCallContent, ToolCallResult } from '../libs/mcp/types';
import { ControllerModule, IpcMethod } from './index';
@@ -228,8 +228,9 @@ export default class McpCtr extends ControllerModule {
type: 'stdio',
};
const client = await this.createClient(params);
let client: MCPClient | undefined;
try {
client = await this.createClient(params);
const manifest = await client.listManifests();
const identifier = input.name;
@@ -257,8 +258,25 @@ export default class McpCtr extends ControllerModule {
mcpParams: params,
type: 'mcp' as any,
});
} catch (error) {
// If it's an MCPConnectionError with stderr logs, enhance the error message
if (error instanceof MCPConnectionError && error.stderrLogs.length > 0) {
const stderrOutput = error.stderrLogs.join('\n');
const enhancedError = new Error(
`${error.message}\n\n--- STDIO Process Output ---\n${stderrOutput}`,
);
enhancedError.name = error.name;
logger.error('getStdioMcpServerManifest failed with STDIO logs:', {
message: error.message,
stderrLogs: error.stderrLogs,
});
throw enhancedError;
}
throw error;
} finally {
await client.disconnect();
if (client) {
await client.disconnect();
}
}
}
@@ -313,8 +331,9 @@ export default class McpCtr extends ControllerModule {
type: 'stdio',
};
const client = await this.createClient(params);
let client: MCPClient | undefined;
try {
client = await this.createClient(params);
const args = safeParseToRecord(input.args);
const raw = (await client.callTool(input.toolName, args)) as ToolCallResult;
@@ -328,10 +347,25 @@ export default class McpCtr extends ControllerModule {
success: true,
});
} catch (error) {
// If it's an MCPConnectionError with stderr logs, enhance the error message
if (error instanceof MCPConnectionError && error.stderrLogs.length > 0) {
const stderrOutput = error.stderrLogs.join('\n');
const enhancedError = new Error(
`${error.message}\n\n--- STDIO Process Output ---\n${stderrOutput}`,
);
enhancedError.name = error.name;
logger.error('callTool failed with STDIO logs:', {
message: error.message,
stderrLogs: error.stderrLogs,
});
throw enhancedError;
}
logger.error('callTool failed:', error);
throw error;
} finally {
await client.disconnect();
if (client) {
await client.disconnect();
}
}
}
@@ -361,8 +395,9 @@ export default class McpCtr extends ControllerModule {
}
private async checkSystemDependency(dependency: any) {
const checkCommand = dependency.checkCommand || `${dependency.name} --version`;
try {
const checkCommand = dependency.checkCommand || `${dependency.name} --version`;
const { stdout, stderr } = await execPromise(checkCommand);
if (stderr && !stdout) {
@@ -444,22 +479,19 @@ export default class McpCtr extends ControllerModule {
const packageName = details?.packageName;
if (!packageName) return { installed: false };
// Only check global npm list - do NOT use npx as it may download packages
try {
const { stdout } = await execPromise(`npm list -g ${packageName} --depth=0`);
if (!stdout.includes('(empty)') && stdout.includes(packageName)) return { installed: true };
if (!stdout.includes('(empty)') && stdout.includes(packageName)) {
return { installed: true };
}
} catch {
// ignore
// ignore - package not found in global list
}
try {
await execPromise(`npx -y ${packageName} --version`);
return { installed: true };
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
installed: false,
};
}
// For npm packages, we don't require pre-installation
// npx will handle downloading and running on-demand during actual MCP connection
return { installed: false };
}
if (installationMethod === 'python') {
@@ -553,7 +585,7 @@ export default class McpCtr extends ControllerModule {
const bestResult = recommendedResult || firstInstallableResult || results[0];
const checkResult: CheckMcpInstallResult = {
...(bestResult || {}),
...bestResult,
allOptions: results as any,
platform: process.platform,
success: true,

View File

@@ -6,15 +6,31 @@ import {
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Progress } from '@modelcontextprotocol/sdk/types.js';
import type { Readable } from 'node:stream';
import { getDesktopEnv } from '@/env';
import type { MCPClientParams, McpPrompt, McpResource, McpTool, ToolCallResult } from './types';
/**
* Custom error class for MCP connection errors that includes STDIO logs
*/
export class MCPConnectionError extends Error {
readonly stderrLogs: string[];
constructor(message: string, stderrLogs: string[] = []) {
super(message);
this.name = 'MCPConnectionError';
this.stderrLogs = stderrLogs;
}
}
export class MCPClient {
private readonly mcp: Client;
private transport: Transport;
private stderrLogs: string[] = [];
private isStdio: boolean = false;
constructor(params: MCPClientParams) {
this.mcp = new Client({ name: 'lobehub-desktop-mcp-client', version: '1.0.0' });
@@ -40,14 +56,21 @@ export class MCPClient {
}
case 'stdio': {
this.transport = new StdioClientTransport({
this.isStdio = true;
const stdioTransport = new StdioClientTransport({
args: params.args,
command: params.command,
env: {
...getDefaultEnvironment(),
...params.env,
},
stderr: 'pipe', // Capture stderr for better error messages
});
// Listen to stderr stream to collect logs
this.setupStderrListener(stdioTransport);
this.transport = stdioTransport;
break;
}
@@ -60,16 +83,45 @@ export class MCPClient {
}
}
private setupStderrListener(transport: StdioClientTransport) {
const stderr = transport.stderr as Readable | null;
if (stderr) {
stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
// Split by newlines and filter empty lines
const lines = text.split('\n').filter((line) => line.trim());
this.stderrLogs.push(...lines);
});
}
}
/**
* Get collected stderr logs from the STDIO process
*/
getStderrLogs(): string[] {
return this.stderrLogs;
}
private isMethodNotFoundError(error: unknown) {
const err = error as any;
if (!err) return false;
// eslint-disable-next-line unicorn/numeric-separators-style
if (err.code === -32601) return true;
if (typeof err.message === 'string' && err.message.includes('Method not found')) return true;
return false;
}
async initialize(options: { onProgress?: (progress: Progress) => void } = {}) {
await this.mcp.connect(this.transport, { onprogress: options.onProgress });
try {
await this.mcp.connect(this.transport, { onprogress: options.onProgress });
} catch (error) {
// If this is a STDIO connection and we have stderr logs, enhance the error
if (this.isStdio && this.stderrLogs.length > 0) {
const originalMessage = error instanceof Error ? error.message : String(error);
throw new MCPConnectionError(originalMessage, this.stderrLogs);
}
throw error;
}
}
async disconnect() {

View File

@@ -1,8 +1,7 @@
import { Button, Flexbox, Highlighter, Icon, Tag } from '@lobehub/ui';
import { Flexbox, Highlighter, Tag } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { ChevronDown, ChevronRight } from 'lucide-react';
import * as motion from 'motion/react-m';
import { memo, useState } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { type MCPErrorInfoMetadata } from '@/types/plugins';
@@ -12,94 +11,73 @@ const ErrorDetails = memo<{
errorMessage?: string;
}>(({ errorInfo, errorMessage }) => {
const { t } = useTranslation('plugin');
const [expanded, setExpanded] = useState(false);
return (
<Flexbox gap={8}>
<Button
color={'default'}
icon={<Icon icon={expanded ? ChevronDown : ChevronRight} />}
onClick={() => setExpanded(!expanded)}
size="small"
style={{
fontSize: '12px',
padding: '0 4px',
}}
variant="filled"
<motion.div
animate={{ height: 'auto', opacity: 1 }}
initial={{ height: 0, opacity: 0 }}
style={{ overflow: 'hidden' }}
>
{expanded
? t('mcpInstall.errorDetails.hideDetails')
: t('mcpInstall.errorDetails.showDetails')}
</Button>
{expanded && (
<motion.div
animate={{ height: 'auto', opacity: 1 }}
initial={{ height: 0, opacity: 0 }}
style={{ overflow: 'hidden' }}
<Flexbox
gap={8}
style={{
backgroundColor: cssVar.colorFillQuaternary,
borderRadius: 8,
fontFamily: 'monospace',
fontSize: '11px',
padding: '8px 12px',
}}
>
<Flexbox
gap={8}
style={{
backgroundColor: cssVar.colorFillQuaternary,
borderRadius: 8,
fontFamily: 'monospace',
fontSize: '11px',
padding: '8px 12px',
}}
>
{errorInfo.params && (
<Flexbox gap={4}>
<div>
<Tag color="blue" variant={'filled'}>
{t('mcpInstall.errorDetails.connectionParams')}
</Tag>
</div>
<div style={{ marginTop: 4, wordBreak: 'break-all' }}>
{errorInfo.params.command && (
<div>
{t('mcpInstall.errorDetails.command')}: {errorInfo.params.command}
</div>
)}
{errorInfo.params.args && (
<div>
{t('mcpInstall.errorDetails.args')}: {errorInfo.params.args.join(' ')}
</div>
)}
</div>
</Flexbox>
)}
{errorInfo.errorLog && (
<Flexbox gap={4}>
<div>
<Tag color="red" variant={'filled'}>
{t('mcpInstall.errorDetails.errorOutput')}
</Tag>
</div>
<Highlighter
language={'log'}
style={{
maxHeight: 200,
overflow: 'auto',
}}
>
{errorInfo.errorLog}
</Highlighter>
</Flexbox>
)}
{errorInfo.originalError && errorInfo.originalError !== errorMessage && (
{errorInfo.params && (
<Flexbox gap={4}>
<div>
<Tag color="orange">{t('mcpInstall.errorDetails.originalError')}</Tag>
<div style={{ marginTop: 4, wordBreak: 'break-all' }}>
{errorInfo.originalError}
</div>
<Tag color="blue" variant={'filled'}>
{t('mcpInstall.errorDetails.connectionParams')}
</Tag>
</div>
)}
</Flexbox>
</motion.div>
)}
<div style={{ marginTop: 4, wordBreak: 'break-all' }}>
{errorInfo.params.command && (
<div>
{t('mcpInstall.errorDetails.command')}: {errorInfo.params.command}
</div>
)}
{errorInfo.params.args && (
<div>
{t('mcpInstall.errorDetails.args')}: {errorInfo.params.args.join(' ')}
</div>
)}
</div>
</Flexbox>
)}
{errorInfo.errorLog && (
<Flexbox gap={4}>
<div>
<Tag color="red" variant={'filled'}>
{t('mcpInstall.errorDetails.errorOutput')}
</Tag>
</div>
<Highlighter
language={'log'}
style={{
maxHeight: 200,
overflow: 'auto',
}}
>
{errorInfo.errorLog}
</Highlighter>
</Flexbox>
)}
{errorInfo.originalError && errorInfo.originalError !== errorMessage && (
<div>
<Tag color="orange">{t('mcpInstall.errorDetails.originalError')}</Tag>
<div style={{ marginTop: 4, wordBreak: 'break-all' }}>{errorInfo.originalError}</div>
</div>
)}
</Flexbox>
</motion.div>
</Flexbox>
);
});

View File

@@ -6,8 +6,10 @@ import { useTranslation } from 'react-i18next';
import KeyValueEditor from '@/components/KeyValueEditor';
import MCPStdioCommandInput from '@/components/MCPStdioCommandInput';
import ErrorDetails from '@/features/MCP/MCPInstallProgress/InstallError/ErrorDetails';
import { useToolStore } from '@/store/tool';
import { mcpStoreSelectors, pluginSelectors } from '@/store/tool/selectors';
import { type MCPErrorInfoMetadata } from '@/types/plugins';
import ArgsInput from './ArgsInput';
import CollapsibleSection from './CollapsibleSection';
@@ -46,10 +48,12 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
const testState = useToolStore(mcpStoreSelectors.getMCPConnectionTestState(identifier), isEqual);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [errorMetadata, setErrorMetadata] = useState<MCPErrorInfoMetadata | null>(null);
const handleTestConnection = async () => {
setIsTesting(true);
setConnectionError(null);
setErrorMetadata(null);
// Manually trigger validation for fields needed for the test
let isValid = false;
@@ -97,12 +101,29 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
// Be careful about overwriting user input if not desired
form.setFieldsValue({ manifest: result.manifest });
setConnectionError(null); // 清除本地错误状态
setErrorMetadata(null);
} else if (result.error) {
// Store 已经处理了错误状态,这里可以选择显示额外的用户友好提示
const errorMessage = t('error.testConnectionFailed', {
error: result.error,
});
setConnectionError(errorMessage);
// Build error metadata for detailed display
if (result.errorLog || mcpType === 'stdio') {
setErrorMetadata({
errorLog: result.errorLog,
params:
mcpType === 'stdio'
? {
args: mcp?.args,
command: mcp?.command,
type: 'stdio',
}
: undefined,
timestamp: Date.now(),
});
}
}
} catch (error) {
// Handle unexpected errors
@@ -121,7 +142,10 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
<QuickImportSection
form={form}
isEditMode={isEditMode}
onClearConnectionError={() => setConnectionError(null)}
onClearConnectionError={() => {
setConnectionError(null);
setErrorMetadata(null);
}}
/>
<Form form={form} layout={'vertical'}>
<Flexbox>
@@ -270,9 +294,12 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
{(connectionError || testState.error) && (
<Alert
closable
onClose={() => setConnectionError(null)}
extra={errorMetadata ? <ErrorDetails errorInfo={errorMetadata} /> : undefined}
onClose={() => {
setConnectionError(null);
setErrorMetadata(null);
}}
showIcon
style={{ marginBottom: 16 }}
title={connectionError || testState.error}
type="error"
/>

View File

@@ -240,3 +240,34 @@ export function createMCPError(
return error;
}
/**
* STDIO Process Output separator used in enhanced error messages
*/
const STDIO_OUTPUT_SEPARATOR = '--- STDIO Process Output ---';
/**
* Parse error message to extract STDIO process output logs
* The enhanced error format from desktop is:
* "Original message\n\n--- STDIO Process Output ---\nlogs..."
*/
export interface ParsedStdioError {
errorLog?: string;
originalMessage: string;
}
export function parseStdioErrorMessage(errorMessage: string): ParsedStdioError {
const separatorIndex = errorMessage.indexOf(STDIO_OUTPUT_SEPARATOR);
if (separatorIndex === -1) {
return { originalMessage: errorMessage };
}
const originalMessage = errorMessage.slice(0, separatorIndex).trim();
const errorLog = errorMessage.slice(separatorIndex + STDIO_OUTPUT_SEPARATOR.length).trim();
return {
errorLog: errorLog || undefined,
originalMessage: originalMessage || errorMessage,
};
}

View File

@@ -9,7 +9,7 @@ import { gt, valid } from 'semver';
import useSWR, { type SWRResponse } from 'swr';
import { type StateCreator } from 'zustand/vanilla';
import { type MCPErrorData } from '@/libs/mcp/types';
import { type MCPErrorData, parseStdioErrorMessage } from '@/libs/mcp/types';
import { discoverService } from '@/services/discover';
import { mcpService } from '@/services/mcp';
import { pluginService } from '@/services/plugin';
@@ -132,6 +132,8 @@ const buildCloudMcpManifest = (params: {
// Test connection result type
export interface TestMcpConnectionResult {
error?: string;
/** STDIO process output logs for debugging */
errorLog?: string;
manifest?: LobeChatPluginManifest;
success: boolean;
}
@@ -301,8 +303,6 @@ export const createMCPPluginStoreSlice: StateCreator<
// Check if cloudEndPoint is available: web + stdio type + haveCloudEndpoint exists
const hasCloudEndpoint = !isDesktop && stdioOption && haveCloudEndpoint;
console.log('hasCloudEndpoint', hasCloudEndpoint);
let shouldUseHttpDeployment = !!httpOption && (!hasNonHttpDeployment || !isDesktop);
if (hasCloudEndpoint) {
@@ -592,7 +592,7 @@ export const createMCPPluginStoreSlice: StateCreator<
event: 'install',
identifier: plugin.identifier,
source: 'self',
})
});
discoverService.reportMcpInstallResult({
identifier: plugin.identifier,
@@ -653,10 +653,22 @@ export const createMCPPluginStoreSlice: StateCreator<
};
} else {
// Fallback handling for normal errors
const errorMessage = error instanceof Error ? error.message : String(error);
const rawErrorMessage = error instanceof Error ? error.message : String(error);
// Parse STDIO error message to extract process output logs
const { originalMessage, errorLog } = parseStdioErrorMessage(rawErrorMessage);
errorInfo = {
message: errorMessage,
message: originalMessage,
metadata: {
errorLog,
params: connection
? {
args: connection.args,
command: connection.command,
type: connection.type,
}
: undefined,
step: 'installation_error',
timestamp: Date.now(),
},
@@ -800,7 +812,7 @@ export const createMCPPluginStoreSlice: StateCreator<
event: 'activate',
identifier: identifier,
source: 'self',
})
});
return { manifest, success: true };
} catch (error) {
@@ -809,20 +821,23 @@ export const createMCPPluginStoreSlice: StateCreator<
return { error: 'Test cancelled', success: false };
}
const errorMessage = error instanceof Error ? error.message : String(error);
const rawErrorMessage = error instanceof Error ? error.message : String(error);
// Parse STDIO error message to extract process output logs
const { originalMessage, errorLog } = parseStdioErrorMessage(rawErrorMessage);
// Set error state
set(
produce((draft: MCPStoreState) => {
draft.mcpTestLoading[identifier] = false;
draft.mcpTestErrors[identifier] = errorMessage;
draft.mcpTestErrors[identifier] = originalMessage;
delete draft.mcpTestAbortControllers[identifier];
}),
false,
n('testMcpConnection/error'),
);
return { error: errorMessage, success: false };
return { error: originalMessage, errorLog, success: false };
}
},
@@ -834,7 +849,7 @@ export const createMCPPluginStoreSlice: StateCreator<
event: 'uninstall',
identifier: identifier,
source: 'self',
})
});
},
updateMCPInstallProgress: (identifier, progress) => {