写在前面
前面手写的 mini-Cursor,工具都是写死在本项目里的:读文件、写文件、跑命令,清一色 Node.js。换个 Python 写的工具?接不了。换个项目想复用?得把代码复制一遍。
这不怪我们写得烂------这是传统 Tool Use 的结构性问题:工具和 Agent 高度耦合,语言、仓库、进程全绑在一起。
MCP(Model Context Protocol)就是来解决这个病的。它给 Agent 和工具之间插了一个通用协议,相当于 AI 界的 USB-C:管你是 Node.js、Python、Java 还是 Rust 写的工具,只要暴露 MCP Server,Agent 就能调用。
今天我们就用 LangChain 的 @langchain/mcp-adapters,把这套协议跑通。
传统 Tool Use 的两大硬伤
手写 Agent 时,工具函数通常直接 import 进来,和主项目活在一个进程里。带来的问题很现实:
| 痛点 | 表现 | 后果 |
|---|---|---|
| 项目耦合 | 工具代码和 Agent 代码在一个仓库 | 想复用得复制粘贴,维护灾难 |
| 语言锁定 | 工具用 Node.js 写,Python 工具用不了 | 每个语言都得自己造轮子 |
| 进程内绑定 | 工具函数跑在主进程里 | 一个工具卡死,整个 Agent 陪葬 |
| 无统一描述 | 各家 schema 自己定 | LLM 看不懂,调用成功率低 |
MCP 不解决"工具怎么实现",它解决"工具怎么被 Agent 发现、描述、调用"。实现是 Server 自己的事,协议是大家共同的语言。
MCP 是什么?给 Model 扩展 Context 的 Protocol
全称 Model Context Protocol,目标很直接:
标准化 LLM 与外部工具、资源之间的通信,让 Agent 能跨进程、跨语言调用能力。
它有两个传输层:
| 传输方式 | 场景 | 本质 |
|---|---|---|
| stdio | 本地调用 | Agent 通过 spawn 启动 MCP Server 子进程,走标准输入输出通信 |
| HTTP | 远程调用 | Agent 像访问普通服务一样,用 HTTP 连到远端 MCP Server |
注意,MCP 不是让你" fetch 一个接口拿数据"。它是让外部进程注册成 MCP Server,然后向 Agent 暴露两样东西:
- Tools:可被 LLM 调用的工具
- Resources:可被 Agent 读取的上下文资源
也就是说,MCP 不是扩展了一个函数调用,而是扩展了 LLM 能看到的 Context(上下文) 。
核心三件套:Host / Client / Server
角色分工比想象中的简单:
| 角色 | 谁 | 干啥 |
|---|---|---|
| Host | 你的 Agent 主程序 | 决策、调模型、维护 messages 循环 |
| Client | @langchain/mcp-adapters 里的 MultiServerMCPClient |
负责和 Server 建连、拿工具、读资源 |
| Server | 外部进程(Node/Python/Java/Rust) | 注册 tool/resource,等待被调用 |
一张图就能串起来:
java
Agent Host (Node.js)
↓
MultiServerMCPClient
↓
stdio / HTTP
↓
MCP Server A (Node) MCP Server B (Python) MCP Server C (Remote)
↓ ↓ ↓
Tools Tools/Resources Resources
Agent 这一侧完全不用关心 Server 是用什么语言写的,只要协议对就行。
完整代码走一遍:从连接 Server 到跑 Agent Loop
代码不长,核心逻辑分四步:
js
javascript
import 'dotenv/config';
import { MultiServerMCPClient } from '@langchain/mcp-adapters';
import { ChatOpenAI } from '@langchain/openai';
import chalk from 'chalk';
import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
const model = new ChatOpenAI({
modelName: 'deepseek-v4-pro',
apiKey: process.env.DEEPSEEK_API_KEY,
temperature: 0,
configuration: {
baseURL: 'https://api.deepseek.com/v1',
},
});
// 1. 启动 MCP Server 子进程并建立连接
const mcpClient = new MultiServerMCPClient({
mcpServers: {
'my-mcp-server': {
command: 'node',
args: ['C:/.../my-mcp-server.mjs'],
},
},
});
// 2. 从 Server 动态获取 tools 和 resources
const tools = await mcpClient.getTools();
const res = await mcpClient.listResources();
// 3. 把 resources 内容读出来,塞进 SystemMessage 当上下文
let resourceContent = '';
for (const [serverName, resources] of Object.entries(res)) {
for (const resource of resources) {
const content = await mcpClient.readResource(serverName, resource.uri);
resourceContent += content[0].text;
}
}
const modelWithTools = model.bindTools(tools);
// 4. 跑一个标准的 ReAct Agent 循环
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [
new SystemMessage(resourceContent),
new HumanMessage(query),
];
for (let i = 0; i < maxIterations; i++) {
console.log(chalk.bgGreen(`正在等待 AI 思考,第${i}轮...`));
const response = await modelWithTools.invoke(messages);
messages.push(response);
if (!response.tool_calls?.length) {
console.log(`\nAI 最终回复:\n${response.content}`);
return response.content;
}
console.log(chalk.bgBlue(`检测到 ${response.tool_calls.length} 个工具调用`));
console.log(chalk.bgBlue(`工具调用:${response.tool_calls.map(t => t.name).join(', ')}`));
for (const toolCall of response.tool_calls) {
const foundTool = tools.find(t => t.name === toolCall.name);
if (foundTool) {
const toolResult = await foundTool.invoke(toolCall.args);
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id,
}));
}
}
}
return messages[messages.length - 1].content;
}
await runAgentWithTools('MCP Server 的使用指南是什么?');
// 5. 关闭所有子进程和通信通道
await mcpClient.close();
逐段拆解:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 连接 Server | new MultiServerMCPClient(...) |
按配置启动子进程,建立 stdio 通信 |
| 拿工具 | mcpClient.getTools() |
把 Server 注册的 tool 转成 LangChain Tool 对象 |
| 读资源 | listResources() + readResource() |
把 resource 内容读出来,当上下文喂给 LLM |
| 绑定工具 | model.bindTools(tools) |
让 LLM 知道"我能调这些外部工具" |
| ReAct 循环 | for + invoke + ToolMessage |
和手写 mini-Cursor 的结构一模一样 |
| 释放资源 | mcpClient.close() |
关掉子进程和 stdio 通道,否则脚本挂死不退出 |
Tools vs Resources:一个能动,一个能读
很多人一开始会把 Tools 和 Resources 搞混。区别很清晰:
| 能力 | Tools | Resources |
|---|---|---|
| 方向 | Agent → Server(调用) | Server → Agent(读取) |
| LLM 怎么用 | 通过 tool_calls 主动调用 |
直接塞进 SystemMessage 当上下文 |
| 典型场景 | 查用户、写数据库、发邮件 | Server 使用说明、私有文档、配置模板 |
| 由谁触发 | LLM 决策 | 开发者预读 |
截图里那个 my-mcp-server.mjs 注册了一个 query_user 工具,还暴露了 resource。Agent 问"MCP Server 的使用指南是什么?"时,resource 里的指南被读出来塞进 SystemMessage,LLM 就能基于它回答。
这就是 MCP 设计精妙的地方:Tools 扩展了 Agent 的行动能力,Resources 扩展了 Agent 的知识边界。一个能动,一个能读,合起来才是真正的 Context 增强。
MCP 带来了什么变化?
| 维度 | 手写 Tool(无 MCP) | MCP 化之后 |
|---|---|---|
| 工具位置 | 和 Agent 同仓库、同进程 | 独立进程,可本地可远程 |
| 语言限制 | Agent 用什么语言,工具就得用什么 | Node Agent 调 Python 工具完全可行 |
| 复用方式 | 复制代码 | 改一行配置,连上 Server |
| 工具发现 | 手动 import | getTools() 动态拉取 |
| 上下文注入 | 自己写 prompt | readResource() 自动读 |
| 维护成本 | 高 | 低 |
5 个踩坑提醒
1. mcpClient.close() 一定要调。 MCP Server 是子进程,stdio 通道不关闭,你的 Node 脚本退出时进程还赖着。很多新手以为"任务跑完就完事了",结果终端一直挂在那儿。
2. Resource 读出来的结构是数组。 readResource() 返回的是数组,内容在 [0].text 里。直接当字符串用会拿到 [object Object],场面一度尴尬。
3. Tools 里找工具别用 find 就完事。 代码里 tools.find(t => t.name === toolCall.name) 如果找不到,foundTool 是 undefined,直接 invoke 会崩。建议兜底返回错误字符串给 LLM。
4. 多个 Server 同名 tool 会打架。 MultiServerMCPClient 能连多个 Server,如果两个 Server 都注册了同名工具,默认名字会冲突。要么改 Server 端 tool name,要么客户端做 namespace 隔离。
5. stdio 子进程路径写死是大坑。 配置里 args: ['C:/Users/...'] 写绝对路径,换台机器直接跪。生产环境用相对路径或环境变量,别把个人电脑路径提交到仓库。
写在最后
从手写 mini-Cursor 到接入 MCP,本质没变:还是 ReAct 循环,还是 ToolMessage 闭环,还是 LLM 想、代码做。变的是工具的边界------以前工具是项目里的几个函数,现在工具可以是任意语言、任意进程、任意机器上的服务。
Agent 的战斗力,一半看 LLM 的脑子,一半看工具的覆盖范围。MCP 就是把"工具生态"这件事标准化了。以前每个 Agent 框架各玩各的,现在好了,协议一通,万物互联。