MCP 远程调用(HTTP)完全解析:串联多个 MCP Server 打造 AI 工作流
前言
在上一篇中,我们写了本地 MCP Server,Agent 通过 command + args spawn 子进程、走 stdio 管道通信。本文更进一步:用 url 连远程 MCP Server,用 npx 一键启动本地 npm 包,三个 Server 串联,让 Agent 自动完成"查地图 → 打开浏览器 → 写文件"的完整工作流。
一、从 stdio 到 HTTP:同一种协议,不同传输方式
1.1 核心不变
协议内容完全一样:tools/list、tools/call、resources/read......LLM 和工具之间说的话还是那些。唯一变的是传输介质:
arduino
stdio(本地) HTTP(远程)
Agent 进程 Agent 进程
│ │
│ spawn + stdin/stdout 管道 │ POST http://服务器:3000/mcp
▼ ▼
MCP Server 子进程 MCP Server(独立 HTTP 服务)
- stdio = 两个人在同一房间直接说话(管道传声)
- HTTP = 一个在北京,一个在上海打电话(网络传数据)
1.2 Client 配置三种写法
arduino
// 方式一:本地文件 --- spawn 子进程
{ command: 'node', args: ['path/to/server.mjs'] }
// 方式二:npx --- spawn 子进程 + 自动下载 npm 包
{ command: 'npx', args: ['-y', '包名'] }
// 方式三:远程 HTTP --- 发网络请求
{ url: 'https://远程地址/mcp' }
不管哪种方式,MultiServerMCPClient 统一管理,getTools() 一把拿回所有工具。
二、npx 是什么
npx -y 包名
| 部分 | 含义 |
|---|---|
npx |
Node 自带命令,运行 npm 包 |
-y |
跳过"是否下载?y/n"确认,直接执行 |
包名 |
npm 上的包,如 @modelcontextprotocol/server-filesystem |
和 npm install 的区别: npx 自动下载、运行、用完即走(或缓存)。不需要先全局安装,一行命令搞定。
在 MCP 里的价值:工具开发者把 MCP Server 发布到 npm,你用 npx 一行就能用,不需要自己写任何工具代码。
三、三个 MCP Server 的配置解析
css
const mcpClient = new MultiServerMCPClient({
mcpServers: {
'amap-server': {
url: 'https://mcp.amap.com/mcp?key=f58f8879063a5f61df94bbb40a6305cb'
},
'file-system': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '允许的目录']
},
'chrome-devtools': {
command: 'npx',
args: ['-y', 'chrome-devtools-mcp@latest']
}
}
});
3.1 三个 Server 的命名
'amap-server'、'file-system'、'chrome-devtools'------全部自己取名。 变量名自由,框架只认后面的配置结构。
3.2 配置结构的固定字段
| 字段 | 含义 | 谁定 |
|---|---|---|
url |
远程 HTTP 地址 | 固定字段,值是实际地址 |
command |
用什么启动子进程 | 固定字段,值填 node/python/npx |
args |
传给 command 的参数 | 固定字段,值是一个数组 |
3.3 amap-server --- 远程 HTTP
css
'amap-server': {
url: 'https://mcp.amap.com/mcp?key=xxx'
}
不启动任何子进程。 Agent 通过 HTTP POST 请求和高德服务器通信。工具跑在远程服务器上。提供的工具:maps_geo(地址→坐标)、maps_around_search(周边搜索)、maps_search_detail(地点详情)、maps_distance(距离计算)等。
3.4 file-system --- 本地 npx,有安全边界
arduino
'file-system': {
command: 'npx',
args: [
'-y',
'@modelcontextprotocol/server-filesystem',
'C:\...\remote-mcp\src' // 只允许访问这个目录
]
}
提供的工具:read_file、write_file、search_files、get_file_info、list_allowed_directories。
最后一个参数是安全边界 ------这个 MCP Server 只能操作这个目录,越界就报 Access denied。这是 file-system MCP 内置的安全机制。
3.5 chrome-devtools --- 浏览器控制
bash
'chrome-devtools': {
command: 'npx',
args: ['-y', 'chrome-devtools-mcp@latest']
}
提供的工具:navigate_page、new_page、close_page、click、fill、fill_form、take_screenshot、take_snapshot、list_pages、evaluate_script、wait_for 等几十个浏览器操作工具。相当于一个能自动操控浏览器的机器人。
3.6 一句话总结
scss
远程用 url,本地用 command + args,npx 省得自己装
一个 MultiServerMCPClient 同时管三种,getTools() 一把全拿回来
四、获取工具
ini
const tools = await mcpClient.getTools();
工具代码不在你的文件里:
| Server | 工具在哪 |
|---|---|
| amap-server | 高德服务器上 |
| file-system | npx 下载的 @modelcontextprotocol/server-filesystem npm 包里 |
| chrome-devtools | npx 下载的 chrome-devtools-mcp npm 包里 |
你一行工具代码都没写,但拿到了几十个工具。这就是 MCP 的复用价值。
五、SystemMessage --- 告诉 LLM 工作目录
go
const messages = [
new SystemMessage(`当前工作目录:C:\...\remote-mcp\src
所有文件操作(读写保存)必须在这个目录下进行,不要使用其他路径。`),
new HumanMessage(query),
];
这是踩坑后的经验。 file-system 只允许写指定目录,但 LLM 不知道------它可能编一个 Linux 路径(/home/user/...),结果报 Access denied。在 SystemMessage 里明确告知工作目录,LLM 就不会乱猜了。
六、ReAct 循环
6.1 invoke
ini
const response = await modelWithTools.invoke(messages);
messages.push(response);
LLM 在云端完成决策。 返回的 response 可能是直接回答,也可能是 tool_calls。
6.2 判断是否调工具
vbscript
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(chalk.green(`AI回答:${response.content}`));
return response.content;
}
两个条件满足任意一个就退出:
!response.tool_calls--- tool_calls 是undefined,LLM 没想调工具tool_calls.length === 0--- LLM 说要调但给了空数组(几乎不会发生,兜底)
6.3 打印工具名
ini
console.log(chalk.bgBlue(`AI调用工具:${response.tool_calls.map(t => t.name).join(', ')}`));
从工具调用列表里只取名字,用逗号拼起来,蓝底打印给开发者看。纯调试信息。
6.4 遍历工具调用
ini
for (const tool_call of response.tool_calls) {
const foundTool = tools.find(t => t.name === tool_call.name);
for...of 串行遍历,一个工具执行完再跑下一个。find 按 LLM 给的名字在工具库里匹配真正的工具对象。
6.5 执行工具
ini
if (foundTool) {
const toolResult = await foundTool.invoke(tool_call.args);
tool_call.args 是 LLM 自动从用户自然语言里提取的参数。LLM 对照工具的 schema 描述,自动推理填入,不需要你手动传。
6.6 处理返回值(四层判断)
不同 MCP Server 返回格式不同,需要统一转成字符串才能放进 ToolMessage:
ini
let contentStr;
if (typeof toolResult === 'string') {
contentStr = toolResult;
}
// 情况一:返回的就是裸字符串,直接用
else if (Array.isArray(toolResult.content)) {
contentStr = toolResult.content.map(c => c.text || '').join('\n');
}
// 情况二:MCP 标准格式 [{type:'text', text:'...'}, ...]
// 取出每项的 .text,换行拼起来。|| '' 兜底
else if (typeof toolResult.content === 'string') {
contentStr = toolResult.content;
}
// 情况三:content 字段就是字符串,直接取
else {
contentStr = JSON.stringify(toolResult);
}
// 情况四:兜底,整个对象转 JSON 字符串。LLM 至少能看到内容,不崩
四种判断只为一个目的:让 contentStr 无论怎样都是字符串,安全放进 ToolMessage。
6.7 塞回 messages
less
messages.push(new ToolMessage({
content: contentStr,
tool_call_id: tool_call.id,
}));
tool_call_id 绝对不能丢。 LLM 可能同时调 3 个工具,每个结果必须标记对应哪个调用,就像给三个人发微信,回复必须标记是对谁说的。
6.8 兜底返回
ini
return messages[messages.length - 1].content;
maxIterations(默认 30 轮)到了还没结束,取最后一条消息当结果返回。防止 LLM 死循环。
七、实战工作流
javascript
await runAgentWithTools(
`抚州东华理工大学附近的酒店,最近的 3 个酒店,
拿到酒店图片,打开浏览器,展示每个酒店的图片,
每个 tab 一个 url 展示,并且在把那个页面标题改为酒店名`
);
Agent 的实际执行步骤
markdown
第 0 轮:maps_geo(地址→坐标)
第 1 轮:maps_around_search(搜附近酒店)
第 2 轮:maps_search_detail × 3(查每个酒店详情+图片)
第 3 轮:new_page(打开酒店图片链接)
→ new_page → new_page → ...(每个酒店一个 tab)
→ select_page → evaluate_script(改页面标题)
第 4 轮:回答用户
三个 MCP Server 被 Agent 自动编排串联,不需要你写任何编排逻辑。
八、常见踩坑
8.1 API 限流
高德免费 key 有 QPS 上限。Agent 并发 8 个 maps_search_detail 直接把额度打爆:
CUQPS_HAS_EXCEEDED_THE_LIMIT
换付费 key 或在 prompt 里让 LLM 分批查询。
8.2 参数格式错误
LLM 给 maps_distance 的坐标参数格式不符合高德要求,返回:
INVALID_PARAMS
LLM 每次生成的参数不一样,重跑一次通常能解决。或在 SystemMessage 里加参数格式约束。
8.3 路径越权
LLM 编了个 C:\home\user...(Linux 习惯),超出 file-system 允许的目录范围:
lua
Access denied - path outside allowed directories
在 SystemMessage 里明确告诉 LLM 工作目录即可解决。
九、完整流程串讲
scss
① 创建 model(ChatOpenAI)
② 创建 mcpClient(MultiServerMCPClient),配置 3 个 MCP Server
- amap:远程 HTTP
- file-system:npx 本地,指定安全目录
- chrome-devtools:npx 本地,控制浏览器
③ mcpClient.getTools() 自动拿回所有工具
④ model.bindTools(tools) 绑定工具
⑤ runAgentWithTools(query):
消息 = [SystemMessage(工作目录约束), HumanMessage(用户任务)]
→ for 循环(最多 30 轮):
→ invoke → 判断是否调工具
→ 不需要 → 结束
→ 需要 → 遍历 tool_calls → find 匹配 → invoke 执行
→ 四层判断处理返回值 → ToolMessage 带 tool_call_id 塞回
→ 到了上限 → 返回最后一条消息
⑥ mcpClient.close() 释放资源
十、两种 MCP 完整对比
| 本地 stdio(mcp-demo) | 远程 HTTP(remote-mcp) | |
|---|---|---|
| Server 在哪 | 你的文件 | 高德服务器 / npx 缓存的包 |
| 连法 | command + args |
url / command + args |
| 传输 | stdin/stdout 管道 | HTTP 网络 / 管道 |
| 跨机器 | 不能 | 能(url 方式) |
| 工具代码 | 你自己写的 | 别人写的,你直接用 |
| Server 数量 | 1 个 | 可串联多个 |
核心不变:McpServer 接口固定,工具注册方式不变,Agent 端 bindTools + ReAct 循环不变。变得只是 Transport 和配置方式。