跪拜 Guibai
← Back to the summary

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:


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:

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:

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!