Chaining Remote and Local MCP Servers into a Single Agent Workflow
MCP Remote Call (HTTP) Complete Analysis: Chaining Multiple MCP Servers to Build an AI Workflow
Foreword
In the previous article, we wrote a local MCP Server where the Agent spawns a child process via command + args and communicates through stdio pipes. This article goes further: connecting to a remote MCP Server using url, launching a local npm package with npx in one click, and chaining three Servers together so the Agent can automatically complete a full workflow of "check map → open browser → write file".
1. From stdio to HTTP: Same Protocol, Different Transport
1.1 The Core Remains Unchanged
The protocol content is exactly the same: tools/list, tools/call, resources/read... The language spoken between the LLM and the tools is still the same. The only thing that changes is the transport medium:
stdio (local) HTTP (remote)
Agent Process Agent Process
│ │
│ spawn + stdin/stdout pipe │ POST http://server:3000/mcp
▼ ▼
MCP Server Child Process MCP Server (standalone HTTP service)
- stdio = two people talking directly in the same room (sound travels through the pipe)
- HTTP = one in Beijing, one in Shanghai, talking on the phone (data travels over the network)
1.2 Three Ways to Write the Client Configuration
// Method 1: Local file — spawn child process
{ command: 'node', args: ['path/to/server.mjs'] }
// Method 2: npx — spawn child process + auto-download npm package
{ command: 'npx', args: ['-y', 'package-name'] }
// Method 3: Remote HTTP — send network request
{ url: 'https://remote-address/mcp' }
No matter which method, MultiServerMCPClient manages them uniformly, and getTools() fetches all tools at once.
2. What is npx
npx -y package-name
| Part | Meaning |
|---|---|
npx |
Node's built-in command to run npm packages |
-y |
Skip the "Download? [y/n]" confirmation and execute directly |
package-name |
A package on npm, e.g., @modelcontextprotocol/server-filesystem |
Difference from npm install: npx automatically downloads, runs, and discards (or caches) the package. No need for a prior global installation; one command does it all.
Its value in MCP: Tool developers publish MCP Servers to npm, and you can use them with a single npx line without writing any tool code yourself.
3. Configuration Analysis of Three MCP Servers
const mcpClient = new MultiServerMCPClient({
mcpServers: {
'amap-server': {
url: 'https://mcp.amap.com/mcp?key=f58f8879063a5f61df94bbb40a6305cb'
},
'file-system': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', 'allowed-directory']
},
'chrome-devtools': {
command: 'npx',
args: ['-y', 'chrome-devtools-mcp@latest']
}
}
});
3.1 Naming the Three Servers
'amap-server', 'file-system', 'chrome-devtools' — all names are chosen by you. Variable names are free; the framework only cares about the configuration structure that follows.
3.2 Fixed Fields in the Configuration Structure
| Field | Meaning | Defined By |
|---|---|---|
url |
Remote HTTP address | Fixed field, value is the actual address |
command |
What to use to start the child process | Fixed field, value is node/python/npx |
args |
Parameters passed to the command | Fixed field, value is an array |
3.3 amap-server — Remote HTTP
'amap-server': {
url: 'https://mcp.amap.com/mcp?key=xxx'
}
No child process is started. The Agent communicates with Amap's servers via HTTP POST requests. The tools run on the remote server. Provided tools: maps_geo (address → coordinates), maps_around_search (nearby search), maps_search_detail (place details), maps_distance (distance calculation), etc.
3.4 file-system — Local npx, with a Security Boundary
'file-system': {
command: 'npx',
args: [
'-y',
'@modelcontextprotocol/server-filesystem',
'C:\...\remote-mcp\src' // Only allowed to access this directory
]
}
Provided tools: read_file, write_file, search_files, get_file_info, list_allowed_directories.
The last parameter is the security boundary — this MCP Server can only operate within this directory; going outside it results in Access denied. This is a built-in security mechanism of the file-system MCP.
3.5 chrome-devtools — Browser Control
'chrome-devtools': {
command: 'npx',
args: ['-y', 'chrome-devtools-mcp@latest']
}
Provided tools: navigate_page, new_page, close_page, click, fill, fill_form, take_screenshot, take_snapshot, list_pages, evaluate_script, wait_for, and dozens of other browser operation tools. Equivalent to a robot that can automatically control a browser.
3.6 One-Sentence Summary
Remote uses url, local uses command + args, npx saves you from installing yourself.
One MultiServerMCPClient manages all three types simultaneously, getTools() fetches them all at once.
4. Getting Tools
const tools = await mcpClient.getTools();
The tool code is not in your files:
| Server | Where the Tools Are |
|---|---|
| amap-server | On Amap's servers |
| file-system | In the @modelcontextprotocol/server-filesystem npm package downloaded by npx |
| chrome-devtools | In the chrome-devtools-mcp npm package downloaded by npx |
You haven't written a single line of tool code, but you've obtained dozens of tools. This is the reuse value of MCP.
5. SystemMessage — Telling the LLM the Working Directory
const messages = [
new SystemMessage(`Current working directory: C:\...\remote-mcp\src
All file operations (read, write, save) must be performed under this directory. Do not use other paths.`),
new HumanMessage(query),
];
This is experience gained from stepping on a pitfall. The file-system only allows writing to a specified directory, but the LLM doesn't know this — it might invent a Linux path (/home/user/...), resulting in Access denied. Explicitly stating the working directory in the SystemMessage prevents the LLM from guessing wildly.
6. ReAct Loop
6.1 invoke
const response = await modelWithTools.invoke(messages);
messages.push(response);
The LLM completes the decision-making in the cloud. The returned response might be a direct answer, or it might be tool_calls.
6.2 Determining Whether to Call a Tool
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(chalk.green(`AI Answer: ${response.content}`));
return response.content;
}
Exit if either of two conditions is met:
!response.tool_calls— tool_calls isundefined, the LLM didn't want to call a tooltool_calls.length === 0— the LLM said it would call a tool but gave an empty array (almost never happens, a safety net)
6.3 Printing Tool Names
console.log(chalk.bgBlue(`AI Calling Tools: ${response.tool_calls.map(t => t.name).join(', ')}`));
Takes only the names from the tool call list, joins them with commas, and prints them on a blue background for the developer to see. Purely debugging information.
6.4 Iterating Through Tool Calls
for (const tool_call of response.tool_calls) {
const foundTool = tools.find(t => t.name === tool_call.name);
for...of iterates serially, executing one tool before running the next. find matches the real tool object in the tool library by the name given by the LLM.
6.5 Executing the Tool
if (foundTool) {
const toolResult = await foundTool.invoke(tool_call.args);
tool_call.args are parameters the LLM automatically extracts from the user's natural language. The LLM infers and fills them in automatically by referencing the tool's schema description; you don't need to pass them manually.
6.6 Handling Return Values (Four-Level Judgment)
Different MCP Servers return different formats, requiring a unified conversion to a string before placing into a ToolMessage:
let contentStr;
if (typeof toolResult === 'string') {
contentStr = toolResult;
}
// Case 1: The return is a bare string, use it directly
else if (Array.isArray(toolResult.content)) {
contentStr = toolResult.content.map(c => c.text || '').join('\n');
}
// Case 2: MCP standard format [{type:'text', text:'...'}, ...]
// Extract .text from each item, join with newlines. || '' as a safety net
else if (typeof toolResult.content === 'string') {
contentStr = toolResult.content;
}
// Case 3: The content field is a string, take it directly
else {
contentStr = JSON.stringify(toolResult);
}
// Case 4: Safety net, convert the entire object to a JSON string. The LLM can at least see the content, no crash
The four judgments serve a single purpose: ensuring contentStr is always a string, safely placed into a ToolMessage.
6.7 Pushing Back into messages
messages.push(new ToolMessage({
content: contentStr,
tool_call_id: tool_call.id,
}));
tool_call_id must never be lost. The LLM might call 3 tools simultaneously, and each result must be marked to correspond to which call, just like sending WeChat messages to three people — the reply must indicate who it's for.
6.8 Fallback Return
return messages[messages.length - 1].content;
When maxIterations (default 30 rounds) is reached without finishing, take the last message as the result and return it. Prevents the LLM from entering an infinite loop.
7. Practical Workflow
await runAgentWithTools(
`Hotels near Fuzhou East China University of Technology, the 3 nearest hotels,
get hotel images, open the browser, display each hotel's image,
show each URL in a separate tab, and change the page title to the hotel name`
);
The Agent's Actual Execution Steps
Round 0: maps_geo (address → coordinates)
Round 1: maps_around_search (search nearby hotels)
Round 2: maps_search_detail × 3 (check each hotel's details + images)
Round 3: new_page (open hotel image links)
→ new_page → new_page → ... (one tab per hotel)
→ select_page → evaluate_script (change page title)
Round 4: Answer the user
Three MCP Servers are automatically orchestrated and chained by the Agent, requiring no orchestration logic written by you.
8. Common Pitfalls
8.1 API Rate Limiting
Amap's free key has a QPS limit. The Agent concurrently calling 8 maps_search_detail directly blows through the quota:
CUQPS_HAS_EXCEEDED_THE_LIMIT
Switch to a paid key or instruct the LLM in the prompt to query in batches.
8.2 Parameter Format Errors
The LLM provided coordinate parameters for maps_distance in a format that didn't meet Amap's requirements, returning:
INVALID_PARAMS
The LLM generates different parameters each time; re-running usually resolves it. Or add parameter format constraints in the SystemMessage.
8.3 Path Out of Bounds
The LLM invented a C:\home\user... (a Linux habit), exceeding the allowed directory range for the file-system:
Access denied - path outside allowed directories
Explicitly telling the LLM the working directory in the SystemMessage resolves this.
9. Complete Workflow Walkthrough
① Create model (ChatOpenAI)
② Create mcpClient (MultiServerMCPClient), configure 3 MCP Servers
- amap: Remote HTTP
- file-system: npx local, specifying a secure directory
- chrome-devtools: npx local, controlling the browser
③ mcpClient.getTools() automatically fetches all tools
④ model.bindTools(tools) binds the tools
⑤ runAgentWithTools(query):
messages = [SystemMessage(working directory constraint), HumanMessage(user task)]
→ for loop (max 30 rounds):
→ invoke → determine if tool call is needed
→ not needed → end
→ needed → iterate tool_calls → find match → invoke execute
→ four-level judgment to handle return value → ToolMessage with tool_call_id pushed back
→ limit reached → return the last message
⑥ mcpClient.close() releases resources
10. Complete Comparison of the Two MCP Types
| Local stdio (mcp-demo) | Remote HTTP (remote-mcp) | |
|---|---|---|
| Server Location | Your files | Amap servers / npx cached packages |
| Connection Method | command + args |
url / command + args |
| Transport | stdin/stdout pipe | HTTP network / pipe |
| Cross-Machine | No | Yes (url method) |
| Tool Code | Written by you | Written by others, you use directly |
| Server Count | 1 | Multiple can be chained |
The core remains unchanged: The McpServer interface is fixed, the tool registration method is unchanged, and the Agent-side bindTools + ReAct loop is unchanged. Only the Transport and configuration method change.