用 LangChain 连接远程 MCP:从工具发现到多轮调用闭环
第一次看到 MCP 客户端代码时,很容易把它理解成"让大模型调用一个接口"。但真正写起来,几个问题会立刻冒出来:模型怎么知道服务端有哪些工具?它返回的工具参数由谁执行?执行结果为什么还要放回消息列表?HTTP MCP 和普通 HTTP API 又有什么区别?
这篇文章用一个可运行的 Node.js 示例完成一条最小但完整的链路:连接一个远程 MCP Server,读取它暴露的工具,把工具绑定给聊天模型,执行模型发起的工具调用,再把结果交回模型生成最终回答。
示例使用 LangChain 负责模型与消息编排,使用 @langchain/mcp-adapters 把 MCP 工具转换成 LangChain 能识别的工具。读完之后,你不仅能运行代码,也能说清每一轮数据究竟流向了哪里。
MCP 在这条链路中解决了什么
大模型本身只负责根据上下文生成内容。即使它输出"我要查询某个位置",也不会因此自动访问地图或文件系统。要让模型使用外部能力,应用程序至少要完成三件事:
- 告诉模型有哪些工具,以及每个工具需要哪些参数。
- 接收模型生成的工具调用请求,并真正执行对应工具。
- 把执行结果送回模型,让模型继续判断或组织答案。
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 ?? [] 使用空值合并运算符提供默认数组。只有左侧是 null 或 undefined 时才采用 [],不会误伤其他合法值。没有工具调用意味着模型已经给出最终回复,此时返回 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 地址填进去就能工作。
程序从启动到结束发生了什么
把完整执行过程拆开看,数据流会清楚很多:
- Node.js 加载 ES Module,
dotenv/config先把.env写入process.env。 - 程序创建
MultiServerMCPClient,但此时还没有得到工具数组。 getTools()连接远程 MCP Server,请求工具定义,并将其转换为 LangChain 工具。- 程序创建聊天模型,再通过
bindTools(tools)把工具名称、描述和参数 Schema 绑定到模型。 runAgent把用户问题包装成HumanMessage,放进messages。- 第一轮
invoke(messages)把对话和工具定义发给模型。 - 如果模型需要外部信息,它在
response.tool_calls中返回工具名、参数和调用 ID;这条 AI 消息也会进入历史记录。 - 程序按名称找到工具,将完整
toolCall交给tool.invoke()。 - LangChain 适配器通过 MCP 调用远程服务,并把服务结果转换为关联同一调用 ID 的工具消息。
- 工具消息被追加到
messages;下一轮模型请求因此能同时看到"自己调用了什么"和"工具返回了什么"。 - 模型可以继续发起工具调用;当
tool_calls为空时,程序把response.content当作最终回答返回。 - 无论成功还是抛错,
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,或服务端返回无法解析的内容。
检查以下项目:
- URL 是否包含服务规定的完整 MCP 路径,而不只是网站首页。
- 服务使用的是 Streamable HTTP 还是旧式 SSE;若明确要求 SSE,应配置
transport: "sse"。 - 是否需要在服务配置的
headers中携带授权信息。 - 代理、证书和防火墙是否允许 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 则统一了应用和工具服务之间的通信方式。
理解这个分工后,再接入地图、浏览器或文件系统都只是更换服务配置和安全策略。真正值得复用的,是工具发现、消息历史、循环上限、错误传播和资源释放这一整套骨架。