用 LangChain 连接远程 MCP:从工具发现到多轮调用闭环

用 LangChain 连接远程 MCP:从工具发现到多轮调用闭环

第一次看到 MCP 客户端代码时,很容易把它理解成"让大模型调用一个接口"。但真正写起来,几个问题会立刻冒出来:模型怎么知道服务端有哪些工具?它返回的工具参数由谁执行?执行结果为什么还要放回消息列表?HTTP MCP 和普通 HTTP API 又有什么区别?

这篇文章用一个可运行的 Node.js 示例完成一条最小但完整的链路:连接一个远程 MCP Server,读取它暴露的工具,把工具绑定给聊天模型,执行模型发起的工具调用,再把结果交回模型生成最终回答。

示例使用 LangChain 负责模型与消息编排,使用 @langchain/mcp-adapters 把 MCP 工具转换成 LangChain 能识别的工具。读完之后,你不仅能运行代码,也能说清每一轮数据究竟流向了哪里。

MCP 在这条链路中解决了什么

大模型本身只负责根据上下文生成内容。即使它输出"我要查询某个位置",也不会因此自动访问地图或文件系统。要让模型使用外部能力,应用程序至少要完成三件事:

  1. 告诉模型有哪些工具,以及每个工具需要哪些参数。
  2. 接收模型生成的工具调用请求,并真正执行对应工具。
  3. 把执行结果送回模型,让模型继续判断或组织答案。

MCP(Model Context Protocol)为工具的发现、参数描述和调用方式提供了一套统一协议。地图服务、浏览器控制器和文件系统服务可以分别实现 MCP Server;应用只要实现 MCP Client,就能用相似的方式接入它们。

这里要区分两层概念:

  • MCP 是通信协议:客户端与服务端用它交换工具列表、调用参数和结果。
  • 工具调用是模型能力:模型根据工具描述生成结构化的调用意图,但真正执行工具的仍然是我们的 Node.js 程序。

MCP 常见的传输方式包括 stdio 和 Streamable HTTP。stdio 通常由客户端启动本地子进程,再通过标准输入输出通信;HTTP 则连接已经运行在某个 URL 上的服务,更适合跨机器部署。本文聚焦远程 HTTP 服务,同时会说明如何切换到 stdio

准备项目与环境变量

建议使用 Node.js 20 或更高版本,并创建一个新项目:

bash 复制代码
mkdir remote-mcp-demo
cd remote-mcp-demo
npm init -y
npm install dotenv @langchain/core @langchain/openai @langchain/mcp-adapters
mkdir src

示例文件使用 .mjs 后缀,因此 Node.js 会按 ES Module 处理,可以直接使用 import 和顶层 await,不必额外修改 package.json

在项目根目录创建 .env

env 复制代码
MODEL_API_KEY=your_api_key
MODEL_BASE_URL=https://your-model-provider.example/v1
MODEL_NAME=your-tool-calling-model
MCP_SERVER_URL=https://your-mcp-server.example/mcp

四个值分别表示模型密钥、OpenAI 兼容接口地址、支持工具调用的模型名,以及远程 MCP 端点。不同模型供应商的名称和地址不同,应以实际服务为准。不要把真实 .env 提交到 Git 仓库,可以在 .gitignore 中加入:

text 复制代码
.env

import "dotenv/config" 会在程序启动时读取 .env,并把值放入 process.env。环境变量不存在时,JavaScript 通常只会得到 undefined,错误可能直到发起连接时才出现。因此,与其等待一个含糊的鉴权错误,不如启动时主动校验。

先连接 MCP Server 并发现工具

创建 src/index.mjs,先写连接与工具加载部分:

javascript 复制代码
import "dotenv/config";
import { MultiServerMCPClient } from "@langchain/mcp-adapters";

function requireEnv(name) {
  const value = process.env[name];
  if (!value) {
    throw new Error(`缺少环境变量:${name}`);
  }
  return value;
}

const mcpClient = new MultiServerMCPClient({
  useStandardContentBlocks: true,
  mcpServers: {
    remoteService: {
      transport: "http",
      url: requireEnv("MCP_SERVER_URL"),
    },
  },
});

const tools = await mcpClient.getTools();

console.log(tools.map((tool) => tool.name));

mcpServers 是一个对象,每个属性代表一个逻辑服务。remoteService 只是客户端内部使用的名称,可以换成更贴近业务的名字。服务配置中的 url 是真正的 MCP 端点;transport: "http" 明确表示使用 Streamable HTTP。

await mcpClient.getTools() 会建立连接、向服务端请求工具列表,并把 MCP 工具适配为 LangChain 的结构化工具。这个表达式返回 Promise,所以要用 await 取得最终的工具数组。等待网络响应期间,JavaScript 运行时并没有整体停止:事件循环仍可处理其他任务,只是当前这段顶层模块代码要等 Promise 落定后才能继续。

useStandardContentBlocks: true 会把 MCP 的文本、图片等内容映射成 LangChain 的标准内容块。只处理纯文本时可能感受不到差异,但新应用显式开启它,可以减少以后接入多模态工具时的格式分支。

如果使用本地 stdio 服务,只需把某个服务配置改成下面这样:

javascript 复制代码
localService: {
  transport: "stdio",
  command: "node",
  args: ["/absolute/path/to/server.mjs"],
}

command 是可执行程序,args 是传给它的参数数组。这里应尽量使用服务脚本的绝对路径,避免启动目录变化后出现"文件不存在"。另外,stdio 通道承载协议消息,服务端不要随意向标准输出打印调试文本;日志应写到标准错误。

把工具交给模型,不等于已经执行工具

接下来创建模型并绑定工具:

javascript 复制代码
import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({
  model: requireEnv("MODEL_NAME"),
  apiKey: requireEnv("MODEL_API_KEY"),
  temperature: 0,
  configuration: {
    baseURL: requireEnv("MODEL_BASE_URL"),
  },
});

const modelWithTools = model.bindTools(tools);

ChatOpenAI 不只可连接 OpenAI 官方端点,也常被用于兼容 OpenAI 请求格式的模型服务。兼容不代表所有能力都相同:这里选择的模型必须支持 tool calling,否则模型可能把"调用工具"写成普通文本,或者接口直接拒绝请求。

bindTools(tools) 会把每个工具的名称、说明和参数 Schema 附加到后续模型请求中。Schema 可以理解为一份机器可读的参数说明,例如规定 location 必须是字符串。模型参考这些信息生成类似下面的结构化数据:

json 复制代码
{
  "name": "search_location",
  "args": {
    "location": "西安大唐不夜城"
  },
  "id": "call_123"
}

此时工具仍未执行。bindTools 只是把"工具菜单"给模型看,执行模型选择的工具,是下一步代理循环的职责。

用消息列表完成工具调用闭环

一次问题可能需要多个工具,也可能连续调用同一个工具。例如先查询地点坐标,再搜索附近酒店。因此不能只调用一次模型,而要循环执行,直到模型不再请求工具。

核心函数如下:

javascript 复制代码
import { HumanMessage } from "@langchain/core/messages";

async function runAgent(query, tools, modelWithTools, maxIterations = 10) {
  const messages = [new HumanMessage(query)];

  for (let round = 1; round <= maxIterations; round += 1) {
    console.log(`第 ${round} 轮模型调用`);

    const response = await modelWithTools.invoke(messages);
    messages.push(response);

    const toolCalls = response.tool_calls ?? [];
    if (toolCalls.length === 0) {
      return response.content;
    }

    for (const toolCall of toolCalls) {
      const tool = tools.find((item) => item.name === toolCall.name);

      if (!tool) {
        throw new Error(`模型请求了未知工具:${toolCall.name}`);
      }

      const toolMessage = await tool.invoke(toolCall);
      messages.push(toolMessage);
    }
  }

  throw new Error(`达到最大循环次数 ${maxIterations},任务仍未结束`);
}

messages 保存完整对话状态。它最初只有用户问题,随后依次追加模型回复和工具结果。每次调用 modelWithTools.invoke(messages) 时,模型都会看到此前发生的事情,因此能根据最新工具结果决定是继续调用工具,还是直接回答。

response.tool_calls ?? [] 使用空值合并运算符提供默认数组。只有左侧是 nullundefined 时才采用 [],不会误伤其他合法值。没有工具调用意味着模型已经给出最终回复,此时返回 response.content

循环里的 toolCall 本身是一个对象,至少包含工具名、参数和调用 ID。代码先按名称找到真正的 LangChain 工具,再执行:

javascript 复制代码
const toolMessage = await tool.invoke(toolCall);

这里传入完整 toolCall,而不是只传 toolCall.args,很重要。LangChain 因而能够返回带有关联 ID 的 ToolMessage。模型可能在同一轮请求多个工具,tool_call_id 正是用来说明"这个结果属于哪一次请求"的。手工取出参数再随意拼一个字符串,很容易丢失关联关系或错误处理图片、资源等非文本返回值。

await 最初拿到的是工具调用返回的 Promise;Promise 成功后,变量 toolMessage 才保存实际消息。若 MCP 连接失败或服务端工具报错,Promise 会被拒绝,错误会沿着 runAgent 向调用方传播。

最大循环次数不是为了业务功能,而是一道保险。如果模型反复选择工具却始终不生成答案,程序不会无限消耗请求额度。

完整可运行代码

把前面的部分组合起来,src/index.mjs 内容如下:

javascript 复制代码
import "dotenv/config";
import { MultiServerMCPClient } from "@langchain/mcp-adapters";
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage } from "@langchain/core/messages";

function requireEnv(name) {
  const value = process.env[name];
  if (!value) {
    throw new Error(`缺少环境变量:${name}`);
  }
  return value;
}

async function runAgent(query, tools, modelWithTools, maxIterations = 10) {
  const messages = [new HumanMessage(query)];

  for (let round = 1; round <= maxIterations; round += 1) {
    console.log(`第 ${round} 轮模型调用`);

    const response = await modelWithTools.invoke(messages);
    messages.push(response);

    const toolCalls = response.tool_calls ?? [];
    if (toolCalls.length === 0) {
      return response.content;
    }

    for (const toolCall of toolCalls) {
      const tool = tools.find((item) => item.name === toolCall.name);
      if (!tool) {
        throw new Error(`模型请求了未知工具:${toolCall.name}`);
      }

      console.log(`执行工具:${toolCall.name}`);
      const toolMessage = await tool.invoke(toolCall);
      messages.push(toolMessage);
    }
  }

  throw new Error(`达到最大循环次数 ${maxIterations},任务仍未结束`);
}

const mcpClient = new MultiServerMCPClient({
  useStandardContentBlocks: true,
  mcpServers: {
    remoteService: {
      transport: "http",
      url: requireEnv("MCP_SERVER_URL"),
    },
  },
});

try {
  const tools = await mcpClient.getTools();
  console.log(`已加载 ${tools.length} 个工具`);

  const model = new ChatOpenAI({
    model: requireEnv("MODEL_NAME"),
    apiKey: requireEnv("MODEL_API_KEY"),
    temperature: 0,
    configuration: {
      baseURL: requireEnv("MODEL_BASE_URL"),
    },
  });

  const modelWithTools = model.bindTools(tools);
  const answer = await runAgent(
    "请告诉我这个 MCP 服务提供了哪些能力,并选择合适的工具验证其中一项。",
    tools,
    modelWithTools,
  );

  console.log("最终回答:", answer);
} catch (error) {
  console.error("执行失败:", error);
  process.exitCode = 1;
} finally {
  await mcpClient.close();
}

运行命令:

bash 复制代码
node src/index.mjs

这段代码依赖两个外部条件:配置的模型确实支持工具调用;MCP_SERVER_URL 指向可访问且符合 MCP 协议的 Streamable HTTP 端点。它不是把任意 REST API 地址填进去就能工作。

程序从启动到结束发生了什么

把完整执行过程拆开看,数据流会清楚很多:

  1. Node.js 加载 ES Module,dotenv/config 先把 .env 写入 process.env
  2. 程序创建 MultiServerMCPClient,但此时还没有得到工具数组。
  3. getTools() 连接远程 MCP Server,请求工具定义,并将其转换为 LangChain 工具。
  4. 程序创建聊天模型,再通过 bindTools(tools) 把工具名称、描述和参数 Schema 绑定到模型。
  5. runAgent 把用户问题包装成 HumanMessage,放进 messages
  6. 第一轮 invoke(messages) 把对话和工具定义发给模型。
  7. 如果模型需要外部信息,它在 response.tool_calls 中返回工具名、参数和调用 ID;这条 AI 消息也会进入历史记录。
  8. 程序按名称找到工具,将完整 toolCall 交给 tool.invoke()
  9. LangChain 适配器通过 MCP 调用远程服务,并把服务结果转换为关联同一调用 ID 的工具消息。
  10. 工具消息被追加到 messages;下一轮模型请求因此能同时看到"自己调用了什么"和"工具返回了什么"。
  11. 模型可以继续发起工具调用;当 tool_calls 为空时,程序把 response.content 当作最终回答返回。
  12. 无论成功还是抛错,finally 都调用 mcpClient.close() 释放连接;若失败,进程退出码被设为 1

注意,等待模型和工具的 Promise 时,并不是整个 Node.js 进程被冻结。当前异步函数暂停在 await 处,事件循环仍可以处理网络事件和其他已注册任务。Promise 被拒绝后,异常会进入外层 catch,而 finally 仍会执行。

常见错误与排查方法

工具结果为空或模型说"没有收到结果"

常见原因是只执行了 toolCall.args,随后手工猜测工具返回对象里有 result.text。MCP 返回可以包含多个内容块,不保证存在这样的属性路径;手工创建消息时还可能漏掉调用 ID。

推荐直接让工具处理完整调用对象:

javascript 复制代码
const toolMessage = await tool.invoke(toolCall);
messages.push(toolMessage);

排查时依次确认工具是否找到、toolCall.id 是否存在,以及工具消息是否紧跟在发起调用的 AI 消息之后。

远程地址能在浏览器打开,MCP 却连接失败

浏览器能打开只说明服务器响应 HTTP,不代表该路径是 MCP 端点。错误可能表现为连接失败、404,或服务端返回无法解析的内容。

检查以下项目:

  1. URL 是否包含服务规定的完整 MCP 路径,而不只是网站首页。
  2. 服务使用的是 Streamable HTTP 还是旧式 SSE;若明确要求 SSE,应配置 transport: "sse"
  3. 是否需要在服务配置的 headers 中携带授权信息。
  4. 代理、证书和防火墙是否允许 Node.js 访问该地址。

模型一直不调用工具

先确认模型本身支持 tool calling,然后打印工具名检查发现是否成功:

javascript 复制代码
console.log(tools.map(({ name, description }) => ({ name, description })));

这里函数参数是一个对象,{ name, description } 是对象解构,等价于先接收整个工具对象,再读取它的同名属性。工具描述过于模糊、用户问题无需外部信息,或者 OpenAI 兼容服务没有完整实现工具调用,都可能导致模型不调用工具。

本地 stdio 服务提示找不到模块

典型报错类似:

text 复制代码
Error: Cannot find module '/path/to/server.mjs'

不要依赖一个不确定的相对路径,改用真实存在的绝对路径:

javascript 复制代码
localService: {
  transport: "stdio",
  command: "node",
  args: ["/absolute/path/to/server.mjs"],
}

同时在终端单独运行 node /absolute/path/to/server.mjs,可以把"服务自身启动失败"和"MCP 客户端连接失败"分开定位。

出错后程序迟迟不退出

客户端可能仍持有 HTTP 会话或子进程。把关闭操作放在 finally 中,而不是只放在成功路径末尾:

javascript 复制代码
try {
  // 加载工具并运行代理
} finally {
  await mcpClient.close();
}

这样即使模型鉴权失败、工具抛错或循环超限,清理逻辑仍会执行。

从演示走向真实应用

这个版本刻意保留了清晰的调用闭环,但用于真实业务时还应继续收紧边界。

一是限制可用工具。不要因为 MCP Server 暴露了某个工具,就默认允许模型调用它。文件写入、浏览器操作、命令执行都可能改变外部状态,应该采用允许列表,并对高风险操作增加人工确认。

二是设置超时、重试和输出上限。网络工具可能长时间不返回,搜索或文件读取也可能生成巨大结果。超时能防止请求悬挂,有限重试可应对暂时性故障,截断或摘要则能避免工具结果占满模型上下文。重试应只用于安全、幂等的调用,不能盲目重复"付款"或"删除文件"一类操作。

三是保留可观测信息。至少记录轮次、工具名、耗时和成功状态,但不要记录密钥或未经处理的隐私数据。生产环境还可以为一次用户请求生成追踪 ID,将模型调用和多个 MCP 工具调用串起来。

四是针对工具做参数复核。Schema 能约束数据类型,却不能自动判断业务意图是否安全。例如路径是合法字符串,不代表它一定在允许访问的目录中。真正执行前仍需要路径白名单、权限校验和业务规则。

总结

远程 MCP 应用的核心并不是某个构造函数,而是一个必须闭合的消息循环:发现工具,把工具描述交给模型,执行模型产生的结构化调用,再把带有关联 ID 的结果放回对话。模型负责选择与推理,Node.js 应用负责执行与控制,MCP 则统一了应用和工具服务之间的通信方式。

理解这个分工后,再接入地图、浏览器或文件系统都只是更换服务配置和安全策略。真正值得复用的,是工具发现、消息历史、循环上限、错误传播和资源释放这一整套骨架。

相关推荐
码上解惑6 小时前
从模型接入到应用运行:智能体开发平台的整体架构设计
人工智能·agent·智能体·spring ai
湘美书院--湘美谈教育6 小时前
湘美谈教育湘美书院大湘西文学系列:AI时代的武侠小说怎么写
大数据·人工智能·深度学习·机器学习·生活
HONG````6 小时前
HarmonyOS ArkUI 弹窗全解:Toast、AlertDialog 与 CustomDialog 封装
后端
Geoking.6 小时前
JSON vs JSONL:从数据格式到 AI Agent 的工程实践
人工智能·深度学习·json
饼干哥哥6 小时前
腾讯会议出AI同传了?这下跨境人开会不用担心哑巴英语了
人工智能·性能优化·腾讯
亲爱的译官.6 小时前
实测|告别手持翻译设备,AR眼镜能否解决跨语言沟通痛点?
人工智能·ar·亲爱的翻译官·翻译设备
Nturmoils7 小时前
把工具变成可发现能力:鸿蒙端动态能力注册表实践
人工智能
ZGIS智博创享7 小时前
矿产数智化专题连载③ | 深挖AI智能找矿内核:以传统成矿理论为根基,算法赋能隐伏矿体突破
人工智能·知识图谱·ai算法·ai找矿·地质找矿逻辑
xd1855785557 小时前
[特殊字符] 宠物美容指南 —— 鸿蒙AI智能助手开发全流程解析
人工智能·华为·harmonyos·鸿蒙·宠物