@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
411 lines (355 loc) • 13.4 kB
text/typescript
import { LobeChatPluginApi, LobeChatPluginManifest, PluginSchema } from '@lobehub/chat-plugin-sdk';
import { DeploymentOption } from '@lobehub/market-sdk';
import { McpError } from '@modelcontextprotocol/sdk/types.js';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import {
MCPClient,
MCPClientParams,
McpPrompt,
McpResource,
McpTool,
StdioMCPParams,
} from '@/libs/mcp';
import { mcpSystemDepsCheckService } from '@/server/services/mcp/deps';
import { CheckMcpInstallResult } from '@/types/plugins';
import { CustomPluginMetadata } from '@/types/tool/plugin';
import { safeParseJSON } from '@/utils/safeParseJSON';
const log = debug('lobe-mcp:service');
// Removed MCPConnection interface as it's no longer needed
class MCPService {
// Store instances of the custom MCPClient, keyed by serialized MCPClientParams
private clients: Map<string, MCPClient> = new Map();
private sanitizeForLogging = <T extends Record<string, any>>(obj: T): Omit<T, 'env'> => {
if (!obj) return obj;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { env: _, ...rest } = obj;
return rest as Omit<T, 'env'>;
};
// --- MCP Interaction ---
// listTools now accepts MCPClientParams
async listTools(params: MCPClientParams): Promise<LobeChatPluginApi[]> {
const client = await this.getClient(params); // Get client using params
const loggableParams = this.sanitizeForLogging(params);
log(`Listing tools using client for params: %O`, loggableParams);
try {
const result = await client.listTools();
log(
`Tools listed successfully for params: %O, result count: %d`,
loggableParams,
result.length,
);
return result.map<LobeChatPluginApi>((item) => ({
// Assuming identifier is the unique name/id
description: item.description,
name: item.name,
parameters: item.inputSchema as PluginSchema,
}));
} catch (error) {
console.error(`Error listing tools for params %O:`, loggableParams, error);
// Propagate a TRPCError for better handling upstream
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: `Error listing tools from MCP server: ${(error as Error).message}`,
});
}
}
// listTools now accepts MCPClientParams
async listRawTools(params: MCPClientParams): Promise<McpTool[]> {
const client = await this.getClient(params); // Get client using params
const loggableParams = this.sanitizeForLogging(params);
log(`Listing tools using client for params: %O`, loggableParams);
try {
const result = await client.listTools();
log(
`Tools listed successfully for params: %O, result count: %d`,
loggableParams,
result.length,
);
return result;
} catch (error) {
console.error(`Error listing tools for params %O:`, loggableParams, error);
// Propagate a TRPCError for better handling upstream
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: `Error listing tools from MCP server: ${(error as Error).message}`,
});
}
}
// listResources now accepts MCPClientParams
async listResources(params: MCPClientParams): Promise<McpResource[]> {
const client = await this.getClient(params); // Get client using params
const loggableParams = this.sanitizeForLogging(params);
log(`Listing resources using client for params: %O`, loggableParams);
try {
const result = await client.listResources();
log(
`Resources listed successfully for params: %O, result count: %d`,
loggableParams,
result.length,
);
return result;
} catch (error) {
console.error(`Error listing resources for params %O:`, loggableParams, error);
// Propagate a TRPCError for better handling upstream
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: `Error listing resources from MCP server: ${(error as Error).message}`,
});
}
}
// listPrompts now accepts MCPClientParams
async listPrompts(params: MCPClientParams): Promise<McpPrompt[]> {
const client = await this.getClient(params); // Get client using params
const loggableParams = this.sanitizeForLogging(params);
log(`Listing prompts using client for params: %O`, loggableParams);
try {
const result = await client.listPrompts();
log(
`Prompts listed successfully for params: %O, result count: %d`,
loggableParams,
result.length,
);
return result;
} catch (error) {
console.error(`Error listing prompts for params %O:`, loggableParams, error);
// Propagate a TRPCError for better handling upstream
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: `Error listing prompts from MCP server: ${(error as Error).message}`,
});
}
}
// callTool now accepts MCPClientParams, toolName, and args
async callTool(params: MCPClientParams, toolName: string, argsStr: any): Promise<any> {
const client = await this.getClient(params); // Get client using params
const args = safeParseJSON(argsStr);
const loggableParams = this.sanitizeForLogging(params);
log(
`Calling tool "${toolName}" using client for params: %O with args: %O`,
loggableParams,
args,
);
try {
// Delegate the call to the MCPClient instance
const result = await client.callTool(toolName, args); // Pass args directly
log(
`Tool "${toolName}" called successfully for params: %O, result: %O`,
loggableParams,
result,
);
const { content, isError } = result;
if (isError) return result;
const data = content as { text: string; type: 'text' }[];
const text = data?.[0]?.text;
if (!text) return data;
// try to get json object, which will be stringify in the client
const json = safeParseJSON(text);
if (json) return json;
return text;
} catch (error) {
if (error instanceof McpError) {
const mcpError = error as McpError;
return mcpError.message;
}
console.error(
`Error calling tool "${toolName}" for params %O:`,
this.sanitizeForLogging(params),
error,
);
// Propagate a TRPCError
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: `Error calling tool "${toolName}" on MCP server: ${(error as Error).message}`,
});
}
}
// Private method to get or initialize a client based on parameters
private async getClient(params: MCPClientParams): Promise<MCPClient> {
const key = this.serializeParams(params); // Use custom serialization
if (this.clients.has(key)) {
return this.clients.get(key)!;
}
log(`No cached client found, Initializing new client.`);
try {
const client = new MCPClient(params);
await client.initialize({
onProgress: (progress) => {
log(`New client initializing... ${progress.progress}/${progress.total}`);
},
}); // Initialization logic should be within MCPClient
this.clients.set(key, client);
log(`New client initialized and cached for key: ${key.slice(0, 20)}`);
return client;
} catch (error) {
console.error(`Failed to initialize MCP client:`, error);
// 保留完整的错误信息,特别是详细的 stderr 输出
const errorMessage = error instanceof Error ? error.message : String(error);
if (typeof error === 'object' && !!error && 'data' in error) {
throw new TRPCError({
cause: error,
code: 'SERVICE_UNAVAILABLE',
message: errorMessage,
});
}
// 记录详细的错误信息用于调试
log('Detailed initialization error: %O', {
error: errorMessage,
params: this.sanitizeForLogging(params),
stack: error instanceof Error ? error.stack : undefined,
});
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: errorMessage, // 直接使用完整的错误信息
});
}
}
// Custom serialization function to ensure consistent keys
private serializeParams(params: MCPClientParams): string {
const sortedKeys = Object.keys(params).sort();
const sortedParams: Record<string, any> = {};
for (const key of sortedKeys) {
const value = (params as any)[key];
// Sort the 'args' array if it exists
if (key === 'args' && Array.isArray(value)) {
sortedParams[key] = JSON.stringify(key);
} else {
sortedParams[key] = value;
}
}
return JSON.stringify(sortedParams);
}
async getStreamableMcpServerManifest(
identifier: string,
url: string,
metadata?: CustomPluginMetadata,
auth?: {
accessToken?: string;
token?: string;
type: 'none' | 'bearer' | 'oauth2';
},
headers?: Record<string, string>,
): Promise<LobeChatPluginManifest> {
const mcpParams = { name: identifier, type: 'http' as const, url };
// 如果有认证信息,添加到参数中
if (auth) {
(mcpParams as any).auth = auth;
}
// 如果有 headers 信息,添加到参数中
if (headers) {
(mcpParams as any).headers = headers;
}
const tools = await this.listTools(mcpParams);
return {
api: tools,
identifier,
meta: {
avatar: metadata?.avatar || 'MCP_AVATAR',
description:
metadata?.description ||
`${identifier} MCP server has ${tools.length} tools, like "${tools[0]?.name}"`,
title: identifier,
},
// TODO: temporary
type: 'mcp' as any,
};
}
async getStdioMcpServerManifest(
params: Omit<StdioMCPParams, 'type'>,
metadata?: CustomPluginMetadata,
): Promise<LobeChatPluginManifest> {
const client = await this.getClient({
args: params.args,
command: params.command,
env: params.env,
name: params.name,
type: 'stdio',
}); // Get client using params
const manifest = await client.listManifests();
const identifier = params.name;
return {
api: manifest.tools ? this.transformMCPToolToLobeAPI(manifest.tools) : [],
identifier,
meta: {
avatar: metadata?.avatar || 'MCP_AVATAR',
description:
metadata?.description ||
`${identifier} MCP server has ` +
Object.entries(manifest)
.filter(([key]) => ['tools', 'prompts', 'resources'].includes(key))
.map(([key, item]) => `${(item as Array<any>)?.length} ${key}`)
.join(','),
title: metadata?.name || identifier,
},
...manifest,
// TODO: temporary
type: 'mcp' as any,
} as LobeChatPluginManifest;
}
/**
* Check MCP plugin installation status
*/
async checkMcpInstall(input: {
deploymentOptions: DeploymentOption[];
}): Promise<CheckMcpInstallResult> {
try {
const loggableInput = {
deploymentOptions: input.deploymentOptions.map((o) => this.sanitizeForLogging(o)),
};
log('Checking MCP plugin installation status: %O', loggableInput);
const results = [];
// 检查每个部署选项
for (const option of input.deploymentOptions) {
// 使用系统依赖检查服务检查部署选项
const result = await mcpSystemDepsCheckService.checkDeployOption(option);
results.push(result);
}
// 找出推荐的或第一个可安装的选项
const recommendedResult = results.find((r) => r.isRecommended && r.allDependenciesMet);
const firstInstallableResult = results.find((r) => r.allDependenciesMet);
// 返回推荐的结果,或第一个可安装的结果,或第一个结果
const bestResult = recommendedResult || firstInstallableResult || results[0];
log('Check completed, best result: %O', bestResult);
// 构造返回结果,确保包含配置检查信息
const checkResult: CheckMcpInstallResult = {
...bestResult,
allOptions: results,
platform: process.platform,
success: true,
};
// 如果最佳结果需要配置,确保在顶层设置相关字段
if (bestResult?.needsConfig) {
checkResult.needsConfig = true;
checkResult.configSchema = bestResult.configSchema;
log('Configuration required for best deployment option: %O', bestResult.configSchema);
}
return checkResult;
} catch (error) {
log('Check failed: %O', error);
return {
error:
error instanceof Error
? error.message
: 'Unknown error when checking MCP plugin installation status',
platform: process.platform,
success: false,
};
}
}
private transformMCPToolToLobeAPI = (data: McpTool[]) => {
return data.map<LobeChatPluginApi>((item) => ({
// Assuming identifier is the unique name/id
description: item.description,
name: item.name,
parameters: item.inputSchema as PluginSchema,
}));
};
}
// Export a singleton instance
export const mcpService = new MCPService();