远程 MCP 实战:LangChain 同时接入高德地图、Chrome、文件系统,让 AI Agent 操控真实世界

前言

上一篇文章《从零上手 MCP》讲了怎么手写一个本地 MCP Server,通过 stdio 让 AI 查数据库。但那只是入门------真实场景里,我们需要同时接入多个 MCP Server,有本地的、有远程的,让 AI 像一个真正的助手一样:查地图、搜酒店、控制浏览器、写文件一条龙完成。

这篇文章带你从零搭建一个"多 MCP Agent",整个过程踩了 6 个坑,全部记录下来。

读完你会收获:

  • 远程 MCP(HTTP)和本地 MCP(stdio)的本质区别
  • 同时接入 4 个 MCP Server 的配置方法
  • LangChain Agent 循环的完整实现
  • 6 个真实踩坑记录和解决方案
  • npx vs node、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 (...) { ... }; 多加了一个分号,导致循环体只包含 invokepush,工具调用处理在循环外面,只会执行最后一次。

正确写法 :工具调用处理必须放在循环体里面,和 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();

希望这篇文章帮你少踩几个坑。有问题欢迎在评论区交流!

相关推荐
KaifuZeng1 小时前
通信与接口协议面试问题汇总三
单片机·嵌入式硬件·面试·通信与接口协议
胡萝卜术2 小时前
从聊天模型到本地执行助手:Remote MCP 多工具 Agent 实战
面试·架构·github
ShineWinsu4 小时前
对于Linux:基于UDP实现简单聊天室功能
linux·c++·面试·udp·笔试·进程·简单聊天室
YHHLAI15 小时前
[特殊字符] 面试中的 Promise —— 从入门到精通
前端·javascript·面试
兰令水20 小时前
leecodecode【面试150】【2026.7.9打卡-java版本】
java·数据结构·leetcode·面试·职场和发展
用户938515635071 天前
Node.js 路径与文件系统:从基础到异步进化之路
javascript·后端·面试
wear工程师1 天前
MySQL 索引失效别再背八股:看懂 EXPLAIN,面试官追问也不慌
mysql·面试
uhakadotcom1 天前
Scrapy-Redis 里面提供哪些即开即用的功能能力模块
前端·面试·github
KaifuZeng1 天前
通信与接口协议面试十七、SFP光模块
单片机·嵌入式硬件·面试·通信与接口协议