本文是MCP协议实战系列的第二篇。为了更好地理解内容,建议读者按以下顺序阅读:
- 如果你是MCP协议的新手,请先学习基础理论篇,打好知识基础;
MCP(Model Context Protocol) 专栏https://blog.csdn.net/aiqlcom/category_12851864.html
- 如果你已经掌握MCP的基本原理,建议从实战篇第一篇开始,熟悉相关组件和功能;
MCP(Model Context Protocol) 模型上下文协议 实战篇1_mcp协议内容-CSDN博客
本篇将在实战篇1的基础上进行代码重构,提取公共逻辑,虽然提高了代码的复用性,但也增加了代码的复杂度。因此,不建议直接从这里开始学习。
对自己有信心的同学,可以跳过后面所有内容直接查看本文对应的代码:
如果不想跳过,那么开始正文:
简介
本文将致力于进一步开发,以实现与多种不同格式的MCP服务器的对接。我们将使用:
servers/src/everything at main · modelcontextprotocol/servers · GitHub
这个官方的测试工具合集作为被测server,它涵盖了多种范例和工具,能够帮助我们全面验证对接功能的兼容性和稳定性。通过这一开发过程,我们旨在提升系统的灵活性和扩展性,确保其能够无缝集成各种MCP服务器。
客户端(Client)
在上一篇文章中,我们在客户端中定义了两个异步函数 listTools
和 callTools
,分别用于列出工具和调用工具。然而,为了支持更多的功能(例如 resources
和 prompts
),我们需要对这一部分进行抽象,以避免为每个功能单独编写类似的函数。
为此,我们定义了一个统一的 manageRequests
函数,用于处理所有的请求。这个函数通过接收客户端实例、请求方法、数据模式(schema)以及可选参数,来动态地构建和执行请求。以下是代码的优化和解释:
/**
* 统一管理请求的函数
* @param client 客户端实例,用于发送请求
* @param method 请求方法(例如 "listTools" 或 "callTools")
* @param schema 数据模式,用于验证请求和响应的数据结构
* @param params 可选参数,传递给请求的具体参数
* @returns 返回请求的结果
*/
export async function manageRequests(
client: Client,
method: string,
schema: any,
params?: any
) {
// 构建请求对象,包含请求方法和可选参数
const requestObject = {
method: method,
...(params && { params: params }) // 如果存在参数,则添加到请求对象中
};
// 使用客户端发送请求,并传入数据模式进行验证
const result = await client.request(requestObject, schema);
// 打印请求结果(用于调试)
console.log(result);
// 返回请求结果
return result;
}
代码优化点:
- 抽象化:通过将请求逻辑抽象到
manageRequests
函数中,避免了为每个功能重复编写类似的代码,提高了代码的复用性和可维护性。 - 动态参数:通过
params
参数,支持动态传递请求所需的参数,使函数更加灵活。 - 日志记录:在控制台打印请求结果,便于调试和跟踪请求的执行情况。
- 类型注释:添加了详细的注释,解释了函数的参数和返回值,提高了代码的可读性。
主进程(Main)
在主进程的initClient
函数中,我们负责初始化多个 MCP 服务器的客户端。为了确保初始化的高效性和健壮性,我们采用了并发初始化的方式,并为每个客户端的初始化过程设置了超时机制,以防止因配置错误或网络问题导致的长时间阻塞。同时,我们还动态获取了每个服务器的能力(Capabilities),以便后续根据服务器的能力进行相应的操作。以下是代码的优化和解释:
/**
* 初始化所有 MCP 服务器的客户端
* @param config 配置文件,包含所有 MCP 服务器的配置信息
* @returns 返回一个包含客户端名称、客户端实例和服务器能力的数组
*/
async function initClient(config: { mcpServers: Record<string, any> }) {
// 使用 Promise.all 并发初始化所有客户端
const clients = await Promise.all(
Object.entries(config.mcpServers).map(async ([name, serverConfig]) => {
console.log(`正在初始化客户端 ${name},配置为:`, serverConfig);
// 设置超时机制,防止初始化过程无限期阻塞
const timeoutPromise = new Promise<Client>((resolve, reject) => {
setTimeout(() => {
reject(new Error(`客户端 ${name} 初始化超时,超过 10 秒未完成`));
}, 10000); // 10 秒超时
});
// 使用 Promise.race 在初始化完成或超时之间竞争
const client = await Promise.race([
initializeClient(name, serverConfig), // 初始化客户端
timeoutPromise, // 超时控制
]);
console.log(`${name} 初始化完成。`);
// 获取服务器的能力(Capabilities)
const capabilities = client.getServerCapabilities();
// 返回客户端名称、客户端实例和服务器能力
return { name, client, capabilities };
})
);
return clients;
}
代码优化点:
- 并发初始化:使用
Promise.all
并发初始化所有客户端,提高了初始化的效率。 - 超时机制:为每个客户端的初始化过程设置了 10 秒的超时,防止因配置错误或网络问题导致的长时间阻塞。
- 动态获取能力:在初始化完成后,动态获取服务器的能力(Capabilities),以便后续根据服务器的能力进行相应的操作。
至此,Client的初始化及能力获取已全部完成,对应的消息交互流程如下:
接着,在主进程中,我们需要将 MCP 服务器的功能(features)批量暴露给渲染进程,以便渲染进程可以通过 IPC(进程间通信)调用这些功能。为了实现这一点,我们定义了一个registerHandler
函数,用于注册 IPC 事件处理器,并根据服务器的能力动态暴露相应的功能。以下是代码的优化和解释:
/**
* 注册 IPC 事件处理器
* @param method 方法名称(例如 "tools/list" 或 "prompts/get")
* @param schema 数据模式,用于验证请求和响应的数据结构
* @returns 返回注册的事件名称
*/
const registerHandler = (method: string, schema: any) => {
// 构造唯一的事件名称,格式为 "客户端名称-方法名称"
const eventName = `${name}-${method}`;
console.log(`注册 IPC Main 事件:${eventName}`);
// 注册 IPC 事件处理器
ipcMain.handle(eventName, async (event, params) => {
// 调用统一的请求管理函数
return await manageRequests(client, method, schema, params);
});
// 返回注册的事件名称
return eventName;
};
// 定义能力对应的数据模式
const capabilitySchemas = {
tools: {
list: ListToolsResultSchema, // 列出工具的结果模式
call: CallToolResultSchema, // 调用工具的结果模式
},
prompts: {
list: ListPromptsResultSchema, // 列出提示词的结果模式
get: GetPromptResultSchema, // 获取提示词的结果模式
},
resources: {
list: ListResourcesResultSchema, // 列出资源的结果模式
read: ReadResourceResultSchema, // 读取资源的结果模式
},
};
// 根据服务器的能力动态注册 IPC 事件
for (const [type, actions] of Object.entries(capabilitySchemas)) {
if (capabilities?.[type]) { // 检查服务器是否支持该能力
feature[type] = {}; // 初始化该能力的操作对象
for (const [action, schema] of Object.entries(actions)) {
// 注册 IPC 事件,并保存事件名称
feature[type][action] = registerHandler(`${type}/${action}`, schema);
}
}
}
代码优化点:
- 模块化设计:将 IPC 事件处理器的注册逻辑封装到
registerHandler
函数中,提高了代码的复用性和可维护性。 - 动态注册:根据服务器的能力动态注册 IPC 事件,确保只暴露服务器支持的功能。
- 数据结构化:使用
capabilitySchemas
对象集中管理能力对应的数据模式,使代码更加清晰和易于扩展。
预加载脚本(Preload)
在preload
脚本中,我们需要将主进程中注册的 IPC 事件暴露给渲染进程,以便渲染进程可以通过这些 API 调用 MCP 服务器的功能。为了实现这一点,我们定义了一个exposeAPIs
函数,动态创建 API 方法并通过contextBridge
将其暴露到渲染进程的全局作用域中。以下是代码的优化和解释:
/**
* 将 MCP 服务器的 API 暴露给渲染进程
*/
async function exposeAPIs() {
// 获取所有已初始化的客户端
const clients = await listClients();
const api: MCPAPI = {};
/**
* 创建 API 方法
* @param methods 包含方法名称的对象(例如 { list: "server1-tools/list", call: "server1-tools/call" })
* @returns 返回一个包含 API 方法的对象
*/
const createAPIMethods = (methods: Record<string, string>) => {
const result: Record<string, (...args: any) => Promise<any>> = {};
Object.keys(methods).forEach((key) => {
const methodName = methods[key];
// 将每个方法映射到 ipcRenderer.invoke,以便调用主进程的 IPC 事件
result[key] = (...args: any) => ipcRenderer.invoke(methodName, ...args);
});
return result;
};
// 遍历所有客户端,动态创建 API
clients.forEach((client) => {
const { name, tools, prompts, resources } = client;
api[name] = {};
// 如果客户端支持 tools,则创建 tools 相关的 API
if (tools) {
api[name]["tools"] = createAPIMethods(tools);
}
// 如果客户端支持 prompts,则创建 prompts 相关的 API
if (prompts) {
api[name]["prompts"] = createAPIMethods(prompts);
}
// 如果客户端支持 resources,则创建 resources 相关的 API
if (resources) {
api[name]["resources"] = createAPIMethods(resources);
}
});
// 将 API 暴露到渲染进程的全局作用域中
contextBridge.exposeInMainWorld("mcpServers", api);
}
// 执行函数,暴露 API
exposeAPIs();
代码优化点:
- 模块化设计:将 API 方法的创建逻辑封装到
createAPIMethods
函数中,提高了代码的复用性和可维护性。 - 动态创建 API:根据客户端支持的能力(如
tools
、prompts
、resources
),动态创建相应的 API 方法。 - 类型安全:使用 TypeScript 类型注解,确保代码的类型安全。
这里其实有更加精简的写法,但为了保持后续开发的扩展性,如果需要支持其他能力(如客户端的 sampling
),只需在 clients
对象中添加相应的能力,并在 exposeAPIs
函数中继续添加即可。
渲染端(Renderer)
由于实战篇1已经实现了大部分核心功能,当前渲染端的主要任务是增强这些功能的可视化呈现。例如,在Agent对话框中,可以通过调用list命令来展示当前支持的所有工具,从而提升用户体验:
在Prompts对话框中,用户可以通过调用list
命令查看当前支持的所有提示模板。
点击某个模板后,系统会自动将其填入输入框,并支持用户填写相应参数,快速生成填充后的完整模板内容。
当我选择了complex_prompt并填入相应参数后,系统会从everything这个MCP服务器中获取其预设的提示模板,并自动填充生成最终内容。
在实践过程中,我们发现了几个关键问题:
- 多模态识别能力有限:即使是GPT这样的先进模型,在处理多模态数据(如图片)时表现仍然不足,通常会直接提示无法直接读取图片内容。
- 后端兼容性问题:以Qwen为例,其官方文档明确指出,对于
image_url
等多模态格式的JSON定义与OpenAI的标准不兼容,这可能导致集成时出现错误。
基于以上问题,为了确保工作流(workflow)或智能体(agent)的稳定性和可靠性,目前建议在设计和构建时优先采用纯文本格式,避免因多模态数据处理带来的潜在风险。(说白了就是Agent纯依赖LLM自身能力,我辛辛苦苦把渲染端支持了多模态,结果LLM本身对于多模态的支持一言难尽,我白干了。。。)
总结
本文详细介绍了如何实现与多种格式的 MCP 服务器对接,并通过主进程和渲染进程的协作,将服务器的功能暴露给前端。以下是核心内容的总结:
统一请求管理:定义了 manageRequests
函数,用于统一处理所有请求,支持动态传递方法和参数,并通过数据模式(schema)验证请求和响应。
客户端初始化:在主进程中使用 initClient
函数并发初始化多个 MCP 客户端,设置超时机制防止阻塞,并动态获取服务器的能力(Capabilities)。
IPC 事件注册:通过 registerHandler
函数动态注册 IPC 事件,将服务器的功能(如 tools
、prompts
、resources
)暴露给渲染进程,支持根据服务器能力灵活调整。
API 暴露:在 preload
脚本中使用 exposeAPIs
函数,将 IPC 事件映射为渲染进程可调用的 API,并通过 contextBridge
安全地暴露到前端。
代码优化与扩展性:通过模块化设计、动态逻辑和详细注释,提高了代码的复用性、可读性和可维护性。支持未来扩展其他能力(如 sampling
),只需简单添加对应的逻辑即可。
最后,依然保持着良好的习惯,所有代码均已同步至 GitHub 仓库:
如果你有需要,可以直接查看。如果觉得项目对你有帮助,欢迎点个 ⭐️ 支持,非常感谢!