摘要:手写内联工具的两个致命缺陷------项目锁定和语言锁定,MCP 协议如何通过 stdio/HTTP 跨进程通信彻底解决,包含 MCP Server 注册工具与资源、MCP Client 获取与调用的完整实战。
在上一篇文章中,我们用 200 多行代码手写了一个 mini-Cursor Agent。它通过四个内联工具(read_file、write_file、list_directory、execute_command)实现了自动创建项目、写入代码、安装依赖和启动服务的能力。但很快你会发现这套方案有两个致命问题:
- 项目锁定 :这四个工具定义在
all-tools.mjs中,和 Agent 代码紧耦合。换个项目想复用这些工具?只能复制粘贴。 - 语言锁定:Node.js 写的工具,Java 或 Python 项目怎么用?Rust 写的 CLI 工具能直接整合进 Agent 吗?
这些问题的本质是:工具和 LLM 之间缺乏一个标准化的通信协议。而 MCP(Model Context Protocol)正是为了解决这个问题而生的------它让工具独立于 LLM,支持本地/远程、跨语言调用,通过标准化协议实现 LLM 和工具的解耦。
为什么需要 MCP:从内联工具到跨进程调用
回顾此前 mini-Cursor 的工具定义方式:
javascript
const tools = [
readFileTool, // 定义在 all-tools.mjs
writeFileTool, // 定义在 all-tools.mjs
listDirectoryTool, // 定义在 all-tools.mjs
executeCommandTool, // 定义在 all-tools.mjs
];
const modelWithTools = model.bindTools(tools);
这段代码存在几个结构性问题。工具和 Agent 在同一个进程里,工具代码和 Agent 代码在同一个仓库里,而且工具只能用 Node.js 写------因为 bindTools 接收的是 JavaScript 函数对象。如果一个团队用 Python 写了一个强大的数据分析工具,想给 Node.js 的 Agent 用,只能用 Python 重写一遍,或者在 Node.js 里通过 child_process 手动调用 Python 脚本,再手动解析输出。
MCP 的解决思路是:把工具从 Agent 进程里"拿出去",放到一个独立的 MCP Server 进程中,Agent 通过标准化的协议与它通信。不管是本地工具还是远程工具,Agent 想跨进程调用某个工具,通过 MCP 协议就行。
两种传输模式:本地用 stdio,远程用 HTTP
MCP 支持两种传输方式,覆盖了本地和远程两种场景:
| 传输方式 | 适用场景 | 底层机制 |
|---|---|---|
| stdio(标准输入输出) | 本地跨进程调用 | 键盘输入、控制台输出------当一个进程(Agent)启动一个子进程(MCP Server)时,通过 stdio 实现进程间通信 |
| HTTP | 远程跨网络调用 | 标准的 HTTP 请求/响应,MCP 掌管远程通信 |
stdio 模式的核心是 IPC(进程间通信) 。Agent 进程通过 child_process 启动一个 MCP Server 子进程,两者通过标准输入输出流交换 JSON 数据。这和 fetch 调用 API 接口有本质区别:MCP 不是去拿接口数据,而是去扩展 Context------让 LLM 能做的事情更多(Tool),知道的东西更多(Resource)。
MCP Server:注册工具和资源
MCP Server 是一个独立的进程,通过 @modelcontextprotocol/sdk 创建。Server 可以注册两种内容:Tool (工具)和 Resource(资源)。
注册 Tool:让 Agent 能查询用户信息
以下是一个完整的 MCP Server 实现,它注册了一个 query_user 工具和一个使用指南资源:
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: 'zs@qq.com', role: 'admin' },
'002': { id: '002', name: '李四', email: 'ls@qq.com', role: 'user' },
'003': { id: '003', name: '王五', email: 'ww@qq.com', role: 'user' },
},
};
const server = new McpServer({
name: 'my-mcp-server',
version: '1.0.0',
});
// 注册 Tool
server.registerTool(
'query_user',
{
description: '查询数据库中的用户信息。输入用户ID,返回该用户的详细信息(姓名、邮箱、角色)',
inputSchema: {
userId: z.string().describe('用户ID,例如:001, 002, 003'),
},
},
async ({ userId }) => {
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}`,
},
],
};
}
);
registerTool 的三个参数分别对应了工具的三个核心要素:名称 (query_user)、描述和 Schema (告诉 LLM 这个工具的参数和用途)、执行函数 (实际干活的逻辑)。这和 LangChain 的 tool() 函数在结构上完全一致------只是 MCP 的工具定义是跨进程的,而 LangChain 的工具定义是进程内的。
注册 Resource:给 LLM 补充背景知识
除了 Tool,MCP Server 还可以注册 Resource。Resource 是一种静态内容,可以作为 SystemMessage 的一部分注入到 LLM 的上下文中,让 LLM 在对话前就"知道"一些背景信息:
javascript
server.registerResource(
'使用指南',
'docs://guide',
{
description: 'MCP Server 使用指南',
mimeType: 'text/plain',
},
async () => {
return {
contents: [
{
uri: 'docs://guide',
mimeType: 'text/plain',
text: `
MCP Server 使用指南
功能:提供用户查询等工具
使用:在 Cursor 等 MCP Client 中通过自然语言对话,Cursor 会自动调用相应工具。
`,
},
],
};
}
);
Resource 的 URI 是一个逻辑标识符(如 docs://guide),不指向真实的文件路径。LLM 通过这个 URI 来"读取"资源内容。Resource 和 RAG 是两种不同的上下文补充策略:RAG 是先检索再注入,适合海量文档的按需查询;Resource 是直接注入,适合篇幅不大、需要 LLM 在对话开始前就了解的基础信息。
启动 Server:通过 stdio 暴露服务
javascript
const transport = new StdioServerTransport();
await server.connect(transport);
StdioServerTransport 把 MCP Server 挂载到标准输入输出流上。当 Agent 进程通过 child_process 启动这个 Server 文件时,两个进程之间的 stdio 通道就建立起来了------Agent 通过 stdin 发送请求,Server 通过 stdout 返回结果。
MCP Client:Agent 如何接入 MCP
有了 MCP Server,Agent 端需要一个 MCP Client 来发现和调用这些工具。LangChain 的 @langchain/mcp-adapters 提供了 MultiServerMCPClient,可以同时连接多个 MCP Server:
javascript
import { MultiServerMCPClient } from '@langchain/mcp-adapters';
import { ChatOpenAI } from '@langchain/openai';
const model = new ChatOpenAI({
modelName: 'deepseek-v4-flash',
apiKey: process.env.DEEPSEEK_API_KEY,
configuration: {
baseURL: process.env.DEEPSEEK_BASE_URL,
},
});
// 配置 MCP Server ------ 可以配置多个
const mcpClient = new MultiServerMCPClient({
mcpServers: {
'my-mcp-server': {
command: 'node',
args: [
'F:/workspace/zzd_ai/ai/agent_in_action/mcp-demo/src/my-mcp-server.mjs',
],
},
},
});
MultiServerMCPClient 的配置中,每个 Server 用 command + args 指定启动方式。底层原理是 child_process.spawn------Agent 进程启动一个 Node 子进程来运行 MCP Server,两个进程通过 stdio 通信。主进程通过 stdio 和他们"通话",close() 则把这个链接和子进程一起关掉。
获取 Tool 和 Resource
javascript
// 获取所有 MCP Server 注册的工具
const tools = await mcpClient.getTools();
// 获取所有 MCP Server 注册的资源
const res = await mcpClient.listResources();
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;
}
}
getTools() 返回的工具列表可以直接传给 model.bindTools(tools),和之前内联工具的用法完全一致。区别在于:这些工具现在运行在另一个进程 中,甚至可以用另一种语言编写。
listResources() 返回的是一个嵌套结构:外层是 Server 名称到资源列表的映射,内层是每个资源的 URI 和描述。上面的代码用 Object.entries() 遍历这个嵌套对象,把每个资源的内容拼成一个字符串,作为 SystemMessage 的一部分注入到 LLM 的上下文中。Object.entries() 把对象转换为 [key, value] 二维数组,是处理这种嵌套结构的标准方式:
javascript
const obj = {
'bytedance': ['AI全栈开发', 'Agent 工程师'],
'tencent': ['后端开发', 'Agent 工程师'],
'163': ['前端开发'],
};
for (let [key, value] of Object.entries(obj)) {
console.log(key, value);
}
ReAct 循环 + MCP 工具
工具和资源获取完毕后,Agent 的 ReAct 循环和之前完全一致,只是 SystemMessage 中多了一段资源内容:
javascript
const modelWithTools = model.bindTools(tools);
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [
new SystemMessage(resourceContent), // 资源作为 SystemMessage 的一部分
new HumanMessage(query),
];
for (let i = 0; i < maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
if (!response.tool_calls || response.tool_calls.length === 0) {
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);
messages.push(
new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id,
})
);
}
}
}
return messages[messages.length - 1].content;
}
await runAgentWithTools('MCP Server 的使用指南是什么?');
// 或者
await runAgentWithTools('查询用户ID为002的信息');
// 关闭所有 MCP 子进程与通信通道,释放进程资源
await mcpClient.close();
mcpClient.close() 是一个容易被忽略但至关重要的步骤。Agent 进程启动 MCP Server 子进程后,子进程会一直运行。如果不调用 close(),Node 脚本不会退出,会一直挂着占用系统资源。close() 关闭和 MCP Server 的通信通道,同时终止子进程。
MCP 与 Tool Use 的本质区别
回顾整个 MCP 的实现,可以总结出 MCP 和传统 Tool Use 之间的一个关键对比:
| 维度 | 内联 Tool Use | MCP |
|---|---|---|
| 工具位置 | Agent 进程内 | 独立进程(本地或远程) |
| 语言限制 | 必须和 Agent 同语言 | 任意语言,通过 stdio/HTTP 通信 |
| 复用性 | 项目锁定,复制粘贴 | 一个 Server 可被多个 Agent 复用 |
| 生命周期 | 随 Agent 启动/销毁 | 独立生命周期,Agent 通过 close() 控制 |
| 传输方式 | 内存中的函数调用 | stdio(本地 IPC)或 HTTP(远程) |
MCP 不是取代 Tool Use,而是把 Tool Use 的能力从"进程内"扩展到了"跨进程"。Tool 本身的定义方式(名称 + Schema + 执行函数)没有变化,变化的是调用路径:从内存中的函数调用变成了跨进程的 stdio/HTTP 通信。
总结
MCP(Model Context Protocol)用一句话概括就是:给 Model 扩展 Context,让它能做的更多(Tool),知道的更多(Resource),通过标准化的 Protocol 实现。核心机制可以拆为三个层次:
- 跨进程通信 :Agent 通过
child_process启动 MCP Server 子进程,两者通过 stdio 交换 JSON 数据。主进程负责发起请求,子进程负责执行工具并返回结果。 - 工具与资源分离:Tool 是"做事"的能力(查询用户、执行命令),Resource 是"知道"的信息(使用指南、文档说明),两者都作为 Context 注入 LLM 的推理过程。
- 生命周期管理 :
MultiServerMCPClient负责启动子进程、获取工具和资源、执行调用,close()负责释放子进程和通信通道。
回到文章开头的两个问题------项目锁定和语言锁定------MCP 给出了一个清晰的答案:把工具从 Agent 进程中分离出来,用 stdio 或 HTTP 作为通信桥梁,让任何语言编写的工具都能被任何 Agent 调用。 在这个架构下,一个 Python 数据科学家写的数据分析工具,可以被一个 Node.js 的 Agent 通过 MCP 直接调用,无需任何代码重写。
MCP 正在成为 AI Agent 生态的基础设施。Cursor、Trae 等 IDE 的 MCP 支持,本质上就是内置了一个 MCP Client,让用户可以通过配置 mcpServers 来扩展 IDE 的能力。理解了 MCP,你就理解了 AI Agent 从"单机工具"走向"分布式能力网络"的关键一步。