Wiring Four MCP Servers into One Agent: Maps, Chrome, Filesystem, and a Custom Tool
Foreword
The previous article ["Getting Started with MCP from Scratch"] covered how to hand-write a local MCP Server, allowing AI to query a database via stdio. But that was just the entry point — in real-world scenarios, we need to simultaneously access multiple MCP Servers, both local and remote, enabling AI to act like a true assistant: checking maps, searching for hotels, controlling a browser, and writing files in one seamless workflow.
This article walks you through building a "Multi-MCP Agent" from scratch, documenting all 6 pitfalls encountered along the way.
After reading, you will gain:
- The essential difference between remote MCP (HTTP) and local MCP (stdio)
- Configuration methods for simultaneously connecting 4 MCP Servers
- A complete implementation of the LangChain Agent loop
- 6 real-world pitfall records and their solutions
- Knowledge points such as
npxvsnode, JSON configuration, and QPS rate limiting
1. First, Understand: Remote MCP vs Local MCP
When configuring MCP for the first time, seeing some use url and others use command + args can be confusing. These are simply MCP's two transport modes:
Local MCP (stdio)
┌──────────┐ spawn child process ┌─────────────┐
│ Agent │ ←── stdin/stdout → │ MCP Server │
│ (parent) │ │ (child) │
└──────────┘ └─────────────┘
Startup: command + args
Example: npx -y @modelcontextprotocol/server-filesystem
Remote MCP (HTTP)
┌──────────┐ HTTP request ┌─────────────┐
│ Agent │ ←───────────────────────→ │ Amap Server │
│ │ │ (maintained by others) │
└──────────┘ └─────────────┘
Startup: provide url directly
Example: https://mcp.amap.com/mcp?key=your_key
In one sentence: Local MCP means you start a child process; remote MCP means you connect to someone else's server.
Why Use MCP?
Before MCP, AI tool invocation looked like this:
Hardcode functions in AI project → Rewrite for a new project → Rewrite for a new language
After MCP:
Anyone develops MCP Server → Publishes it → Everyone uses it directly
Decoupling. Amap writes a map MCP, and you can use it immediately; Google writes a Chrome MCP, and you can install it to control the browser. No need to reinvent the wheel.
2. Project Goal: Let AI Search Hotels + Open Browser to Display
Our task:
Search for "the 3 nearest hotels to Beijing South Railway Station, get hotel images, open the browser, display each hotel's image URL in a separate tab, and change the page title to the hotel name"
This task involves 4 capabilities, each corresponding to an MCP Server:
| MCP Server | Type | Responsibility | Connection Method |
|---|---|---|---|
| Amap | Remote HTTP | Address → Coordinates, Search nearby hotels, Query hotel details | url |
| Chrome DevTools | Local stdio | Open browser, Create new tab, Screenshot | npx |
| FileSystem | Local stdio | Read/Write files | npx |
| Custom my-mcp-server | Local stdio | Query user info (demo) | node run directly |
3. Code Walkthrough: Building Step by Step
3.1 Environment Setup
npm install dotenv @langchain/mcp-adapters @langchain/openai @langchain/core chalk
.env file (place it inside the src/ directory! The reason will be explained later):
DEEPSEEK_API_KEY=sk-your_key
DEEPSEEK_API_BASE_URL=https://api.deepseek.com/v1
3.2 Create the Model (Brain)
import { ChatOpenAI } from '@langchain/openai';
const model = new ChatOpenAI({
modelName: 'deepseek-v4-pro',
temperature: 0,
configuration: {
apiKey: process.env.DEEPSEEK_API_KEY, // ⚠️ Note: apiKey must be placed inside configuration!
baseURL: 'https://api.deepseek.com/v1',
},
});
3.3 Connect 4 MCP Servers Simultaneously
import { MultiServerMCPClient } from '@langchain/mcp-adapters';
const mcpClient = new MultiServerMCPClient({
mcpServers: {
// ① Remote MCP: Amap — Provide URL directly
'amap-maps-http': {
url: 'https://mcp.amap.com/mcp?key=your_amap_key'
},
// ② Local MCP: Chrome DevTools — npx auto-downloads and runs
'chrome-devtools': {
command: 'npx',
args: ['-y', 'chrome-devtools-mcp@latest']
},
// ③ Local MCP: FileSystem — npx + specify allowed access directory
'filesystem': {
command: 'npx',
args: [
'-y',
'@modelcontextprotocol/server-filesystem',
'C:/Users/Hjf20/Desktop/workspace' // Allowed root directory for read/write
]
},
// ④ Local MCP: Custom — node runs local file directly
'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);
Key Distinction:
npxdoesn't require pre-installing packages; it automatically downloads from npm and runs them. Suitable for "use and discard" scenarios.noderuns your local.mjsfile, suitable for MCP Servers you develop yourself.
3.4 Core: Agent Loop
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [new HumanMessage(query)];
for (let i = 0; i < maxIterations; i++) {
// ① AI thinks
const response = await modelWithTools.invoke(messages);
messages.push(response);
// ② No tool calls → AI gives final answer, end
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log('AI final answer:', response.content);
return response.content;
}
// ③ Tool calls exist → Execute one by one
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 tool return might be a string, or an object like { text: "..." }
let contentStr;
if (typeof toolResult === 'string') {
contentStr = toolResult;
} else if (toolResult && toolResult.text) {
contentStr = toolResult.text;
}
// Feed result back into message history, AI can see it in the next round
messages.push(new ToolMessage({
content: contentStr,
tool_call_id: toolCall.id, // ⚠️ Must include id!
}));
// Avoid exceeding Amap free API QPS limit
await new Promise(r => setTimeout(r, 500));
}
}
}
return messages[messages.length - 1].content;
}
// Start!
await runAgentWithTools('The 3 nearest hotels to Beijing South Railway Station, get hotel images...');
await mcpClient.close(); // ⚠️ Remember to close after running, otherwise the process won't exit
The Essence of the Agent Loop:
Round 1: AI sees the question → Decides to call maps_geo (Address → Coordinates)
Round 2: AI gets coordinates → Decides to call maps_around_search (Search hotels)
Round 3: AI gets hotel list → Decides to call maps_search_detail ×4 (Query details)
Round 4: AI gets details → Decides to call write_file (Write HTML)
Round 5: AI gets file path → Decides to call new_page (Open browser)
Each round, AI makes decisions with complete memory of "what happened before"
4. What a Local MCP Server Looks Like
Writing your own MCP Server is actually very simple. Use @modelcontextprotocol/sdk:
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: 'Zuhai', email: '[email protected]', role: 'admin' },
'002': { id: '002', name: 'Guangguang', email: '[email protected]', role: 'user' },
'003': { id: '003', name: 'Xiaohong', email: '[email protected]', role: 'user' },
}
};
const server = new McpServer({
name: 'my-mcp-server',
version: '1.0.0'
});
// Register tool
server.registerTool('query_user', {
description: 'Query user information. Input user ID, returns name, email, role',
inputSchema: z.object({ // ⚠️ Must be wrapped in z.object()
userId: z.string().describe('User ID, e.g.: 001, 002, 003')
})
}, async ({ userId }) => { // ⚠️ Destructure parameters
const user = database.users[userId];
if (!user) {
return {
content: [{ type: 'text', text: `User ${userId} does not exist` }]
};
}
return {
content: [{
type: 'text',
text: `User ${user.id}: ${user.name}, ${user.email}, ${user.role}`
}]
};
});
// Start — Communicate via stdio
const transport = new StdioServerTransport();
await server.connect(transport);
After the Server is written, the client starts it as a child process via command: 'node' + args, and both sides exchange JSON messages through standard input/output (stdin/stdout).
5. 6 Real-World Pitfall Records
Pitfall 1: Forgetting type in JSON Config, Trae IDE Reports Format Error
When configuring remote MCP in Trae IDE, only url was written, missing type:
// ❌ Error: Trae defaults to stdio mode parsing, cannot find command field
{
"mcpServers": {
"amap": {
"url": "https://mcp.amap.com/mcp?key=xxx"
}
}
}
// ✅ Add type field
{
"mcpServers": {
"amap": {
"type": "sse",
"url": "https://mcp.amap.com/mcp?key=xxx"
}
}
}
Root Cause: Tools (Trae / Claude Code) default to parsing config in stdio mode. Without type, they look for the command field, fail to find it, and report a format error.
Pitfall 2: @langchain/openai's apiKey Must Be Placed Inside configuration
// ❌ Error: Missing credentials
const model = new ChatOpenAI({
modelName: 'deepseek-v4-pro',
apiKey: process.env.DEEPSEEK_API_KEY, // Placing it outside is useless!
configuration: { baseURL: '...' },
});
// ✅ Correct: apiKey inside configuration
const model = new ChatOpenAI({
modelName: 'deepseek-v4-pro',
configuration: {
apiKey: process.env.DEEPSEEK_API_KEY, // Takes effect only here
baseURL: 'https://api.deepseek.com/v1',
},
});
Root Cause: In @langchain/openai v1.5.5 source code, configuration.apiKey is read first; the top-level apiKey is not passed to the underlying OpenAI client.
Pitfall 3: .env File Path — Where dotenv Looks
When code uses import 'dotenv/config', dotenv defaults to looking for the .env file in the current working directory (CWD).
You run in this directory → dotenv looks for .env in this directory
❌ Run node mcp-test.mjs inside src/ → looks for src/.env (not found)
✅ Run node src/mcp-test.mjs inside remote-mcp/ → looks for remote-mcp/.env (found)
Two Solutions:
- Place
.envin the directory where you execute thenodecommand - Or explicitly specify the path in code:
import { config } from 'dotenv'; config({ path: '../.env' });
Pitfall 4: Amap Free API QPS Limit Exceeded
In Round 3, AI called 4 maps_search_detail simultaneously; the free API only allows a limited number of requests per second:
ToolException: MCP tool 'maps_search_detail' returned an error:
API call failed: CUQPS_HAS_EXCEEDED_THE_LIMIT
Solution: Add a delay after each tool call:
await new Promise(r => setTimeout(r, 500)); // 500ms interval
Pitfall 5: FileSystem MCP Path Permission Restriction
AI wanted to write HTML to C:\workspace\hotels.html, but FileSystem MCP was only authorized to access C:/Users/.../workspace:
Access denied - path outside allowed directories:
C:\workspace\hotels.html not in C:\Users\Hjf20\Desktop\workspace
Solution: Set the allowed directory larger during configuration (e.g., workspace root), or explicitly tell AI in the prompt which path to save to.
Pitfall 6: Wrong for Loop — Tool Calls Outside the Loop
This was the most subtle bug I encountered:
// ❌ Notice this semicolon! The for loop body ends here
for (let i = 0; i < maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
}; // ← This semicolon! The loop body ends here
// The code below only executes once after the loop finishes!
if (!response.tool_calls || ...) { ... }
for (...) { ... }; had an extra semicolon, causing the loop body to only contain invoke and push. The tool call handling was outside the loop, executing only for the last iteration.
Correct Approach: Tool call handling must be placed inside the loop body, forming a complete Agent loop together with invoke.
6. Execution Results
After resolving all pitfalls, the Agent ran successfully:
Loaded tools: maps_geo, maps_around_search, maps_search_detail, ..., write_file, new_page, ...
Round 1 → maps_geo // "Beijing South Station" → (116.xxx, 39.xxx)
Round 2 → maps_around_search // Search nearby hotels → Return list
Round 3 → maps_search_detail ×4 // Query hotel details one by one (including image URLs)
Round 4 → write_file // Generate HTML saved locally
Round 5 → new_page // Open Chrome to display (requires Chrome installed)
All 31 tools loaded normally, and the Agent loop ran correctly.
7. Key Concept Quick Reference
| Concept | One-Line Explanation |
|---|---|
| MCP | Standard communication protocol between AI and tools |
| stdio mode | Parent process spawns child process, communicates via standard input/output |
| HTTP mode | Directly request remote URL, an MCP service maintained by others |
npx vs node |
npx auto-downloads npm packages and runs them; node runs local files |
command + args |
Startup method for local MCP (launching child process) |
url |
Connection method for remote MCP (sending HTTP requests) |
| Agent Loop | AI thinks → Decides which tools to call → Executes → Feeds results back → Thinks again |
tool_call_id |
Unique identifier linking tool results to call requests |
| QPS Limit | Free APIs have a cap on requests per second; adding delays can circumvent it |
8. Summary
The essence of MCP can be summed up in one sentence: Extract tools from AI applications, standardize communication, so any MCP Server written by anyone can be called by any AI Agent.
Remember this code skeleton:
// 1. Connect multiple MCP Servers (mix of remote + local)
const mcpClient = new MultiServerMCPClient({
mcpServers: {
'remote': { url: 'https://...' },
'local': { command: 'npx', args: ['-y', 'package_name'] },
}
});
// 2. Get tools, bind to model
const tools = await mcpClient.getTools();
const modelWithTools = model.bindTools(tools);
// 3. Agent Loop
for (let i = 0; i < maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
if (!response.tool_calls?.length) return response.content; // End
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();
Hope this article helps you avoid a few pitfalls. Feel free to ask questions in the comments!