MCP(Model Context Protocol)模型上下文协议 实战篇2

本文是MCP协议实战系列的第二篇。为了更好地理解内容,建议读者按以下顺序阅读:

  • 如果你是MCP协议的新手,请先学习基础理论篇,打好知识基础;

MCP(Model Context Protocol) 专栏icon-default.png?t=O83Ahttps://blog.csdn.net/aiqlcom/category_12851864.html

  • 如果你已经掌握MCP的基本原理,建议从实战篇第一篇开始,熟悉相关组件和功能;

MCP(Model Context Protocol) 模型上下文协议 实战篇1_mcp协议内容-CSDN博客

本篇将在实战篇1的基础上进行代码重构,提取公共逻辑,虽然提高了代码的复用性,但也增加了代码的复杂度。因此,不建议直接从这里开始学习。

对自己有信心的同学,可以跳过后面所有内容直接查看本文对应的代码:

GitHub - AI-QL/chat-mcp: An example of a framework that leverages MCP(Model Context Protocol) to interface with other LLMs.

如果不想跳过,那么开始正文:

简介

本文将致力于进一步开发,以实现与多种不同格式的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;
}

代码优化点:

  1. 抽象化:通过将请求逻辑抽象到 manageRequests 函数中,避免了为每个功能重复编写类似的代码,提高了代码的复用性和可维护性。
  2. 动态参数:通过 params 参数,支持动态传递请求所需的参数,使函数更加灵活。
  3. 日志记录:在控制台打印请求结果,便于调试和跟踪请求的执行情况。
  4. 类型注释:添加了详细的注释,解释了函数的参数和返回值,提高了代码的可读性。

主进程(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;
}

代码优化点:

  1. 并发初始化:使用 Promise.all 并发初始化所有客户端,提高了初始化的效率。
  2. 超时机制:为每个客户端的初始化过程设置了 10 秒的超时,防止因配置错误或网络问题导致的长时间阻塞。
  3. 动态获取能力:在初始化完成后,动态获取服务器的能力(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);
    }
  }
}

代码优化点:

  1. 模块化设计:将 IPC 事件处理器的注册逻辑封装到 registerHandler 函数中,提高了代码的复用性和可维护性。
  2. 动态注册:根据服务器的能力动态注册 IPC 事件,确保只暴露服务器支持的功能。
  3. 数据结构化:使用 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();

代码优化点:

  1. 模块化设计:将 API 方法的创建逻辑封装到 createAPIMethods 函数中,提高了代码的复用性和可维护性。
  2. 动态创建 API:根据客户端支持的能力(如 toolspromptsresources),动态创建相应的 API 方法。
  3. 类型安全:使用 TypeScript 类型注解,确保代码的类型安全。

这里其实有更加精简的写法,但为了保持后续开发的扩展性,如果需要支持其他能力(如客户端的 sampling),只需在 clients 对象中添加相应的能力,并在 exposeAPIs 函数中继续添加即可。

渲染端(Renderer)

由于实战篇1已经实现了大部分核心功能,当前渲染端的主要任务是增强这些功能的可视化呈现。例如,在Agent对话框中,可以通过调用list命令来展示当前支持的所有工具,从而提升用户体验:

Prompts对话框中,用户可以通过调用list命令查看当前支持的所有提示模板。

点击某个模板后,系统会自动将其填入输入框,并支持用户填写相应参数,快速生成填充后的完整模板内容。

当我选择了complex_prompt并填入相应参数后,系统会从everything这个MCP服务器中获取其预设的提示模板,并自动填充生成最终内容。

在实践过程中,我们发现了几个关键问题:

  1. 多模态识别能力有限:即使是GPT这样的先进模型,在处理多模态数据(如图片)时表现仍然不足,通常会直接提示无法直接读取图片内容。
  2. 后端兼容性问题:以Qwen为例,其官方文档明确指出,对于image_url等多模态格式的JSON定义与OpenAI的标准不兼容,这可能导致集成时出现错误。

基于以上问题,为了确保工作流(workflow)或智能体(agent)的稳定性和可靠性,目前建议在设计和构建时优先采用纯文本格式,避免因多模态数据处理带来的潜在风险。(说白了就是Agent纯依赖LLM自身能力,我辛辛苦苦把渲染端支持了多模态,结果LLM本身对于多模态的支持一言难尽,我白干了。。。)

总结

本文详细介绍了如何实现与多种格式的 MCP 服务器对接,并通过主进程和渲染进程的协作,将服务器的功能暴露给前端。以下是核心内容的总结:

统一请求管理:定义了 manageRequests 函数,用于统一处理所有请求,支持动态传递方法和参数,并通过数据模式(schema)验证请求和响应。

客户端初始化:在主进程中使用 initClient 函数并发初始化多个 MCP 客户端,设置超时机制防止阻塞,并动态获取服务器的能力(Capabilities)。

IPC 事件注册:通过 registerHandler 函数动态注册 IPC 事件,将服务器的功能(如 toolspromptsresources)暴露给渲染进程,支持根据服务器能力灵活调整。

API 暴露:在 preload 脚本中使用 exposeAPIs 函数,将 IPC 事件映射为渲染进程可调用的 API,并通过 contextBridge 安全地暴露到前端。

代码优化与扩展性:通过模块化设计、动态逻辑和详细注释,提高了代码的复用性、可读性和可维护性。支持未来扩展其他能力(如 sampling),只需简单添加对应的逻辑即可。

最后,依然保持着良好的习惯,所有代码均已同步至 GitHub 仓库:

GitHub - AI-QL/chat-mcp: An example of a framework that leverages MCP(Model Context Protocol) to interface with other LLMs.

如果你有需要,可以直接查看。如果觉得项目对你有帮助,欢迎点个 ⭐️ 支持,非常感谢!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值