fix: resolve race conditions in initializeClientsFromSettings (#201)

This commit is contained in:
samanhappy
2025-06-28 22:11:14 +08:00
committed by GitHub
parent adabf1d92b
commit 89f85c73ff
4 changed files with 232 additions and 132 deletions

View File

@@ -18,9 +18,10 @@ interface DynamicFormProps {
onCancel: () => void; onCancel: () => void;
loading?: boolean; loading?: boolean;
storageKey?: string; // Optional key for localStorage persistence storageKey?: string; // Optional key for localStorage persistence
title?: string; // Optional title to display instead of default parameters title
} }
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey }) => { const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [formValues, setFormValues] = useState<Record<string, any>>({}); const [formValues, setFormValues] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
@@ -624,15 +625,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Mode Toggle */} {/* Mode Toggle */}
<div className="flex justify-between items-center border-b pb-3"> <div className="flex justify-between items-center pb-3">
<h3 className="text-lg font-medium text-gray-900">{t('tool.parameters')}</h3> <h6 className="text-md font-medium text-gray-900">{title}</h6>
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <button
type="button" type="button"
onClick={switchToFormMode} onClick={switchToFormMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'
}`} }`}
> >
{t('tool.formMode')} {t('tool.formMode')}
@@ -642,7 +643,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
onClick={switchToJsonMode} onClick={switchToJsonMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'
}`} }`}
> >
{t('tool.jsonMode')} {t('tool.jsonMode')}
@@ -671,7 +672,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200" className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded-md hover:bg-gray-300"
> >
{t('tool.cancel')} {t('tool.cancel')}
</button> </button>
@@ -685,7 +686,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
} }
}} }}
disabled={loading || !!jsonError} disabled={loading || !!jsonError}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50" className="px-4 py-1 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
> >
{loading ? t('tool.running') : t('tool.runTool')} {loading ? t('tool.running') : t('tool.runTool')}
</button> </button>
@@ -702,14 +703,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200" className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded-md hover:bg-gray-300"
> >
{t('tool.cancel')} {t('tool.cancel')}
</button> </button>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50" className="px-4 py-1 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
> >
{loading ? t('tool.running') : t('tool.runTool')} {loading ? t('tool.running') : t('tool.runTool')}
</button> </button>

View File

@@ -229,13 +229,13 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
{/* Run Form */} {/* Run Form */}
{showRunForm && ( {showRunForm && (
<div className="border border-gray-300 rounded-lg p-4 bg-blue-50"> <div className="border border-gray-300 rounded-lg p-4 bg-blue-50">
<h4 className="text-sm font-medium text-gray-900 mb-3">{t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}</h4>
<DynamicForm <DynamicForm
schema={tool.inputSchema || { type: 'object' }} schema={tool.inputSchema || { type: 'object' }}
onSubmit={handleRunTool} onSubmit={handleRunTool}
onCancel={handleCancelRun} onCancel={handleCancelRun}
loading={isRunning} loading={isRunning}
storageKey={getStorageKey()} storageKey={getStorageKey()}
title={t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}
/> />
{/* Tool Result */} {/* Tool Result */}
{result && ( {result && (

View File

@@ -303,7 +303,7 @@
"tool": { "tool": {
"run": "运行", "run": "运行",
"running": "运行中...", "running": "运行中...",
"runTool": "运行工具", "runTool": "运行",
"cancel": "取消", "cancel": "取消",
"noDescription": "无描述信息", "noDescription": "无描述信息",
"inputSchema": "输入模式:", "inputSchema": "输入模式:",

View File

@@ -101,6 +101,187 @@ export const syncToolEmbedding = async (serverName: string, toolName: string) =>
// Store all server information // Store all server information
let serverInfos: ServerInfo[] = []; let serverInfos: ServerInfo[] = [];
// Helper function to create transport based on server configuration
const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
let transport;
if (conf.type === 'streamable-http') {
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.requestInit = {
headers: conf.headers,
};
}
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
} else if (conf.url) {
// SSE transport
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.eventSourceInit = {
headers: conf.headers,
};
options.requestInit = {
headers: conf.headers,
};
}
transport = new SSEClientTransport(new URL(conf.url), options);
} else if (conf.command && conf.args) {
// Stdio transport
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...replaceEnvVars(conf.env || {}),
};
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
const settings = loadSettings();
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
if (
settings.systemConfig?.install?.pythonIndexUrl &&
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
) {
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
}
if (
settings.systemConfig?.install?.npmRegistry &&
(conf.command === 'npm' ||
conf.command === 'npx' ||
conf.command === 'pnpm' ||
conf.command === 'yarn' ||
conf.command === 'node')
) {
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
}
transport = new StdioClientTransport({
command: conf.command,
args: conf.args,
env: env,
stderr: 'pipe',
});
transport.stderr?.on('data', (data) => {
console.log(`[${name}] [child] ${data}`);
});
} else {
throw new Error(`Unable to create transport for server: ${name}`);
}
return transport;
};
// Helper function to handle client.callTool with reconnection logic
const callToolWithReconnect = async (
serverInfo: ServerInfo,
toolParams: any,
options?: any,
maxRetries: number = 1,
): Promise<any> => {
if (!serverInfo.client) {
throw new Error(`Client not found for server: ${serverInfo.name}`);
}
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
return result;
} catch (error: any) {
// Check if error message starts with "Error POSTing to endpoint (HTTP 40"
const isHttp40xError = error?.message?.startsWith?.('Error POSTing to endpoint (HTTP 40');
// Only retry for StreamableHTTPClientTransport
const isStreamableHttp = serverInfo.transport instanceof StreamableHTTPClientTransport;
if (isHttp40xError && attempt < maxRetries && serverInfo.transport && isStreamableHttp) {
console.warn(
`HTTP 40x error detected for StreamableHTTP server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
);
try {
// Close existing connection
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
serverInfo.keepAliveIntervalId = undefined;
}
serverInfo.client.close();
serverInfo.transport.close();
// Get server configuration to recreate transport
const settings = loadSettings();
const conf = settings.mcpServers[serverInfo.name];
if (!conf) {
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
}
// Recreate transport using helper function
const newTransport = createTransportFromConfig(serverInfo.name, conf);
// Create new client
const client = new Client(
{
name: `mcp-client-${serverInfo.name}`,
version: '1.0.0',
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
},
},
);
// Reconnect with new transport
await client.connect(newTransport, serverInfo.options || {});
// Update server info with new client and transport
serverInfo.client = client;
serverInfo.transport = newTransport;
serverInfo.status = 'connected';
// Reload tools list after reconnection
try {
const tools = await client.listTools({}, serverInfo.options || {});
serverInfo.tools = tools.tools.map((tool) => ({
name: `${serverInfo.name}-${tool.name}`,
description: tool.description || '',
inputSchema: tool.inputSchema || {},
}));
// Save tools as vector embeddings for search
saveToolsAsVectorEmbeddings(serverInfo.name, serverInfo.tools);
} catch (listToolsError) {
console.warn(
`Failed to reload tools after reconnection for server ${serverInfo.name}:`,
listToolsError,
);
// Continue anyway, as the connection might still work for the current tool
}
console.log(`Successfully reconnected to server: ${serverInfo.name}`);
// Continue to next attempt
continue;
} catch (reconnectError) {
console.error(`Failed to reconnect to server ${serverInfo.name}:`, reconnectError);
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to reconnect: ${reconnectError}`;
// If this was the last attempt, throw the original error
if (attempt === maxRetries) {
throw error;
}
}
} else {
// Not an HTTP 40x error or no more retries, throw the original error
throw error;
}
}
}
// This should not be reached, but just in case
throw new Error('Unexpected error in callToolWithReconnect');
};
// Initialize MCP server clients // Initialize MCP server clients
export const initializeClientsFromSettings = async (isInit: boolean): Promise<ServerInfo[]> => { export const initializeClientsFromSettings = async (isInit: boolean): Promise<ServerInfo[]> => {
const settings = loadSettings(); const settings = loadSettings();
@@ -154,21 +335,21 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
continue; continue;
} }
// Create server info first and keep reference to it
const serverInfo: ServerInfo = {
name,
status: 'connecting',
error: null,
tools: [],
createTime: Date.now(),
enabled: conf.enabled === undefined ? true : conf.enabled,
};
serverInfos.push(serverInfo);
try { try {
// Create OpenAPI client instance // Create OpenAPI client instance
openApiClient = new OpenAPIClient(conf); openApiClient = new OpenAPIClient(conf);
// Add server with connecting status first
const serverInfo: ServerInfo = {
name,
status: 'connecting',
error: null,
tools: [],
createTime: Date.now(),
enabled: conf.enabled === undefined ? true : conf.enabled,
};
serverInfos.push(serverInfo);
console.log(`Initializing OpenAPI server: ${name}...`); console.log(`Initializing OpenAPI server: ${name}...`);
// Perform async initialization // Perform async initialization
@@ -197,91 +378,13 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
} catch (error) { } catch (error) {
console.error(`Failed to initialize OpenAPI server ${name}:`, error); console.error(`Failed to initialize OpenAPI server ${name}:`, error);
// Find and update the server info if it was already added // Update the already pushed server info with error status
const existingServerIndex = serverInfos.findIndex((s) => s.name === name); serverInfo.status = 'disconnected';
if (existingServerIndex !== -1) { serverInfo.error = `Failed to initialize OpenAPI server: ${error}`;
serverInfos[existingServerIndex].status = 'disconnected';
serverInfos[existingServerIndex].error = `Failed to initialize OpenAPI server: ${error}`;
} else {
// Add new server info with error status
serverInfos.push({
name,
status: 'disconnected',
error: `Failed to initialize OpenAPI server: ${error}`,
tools: [],
createTime: Date.now(),
});
}
continue; continue;
} }
} else if (conf.type === 'streamable-http') {
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.requestInit = {
headers: conf.headers,
};
}
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
} else if (conf.url) {
// Default to SSE only when 'conf.type' is not specified and 'conf.url' is available
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.eventSourceInit = {
headers: conf.headers,
};
options.requestInit = {
headers: conf.headers,
};
}
transport = new SSEClientTransport(new URL(conf.url), options);
} else if (conf.command && conf.args) {
// If type is stdio or if command and args are provided without type
const env: Record<string, string> = {
...(process.env as Record<string, string>), // Inherit all environment variables from parent process
...replaceEnvVars(conf.env || {}), // Override with configured env vars
};
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
// Add UV_DEFAULT_INDEX from settings if available (for Python packages)
const settings = loadSettings(); // Add UV_DEFAULT_INDEX from settings if available (for Python packages)
if (
settings.systemConfig?.install?.pythonIndexUrl &&
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
) {
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
}
// Add npm_config_registry from settings if available (for NPM packages)
if (
settings.systemConfig?.install?.npmRegistry &&
(conf.command === 'npm' ||
conf.command === 'npx' ||
conf.command === 'pnpm' ||
conf.command === 'yarn' ||
conf.command === 'node')
) {
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
}
transport = new StdioClientTransport({
command: conf.command,
args: conf.args,
env: env,
stderr: 'pipe',
});
transport.stderr?.on('data', (data) => {
console.log(`[${name}] [child] ${data}`);
});
} else { } else {
console.warn(`Skipping server '${name}': missing required configuration`); transport = createTransportFromConfig(name, conf);
serverInfos.push({
name,
status: 'disconnected',
error: 'Missing required configuration',
tools: [],
createTime: Date.now(),
});
continue;
} }
const client = new Client( const client = new Client(
@@ -312,6 +415,19 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
maxTotalTimeout: serverRequestOptions.maxTotalTimeout, maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
}; };
// Create server info first and keep reference to it
const serverInfo: ServerInfo = {
name,
status: 'connecting',
error: null,
tools: [],
client,
transport,
options: requestOptions,
createTime: Date.now(),
};
serverInfos.push(serverInfo);
client client
.connect(transport, initRequestOptions || requestOptions) .connect(transport, initRequestOptions || requestOptions)
.then(() => { .then(() => {
@@ -320,11 +436,6 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
.listTools({}, initRequestOptions || requestOptions) .listTools({}, initRequestOptions || requestOptions)
.then((tools) => { .then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`); console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
const serverInfo = getServerByName(name);
if (!serverInfo) {
console.warn(`Server info not found for server: ${name}`);
return;
}
serverInfo.tools = tools.tools.map((tool) => ({ serverInfo.tools = tools.tools.map((tool) => ({
name: `${name}-${tool.name}`, name: `${name}-${tool.name}`,
@@ -344,33 +455,17 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
console.error( console.error(
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`, `Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
); );
const serverInfo = getServerByName(name); serverInfo.status = 'disconnected';
if (serverInfo) { serverInfo.error = `Failed to list tools: ${error.stack} `;
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to list tools: ${error.stack} `;
}
}); });
}) })
.catch((error) => { .catch((error) => {
console.error( console.error(
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`, `Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
); );
const serverInfo = getServerByName(name); serverInfo.status = 'disconnected';
if (serverInfo) { serverInfo.error = `Failed to connect: ${error.stack} `;
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to connect: ${error.stack} `;
}
}); });
serverInfos.push({
name,
status: 'connecting',
error: null,
tools: [],
client,
transport,
options: requestOptions,
createTime: Date.now(),
});
console.log(`Initialized client for server: ${name}`); console.log(`Initialized client for server: ${name}`);
} }
@@ -902,12 +997,12 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
toolName = toolName.startsWith(`${targetServerInfo.name}-`) toolName = toolName.startsWith(`${targetServerInfo.name}-`)
? toolName.replace(`${targetServerInfo.name}-`, '') ? toolName.replace(`${targetServerInfo.name}-`, '')
: toolName; : toolName;
const result = await client.callTool( const result = await callToolWithReconnect(
targetServerInfo,
{ {
name: toolName, name: toolName,
arguments: finalArgs, arguments: finalArgs,
}, },
undefined,
targetServerInfo.options || {}, targetServerInfo.options || {},
); );
@@ -957,7 +1052,11 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
request.params.name = request.params.name.startsWith(`${serverInfo.name}-`) request.params.name = request.params.name.startsWith(`${serverInfo.name}-`)
? request.params.name.replace(`${serverInfo.name}-`, '') ? request.params.name.replace(`${serverInfo.name}-`, '')
: request.params.name; : request.params.name;
const result = await client.callTool(request.params, undefined, serverInfo.options || {}); const result = await callToolWithReconnect(
serverInfo,
request.params,
serverInfo.options || {},
);
console.log(`Tool call result: ${JSON.stringify(result)}`); console.log(`Tool call result: ${JSON.stringify(result)}`);
return result; return result;
} catch (error) { } catch (error) {