MCP 本地调用(stdio)完全解析
这篇文章会一步步拆解 MCP 的本地调用模式——也就是通过 stdio 管道通信的方式。我们会从实际痛点出发,写一个完整的 MCP Server,再让 Agent 端连上来用。全程有代码、有流程图、有对比,适合想彻底搞懂 MCP 工作原理的开发者。
一、为什么需要 MCP
之前 Tool 的问题
在 tool.mjs 和 mini-cursor.mjs 里,工具是直接写在 Agent 代码里的:

// 工具定义和 Agent 在同一文件
const readFileTool = tool(async ({ filePath }) => {
const content = await fs.readFile(filePath, 'utf-8');
return content;
}, { name: 'read_file', ... });
const tools = [readFileTool];
const modelWithTools = model.bindTools(tools);
这样写看着挺直接,但一细想,问题就出来了:
- 不能跨项目复用: 换个项目,工具代码得拷过去重写
- 不能跨语言: 用 Python 写的工具,Node 项目调不了
- 耦合太紧: 工具开发者必须懂 Agent 代码,Agent 开发者必须了解工具细节
MCP 解决了什么
MCP(Model Context Protocol)把 Tool 从 Agent 里拆出来,变成独立进程,通过标准协议通信:
之前: 之后:
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ Agent │ │ Agent │ │ MCP Server │
│ + LLM │ │ + LLM │←───→│ + Tools │
│ + Tools │ └──────────┘ └──────────────┘
└──────────────┘ 解耦!可复用!可跨语言!
二、两个文件,两种角色
mcp-demo/src/ 下有两个文件,一个提供服务,一个消费服务:
langchain-mcp-test.mjs(父进程 / Agent / MCP Client)
│
│ child_process.spawn('node', ['my-mcp-server.mjs'])
│ 通过 stdin/stdout 管道通信
│
▼
my-mcp-server.mjs(子进程 / MCP Server)
│
│ 提供:query_user 工具 + docs://guide 资源
和 API Server 与 API Client 的关系一模一样——一个是后端服务,一个是前端调用。
三、my-mcp-server.mjs — 写一个 MCP Server
3.1 创建 Server 实例
const server = new McpServer({
name: 'my-mcp-server',
version: '1.0.0'
});
这是固定步骤。 McpServer 是 SDK 提供的类,new 一个实例出来,后面才能在上面注册工具和资源。
name:自己取名,标识这个 Server,Agent 端配置时会用到version:版本号,随意
3.2 注册工具
server.registerTool('query_user', // ① 工具名(LLM 看到的名字)
{
// ② 描述对象
description: '查询数据库中的用户信息。输入用户ID,返回该用户的详细信息(姓名、邮箱、角色)',
inputSchema: z.object({
userId: z.string().describe('用户ID, 例如:001, 002, 003')
})
},
async ({ userId }) => { // ③ 处理函数(LLM 提取的参数传进来)
const user = database.users[userId];
if (!user) {
return {content: [{ type: 'text', text: `用户 ID ${userId} 不存在。可用的ID: 001, 002, 003` }]};
}
return {content: [{type: 'text', text: `用户 ${user.id} 的信息是:姓名:${user.name}, 邮箱:${user.email}, 角色:${user.role}`}]};
});
三个参数拆解:
| 参数 | 是什么 | 谁用 |
|---|---|---|
'query_user' | 工具名 | LLM 看到这个名字,决定是否调用 |
{ description, inputSchema } | 描述 + 参数约束 | LLM 根据 description 判断用途,根据 schema 填参数 |
async ({ userId }) => {...} | 真正干活的函数 | 代码执行,userId是 LLM 自动从用户话里提取的 |
返回格式固定:
return {
content: [{ type: 'text', text: '返回给 LLM 的文字内容' }]
};
content 是数组,通常只有一项 [0]。协议设计成数组是为了支持多段内容(比如同时返回文字 + 图片),但绝大多数情况一项就够了。
3.3 注册资源
server.registerResource(
'使用指南', // ① 给人看的名称
'docs://guide', // ② URI,Agent 通过这个地址读取
{ // ③ 元信息
description: 'MCP Server 使用指南',
mimeType: 'text/plain' // 告诉 Agent 这是纯文本
},
async () => { // ④ 返回资源内容的函数
return {
contents: [{
uri: 'docs://guide',
mimeType: 'text/plain',
text: `MCP Server 使用指南
功能:提供用户查询等工具。
使用:在 Cursor 等 MCP Client 中通过自然语言对话,Cursor 会自动调用相应工具。`
}]
};
}
);
四个参数:
| 参数 | 含义 |
|---|---|
'使用指南' | 显示名 |
'docs://guide' | URI,自己取名。docs://只是命名习惯,不是真实网址。叫app://help也行 |
{ description, mimeType } | 描述 + 格式说明 |
async () => {...} | 返回资源内容,和工具返回格式一样 |
资源的作用: 给 LLM 当"说明书"——告诉 LLM 这个 Server 有什么能力、该怎么用。在 Agent 端读取后作为 SystemMessage 注入,LLM 就能看到。
3.4 启动 Server
const transport = new StdioServerTransport();
await server.connect(transport);
固定两步: 创建传输通道 → 绑定并启动。
StdioServerTransport 是 stdio 模式的传输方式——Agent 通过 stdin/stdout 管道和它通信。如果是远程 MCP,这里换成 StreamableHTTPServerTransport 即可,Server 逻辑不用改。
四、langchain-mcp-test.mjs — Agent 端调用
4.1 连接 MCP Server
const mcpClient = new MultiServerMCPClient({
mcpServers: {
'my-mcp-server': {
command: 'node',
args: ['C:UsersrogDesktopwork spacexll_aiaiagent_in_actionmcp-demosrcmy-mcp-server.mjs']
}
}
});
固定格式:
{
'Server 名称': {
command: '用什么启动', // node / python / npx
args: ['路径或参数'] // 传给 command 的参数
}
}
本质就是 child_process.spawn(command, args) 。LangChain 底层帮你 spawn 子进程、连 stdio。不同语言都一样:
{ command: 'node', args: ['./server.mjs'] } // Node
{ command: 'python', args: ['./server.py'] } // Python
{ command: 'npx', args: ['-y', '@amap/mcp-server'] } // npx,自动下载 npm 包
npx -y 包名:自动下载 + 运行,不用先 npm install。-y 跳过确认。
4.2 获取工具和资源
const tools = await mcpClient.getTools(); // 从所有 MCP Server 拿工具列表
const res = await mcpClient.listResources(); // 从所有 MCP Server 拿资源列表
getTools() 和 listResources() 是 MultiServerMCPClient 的固定方法,不能改名。
对比 Tool 阶段:
// 之前:手动定义,手动塞数组
const readFileTool = tool(...)
const tools = [readFileTool]
// 现在:一行自动拿回所有工具
const tools = await mcpClient.getTools()
不管连了几个 MCP Server,getTools() 把所有工具合并到一个数组里给你。
4.3 读取资源内容
let resourceContent = '';
for (const [serverName, resources] of Object.entries(res)) {
for (const resource of resources) {
const content = await mcpClient.readResource(serverName, resource.uri);
resourceContent += content[0].text;
}
}
逐层拆解:
res 的结构是一个对象:
res = {
'my-mcp-server': [{ uri: 'docs://guide', name: '使用指南', mimeType: 'text/plain' }]
// 如果有多个 Server,还会有更多 key
}
第一层循环: Object.entries(res) 把对象拆成 [server名, 资源数组]
// 第 1 圈:serverName = 'my-mcp-server', resources = [{ uri: 'docs://guide', name: '使用指南', ... }]
第二层循环: 遍历当前 Server 下的每个资源
// 第 1 圈:resource = { uri: 'docs://guide', name: '使用指南', mimeType: 'text/plain' }
读取资源:
const content = await mcpClient.readResource(serverName, resource.uri);
// ↑↑ ↑↑
// 从哪个 Server 读哪个资源
两个参数:"去谁家、拿哪个东西"。
取第一段文本:
resourceContent += content[0].text;
// ↑ ↑ ↑
// 追加拼接第一项文本内容
readResource 返回的是数组(和工具返回格式一样),资源通常只有一项,取 [0].text。
全部遍历完的结果:
resourceContent = "MCP Server 使用指南n功能:提供用户查询等工具。n使用:在 Cursor 等..."
所有资源的文字内容拼在一起了。
4.4 作为 SystemMessage 注入 LLM
const messages = [
new SystemMessage(resourceContent), // MCP 资源内容 → 角色知识
new HumanMessage(query) // 用户任务
];
资源内容变成了 SystemMessage。 LLM 看到它就知道:"哦,我有一个叫 query_user 的工具,用来查用户信息,参数是 userId"。
4.5 绑定工具 + ReAct 循环
const modelWithTools = model.bindTools(tools);
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [
new SystemMessage(resourceContent),
new HumanMessage(query)
];
for (let i = 0; i < maxIterations; i++) {
console.log(chalk.bgGreen(`正在等待AI思考, 第${i}轮....`));
const response = await modelWithTools.invoke(messages);
messages.push(response);
// 没有 tool_calls → LLM 直接答了 → 结束
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(`\n AI 最终回复:\n ${response.content}`);
return response.content;
}
// 有 tool_calls → LLM 要调工具
console.log(chalk.bgBlue(`检测到 ${response.tool_calls.length} 个工具调用`));
console.log(chalk.bgBlue(`工具调用: ${response.tool_calls.map(t => t.name).join(', ')}`));
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);
// 结果塞回 messages,必须带 tool_call_id
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id
}));
}
}
}
// 兜底:循环到上限还没结束,返回最后一条消息内容
return messages[messages.length - 1].content;
}
完整执行流程:
第 1 轮 invoke:
messages = [SystemMessage(资源内容), HumanMessage("查用户002")]
LLM 看到资源 → 知道有 query_user 工具
LLM 判断要查用户 → 返回 tool_calls: [{ name: 'query_user', args: { userId: '002' }, id: 'call_001' }]
↓
遍历 tool_calls → find("query_user") → 找到 → invoke({ userId: '002' })
↓
push ToolMessage({ content: "用户 002:光光...", tool_call_id: 'call_001' })
↓
第 2 轮 invoke:
messages = [SystemMessage, HumanMessage, AIMessage(tool_calls), ToolMessage(用户信息)]
LLM 看到工具结果 → 没有更多要调了 → 返回 content: "用户 002 是光光..."
↓
检测到没有 tool_calls → return "用户 002 是光光..."
4.6 关闭连接
await mcpClient.close();
关闭所有 MCP 连接、杀掉子进程、释放资源。必须调用,否则脚本跑完子进程还挂着。
五、核心概念串讲
5.1 变量名 vs 方法名
const mcpClient = new MultiServerMCPClient({...}) // mcpClient 自己取的
await mcpClient.getTools() // getTools 框架定的
await mcpClient.listResources() // listResources 框架定的
await mcpClient.readResource(...) // readResource 框架定的
await mcpClient.close() // close 框架定的
规律:点号前面自己取名,点号后面框架写死。
5.2 工具调用决策全流程
用户说"查询用户002"
→ invoke(messages)
→ LLM 云端决策:需要调 query_user,userId = "002"
→ 返回 { tool_calls: [{ name: 'query_user', args: { userId: '002' }, id: 'call_001' }] }
→ find("query_user") 在 tools 里找匹配
→ tool.invoke({ userId: '002' }) 执行
→ ToolMessage({ content: 结果, tool_call_id: 'call_001' })
→ 再 invoke,LLM 看到结果,回复用户
决策是 LLM 做的,代码只负责执行。 invoke 那一行,LLM 在云端完成推理。
5.3 tool_call_id 为什么重要
LLM 可能一次调多个工具:
tool_calls: [
{ name: 'read_file', args: {...}, id: 'call_001' },
{ name: 'write_file', args: {...}, id: 'call_002' },
]
每个 ToolMessage 必须带对应的 id,LLM 才知道"这个结果是对应 call_001 还是 call_002"。就像同时给三个人发微信,每条回复必须标记是对哪条的。
5.4 资源内容读取链路
Server: registerResource('使用指南', 'docs://guide', ...)
→ Agent: listResources() 拿到 [{ uri: 'docs://guide', name: '使用指南' }]
→ Agent: readResource('my-mcp-server', 'docs://guide')
→ Server: 返回 { contents: [{ text: 'MCP Server 使用指南...' }] }
→ Agent: content[0].text 取出文字 → 拼进 resourceContent
→ new SystemMessage(resourceContent) → LLM 看到
5.5 ReAct 循环的两层判断
// 外层 for 循环:控制最大轮数
for (let i = 0; i < maxIterations; i++) {
// 内层判断:这轮要不要调工具
if (!response.tool_calls || response.tool_calls.length === 0) {
return response.content; // 不用 → 结束
}
// 需要 → 逐一执行
for (const toolCall of response.tool_calls) { ... }
}
// 到了 maxIterations → 兜底返回
return messages[messages.length - 1].content;
六、MCP 的核心价值
| Tool 阶段 | MCP Server 阶段 | |
|---|---|---|
| 工具放哪 | Agent 代码里 | 独立进程,独立文件 |
| 怎么调 | 函数调用 | MCP 标准协议(stdio) |
| 换项目能用吗 | 不能,得重写 | 能,一行command+args配置 |
| 跨语言 | 不能 | Ja va/Python/Rust 都能写 MCP Server |
| 谁维护 | Agent 开发者 | 工具开发者独立维护 |
| 能不能多个 | 手动拼数组 | MultiServerMCPClient自动合并 |
一句话:让工具脱离 Agent 独立存在,任何 Agent 想用就连上来,不管语言、不管在哪。
七、复习检查清单
- 能写出创建 MCP Server 的 4 个固定步骤
- 能写出 Agent 端连接 MCP Server 的固定格式(
command + args) - 理解
getTools()/listResources()/readResource()的作用和返回值结构 - 能解释资源读取的两层循环在做什么
- 理解
tool_call_id为什么必须带 - 能画出 ReAct 循环的完整流程图
- 能说出 MCP 相比原始 Tool 的 4 个优势
