前言
上一篇文章《从零上手 MCP》讲了怎么手写一个本地 MCP Server,通过 stdio 让 AI 查数据库。但那只是入门------真实场景里,我们需要同时接入多个 MCP Server,有本地的、有远程的,让 AI 像一个真正的助手一样:查地图、搜酒店、控制浏览器、写文件一条龙完成。
这篇文章带你从零搭建一个"多 MCP Agent",整个过程踩了 6 个坑,全部记录下来。
读完你会收获:
- 远程 MCP(HTTP)和本地 MCP(stdio)的本质区别
- 同时接入 4 个 MCP Server 的配置方法
- LangChain Agent 循环的完整实现
- 6 个真实踩坑记录和解决方案
npxvsnode、JSON 配置、QPS 限流等知识点
一、先搞清楚:远程 MCP vs 本地 MCP
第一次配置 MCP 的时候,看到有的用 url,有的用 command + args,很容易懵。其实这就是 MCP 的两种传输模式:
bash
本地 MCP(stdio)
┌──────────┐ spawn 子进程 ┌─────────────┐
│ Agent │ ←── stdin/stdout → │ MCP Server │
│ (父进程) │ │ (子进程) │
└──────────┘ └─────────────┘
启动方式:command + args
例如:npx -y @modelcontextprotocol/server-filesystem
远程 MCP(HTTP)
┌──────────┐ HTTP 请求 ┌─────────────┐
│ Agent │ ←───────────────→ │ 高德服务器 │
│ │ │ (别人维护) │
└──────────┘ └─────────────┘
启动方式:直接给 url
例如:https://mcp.amap.com/mcp?key=你的key
一句话总结:本地 MCP 你启动子进程,远程 MCP 你连别人的服务器。
为什么要用 MCP?
用 MCP 之前,AI 调工具是这样:
AI 项目里写死函数 → 换个项目重写 → 换个语言重写
用 MCP 之后:
arduino
任何人开发 MCP Server → 发布 → 所有人直接用
解耦。高德写好地图 MCP,你拿来就能用;Google 写好 Chrome MCP,装上就能控制浏览器。不用重复造轮子。
二、项目目标:让 AI 搜酒店 + 打开浏览器展示
我们的任务:
搜"北京南站附近最近的 3 个酒店,拿到酒店图片,打开浏览器,每个 tab 展示一个酒店的图片 URL,并把页面标题改为酒店名"
这个任务涉及 4 种能力,每种对应一个 MCP Server:
| MCP Server | 类型 | 负责做什么 | 连接方式 |
|---|---|---|---|
| 高德地图 | 远程 HTTP | 地址→经纬度、搜周边酒店、查酒店详情 | url |
| Chrome DevTools | 本地 stdio | 打开浏览器、新建 tab、截图 | npx |
| FileSystem | 本地 stdio | 读写文件 | npx |
| 自定义 my-mcp-server | 本地 stdio | 查用户信息(demo) | node 直接跑 |
三、代码详解:一步一步搭
3.1 环境准备
bash
npm install dotenv @langchain/mcp-adapters @langchain/openai @langchain/core chalk
.env 文件(放在 src/ 目录下!后面会说为什么):
ini
DEEPSEEK_API_KEY=sk-你的key
DEEPSEEK_API_BASE_URL=https://api.deepseek.com/v1
3.2 创建模型(大脑)
javascript
import { ChatOpenAI } from '@langchain/openai';
const model = new ChatOpenAI({
modelName: 'deepseek-v4-pro',
temperature: 0,
configuration: {
apiKey: process.env.DEEPSEEK_API_KEY, // ⚠️ 注意:apiKey 要放在 configuration 里面!
baseURL: 'https://api.deepseek.com/v1',
},
});
3.3 同时连接 4 个 MCP Server
javascript
import { MultiServerMCPClient } from '@langchain/mcp-adapters';
const mcpClient = new MultiServerMCPClient({
mcpServers: {
// ① 远程 MCP:高德地图 ------ 直接给 URL
'amap-maps-http': {
url: 'https://mcp.amap.com/mcp?key=你的高德Key'
},
// ② 本地 MCP:Chrome DevTools ------ npx 自动下载运行
'chrome-devtools': {
command: 'npx',
args: ['-y', 'chrome-devtools-mcp@latest']
},
// ③ 本地 MCP:FileSystem ------ npx + 指定允许访问的目录
'filesystem': {
command: 'npx',
args: [
'-y',
'@modelcontextprotocol/server-filesystem',
'C:/Users/Hjf20/Desktop/workspace' // 允许读写的根目录
]
},
// ④ 本地 MCP:自定义 ------ node 直接跑本地文件
'my-mcp-server': {
command: 'node',
args: ['C:/Users/Hjf20/Desktop/workspace/.../my-mcp-server.mjs'],
},
}
});
const tools = await mcpClient.getTools();
const modelWithTools = model.bindTools(tools);
关键区别:
npx不需要事先安装包,它会自动从 npm 下载然后运行。适合"用完即走"的场景node跑的是你本地的.mjs文件,适合自己开发的 MCP Server
3.4 核心:Agent 循环
javascript
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [new HumanMessage(query)];
for (let i = 0; i < maxIterations; i++) {
// ① AI 思考
const response = await modelWithTools.invoke(messages);
messages.push(response);
// ② 没有工具调用 → AI 给出最终答案,结束
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log('AI 最终回答:', response.content);
return response.content;
}
// ③ 有工具调用 → 逐个执行
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);
// MCP 工具返回可能是字符串,也可能是 { text: "..." } 对象
let contentStr;
if (typeof toolResult === 'string') {
contentStr = toolResult;
} else if (toolResult && toolResult.text) {
contentStr = toolResult.text;
}
// 把结果塞回消息历史,AI 下一轮能看到
messages.push(new ToolMessage({
content: contentStr,
tool_call_id: toolCall.id, // ⚠️ 必须带 id!
}));
// 避免高德免费 API QPS 超限
await new Promise(r => setTimeout(r, 500));
}
}
}
return messages[messages.length - 1].content;
}
// 启动!
await runAgentWithTools('北京南站附近最近的3个酒店,拿到酒店图片...');
await mcpClient.close(); // ⚠️ 跑完记得关,否则进程不退出
Agent 循环的本质:
arduino
第1轮:AI 看到问题 → 决定调用 maps_geo(地址→经纬度)
第2轮:AI 拿到经纬度 → 决定调用 maps_around_search(搜酒店)
第3轮:AI 拿到酒店列表 → 决定调用 maps_search_detail ×4(查详情)
第4轮:AI 拿到详情 → 决定调用 write_file(写 HTML)
第5轮:AI 拿到文件路径 → 决定调用 new_page(打开浏览器)
每一轮 AI 都带着"之前发生了什么"的完整记忆做决策
四、本地 MCP Server 长什么样
自己写一个 MCP Server 其实很简单。用 @modelcontextprotocol/sdk:
javascript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const database = {
users: {
'001': { id: '001', name: '祖豪', email: 'zh@qq.com', role: 'admin' },
'002': { id: '002', name: '光光', email: 'gg@qq.com', role: 'user' },
'003': { id: '003', name: '小红', email: 'xh@qq.com', role: 'user' },
}
};
const server = new McpServer({
name: 'my-mcp-server',
version: '1.0.0'
});
// 注册工具
server.registerTool('query_user', {
description: '查询用户信息。输入用户ID,返回姓名、邮箱、角色',
inputSchema: z.object({ // ⚠️ 必须 z.object() 包起来
userId: z.string().describe('用户ID, 例如:001, 002, 003')
})
}, async ({ userId }) => { // ⚠️ 解构参数
const user = database.users[userId];
if (!user) {
return {
content: [{ type: 'text', text: `用户 ${userId} 不存在` }]
};
}
return {
content: [{
type: 'text',
text: `用户 ${user.id}:${user.name}, ${user.email}, ${user.role}`
}]
};
});
// 启动 ------ 通过 stdio 通信
const transport = new StdioServerTransport();
await server.connect(transport);
Server 写好之后,客户端通过 command: 'node' + args 启动它作为子进程,双方通过标准输入输出(stdin/stdout)交换 JSON 消息。
五、6 个真实踩坑记录
坑 1:JSON 配置忘写 type,Trae IDE 报格式错误
在 Trae IDE 里配远程 MCP 时,只写了 url,没写 type:
json
// ❌ 报错:Trae 默认按 stdio 模式解析,找不到 command 字段
{
"mcpServers": {
"amap": {
"url": "https://mcp.amap.com/mcp?key=xxx"
}
}
}
// ✅ 加上 type 字段
{
"mcpServers": {
"amap": {
"type": "sse",
"url": "https://mcp.amap.com/mcp?key=xxx"
}
}
}
根因 :工具(Trae / Claude Code)默认按 stdio 模式解析配置,没写 type 就去找 command 字段,找不到就报格式错误。
坑 2:@langchain/openai 的 apiKey 要放在 configuration 里面
javascript
// ❌ 报错:Missing credentials
const model = new ChatOpenAI({
modelName: 'deepseek-v4-pro',
apiKey: process.env.DEEPSEEK_API_KEY, // 放外面没用!
configuration: { baseURL: '...' },
});
// ✅ 正确:apiKey 放 configuration 里面
const model = new ChatOpenAI({
modelName: 'deepseek-v4-pro',
configuration: {
apiKey: process.env.DEEPSEEK_API_KEY, // 放这里才生效
baseURL: 'https://api.deepseek.com/v1',
},
});
根因 :@langchain/openai v1.5.5 源码中优先读 configuration.apiKey,顶层的 apiKey 不会被传递给底层 OpenAI 客户端。
坑 3:.env 文件路径 ------ dotenv 从哪个目录找
代码里写 import 'dotenv/config',dotenv 默认从当前工作目录 (CWD)找 .env 文件。
bash
你在这个目录运行 → dotenv 在这个目录找 .env
❌ src/ 目录下跑 node mcp-test.mjs → 找 src/.env(找不到)
✅ remote-mcp/ 目录下跑 node src/mcp-test.mjs → 找 remote-mcp/.env(找到)
两种解决方案:
- 把
.env放在你执行node命令的那个目录 - 或者在代码里显式指定路径:
import { config } from 'dotenv'; config({ path: '../.env' });
坑 4:高德免费 API QPS 超限
AI 在第 3 轮同时调了 4 个 maps_search_detail,免费 API 一秒内只允许有限次请求:
go
ToolException: MCP tool 'maps_search_detail' returned an error:
API 调用失败:CUQPS_HAS_EXCEEDED_THE_LIMIT
解决:每次调用工具后加个延迟:
javascript
await new Promise(r => setTimeout(r, 500)); // 500ms 间隔
坑 5:FileSystem MCP 路径权限限制
AI 想把 HTML 写到 C:\workspace\hotels.html,但 FileSystem MCP 只被授权访问 C:/Users/.../workspace:
lua
Access denied - path outside allowed directories:
C:\workspace\hotels.html not in C:\Users\Hjf20\Desktop\workspace
解决:配置时把允许目录设大一点(比如 workspace 根目录),或者在 prompt 里明确告诉 AI 保存到哪个路径。
坑 6:for 循环写错了------工具调用在循环外面
这是我踩过最隐蔽的 bug:
javascript
// ❌ 注意这个分号!for 循环体到这就结束了
for (let i = 0; i < maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
}; // ← 这个分号!循环体到此为止
// 下面的代码只会在循环结束后执行一次!
if (!response.tool_calls || ...) { ... }
for (...) { ... }; 多加了一个分号,导致循环体只包含 invoke 和 push,工具调用处理在循环外面,只会执行最后一次。
正确写法 :工具调用处理必须放在循环体里面,和 invoke 一起构成完整的 Agent 循环。
六、运行结果
解决所有坑之后,Agent 成功跑通:
arduino
已加载工具:maps_geo, maps_around_search, maps_search_detail, ..., write_file, new_page, ...
第1轮 → maps_geo // "北京南站" → (116.xxx, 39.xxx)
第2轮 → maps_around_search // 搜周边酒店 → 返回列表
第3轮 → maps_search_detail ×4 // 逐个查酒店详情(含图片 URL)
第4轮 → write_file // 生成 HTML 保存到本地
第5轮 → new_page // 打开 Chrome 展示(需装 Chrome)
31 个工具全部正常加载,Agent 循环运转正常。
七、关键概念速查
| 概念 | 一句话解释 |
|---|---|
| MCP | AI 和工具之间的标准通信协议 |
| stdio 模式 | 父进程 spawn 子进程,通过标准输入输出通信 |
| HTTP 模式 | 直接请求远程 URL,别人维护的 MCP 服务 |
npx vs node |
npx 自动下载 npm 包并运行;node 运行本地文件 |
command + args |
本地 MCP 的启动方式(启动子进程) |
url |
远程 MCP 的连接方式(发 HTTP 请求) |
| Agent 循环 | AI 思考 → 决定调哪些工具 → 执行 → 结果喂回 → 再思考 |
tool_call_id |
把工具结果和调用请求对应起来的唯一标识 |
| QPS 限制 | 免费 API 每秒请求次数有上限,加延迟可规避 |
八、总结
MCP 的精髓就一句话:把工具从 AI 应用里抽出来,标准化通信,任何人写的 MCP Server 都能被任何 AI Agent 调用。
代码骨架记住这个就够:
javascript
// 1. 连接多个 MCP Server(远程 + 本地混合)
const mcpClient = new MultiServerMCPClient({
mcpServers: {
'远程': { url: 'https://...' },
'本地': { command: 'npx', args: ['-y', '包名'] },
}
});
// 2. 拿到工具,装到模型上
const tools = await mcpClient.getTools();
const modelWithTools = model.bindTools(tools);
// 3. Agent 循环
for (let i = 0; i < maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
if (!response.tool_calls?.length) return response.content; // 结束
for (const tc of response.tool_calls) {
const result = await tools.find(t => t.name === tc.name).invoke(tc.args);
messages.push(new ToolMessage({ content: result, tool_call_id: tc.id }));
}
}
await mcpClient.close();
希望这篇文章帮你少踩几个坑。有问题欢迎在评论区交流!