MCP即插即用实践指南
把天气、任务 待办两个工具封装成远程 MCP Server(7890 端口常驻服务,Streamable HTTP 传输),再让手写 ReAct 循环的 Agent 通过 MCP Client 接入,全程抓出原始 JSON-RPC 报文,把 MCP 的通信黑盒拆成看得见的消息交换。
写作目的:不为用框架,而是吃透 MCP 的本质------它不是魔法,就是"一次 HTTP 请求,携带一条 JSON-RPC 报文"。看懂这层,Cursor、Claude Desktop、Qoder 里的 MCP 对你就没有黑盒。
一、为什么需要 MCP:Agent 架构的必然选择
从 Agent 架构看,一个 Agent = LLM(大脑,负责推理决策)+ 记忆/上下文 + 工具(手脚,负责与外部世界交互) 。模型自己查不了天气、读不了数据库、发不了消息------所有"动作型"能力都必须委托给工具执行,于是架构上必然出现一个问题:工具以什么方式接入 Agent。
最原始的实现是把工具写进 Agent 代码:函数 + JSON Schema 声明硬编码在进程内,随请求一起发给模型。单项目内可行,但站到整个 Agent 生态的高度看,有三个结构性问题:
- M×N 集成问题 :市面上有 M 个 Agent 宿主(Cursor、Claude Desktop、Qoder、自建),有 N 种工具能力(GitHub、数据库、飞书......)。每个 Agent 为每个工具各写一套对接,就是 M×N 份重复实现;用 MCP 统一接口后,双方各自只实现一次协议,降到 M + N------和 USB 一个道理:外设不为每台电脑设计专属插头。
- 职责耦合:Agent 架构里,Agent 的职责是编排与推理,不是实现工具。工具硬编码意味着工具迭代(加接口、换数据源)必须动 Agent 的代码与发版,两类变更节奏被绑死。
- 凭证与生命周期无法隔离:工具往往携带凭证(token、数据库连接串),硬编码进 Agent 进程后,凭证管理混入 Agent 部署;工具也无法独立启停、独立升级、被多个 Agent 共享。
MCP(Model Context Protocol)就是把"工具的定义、发现、调用、结果返回"全部标准化成协议,把工具层从 Agent 进程里抽出来变成独立服务------Agent 只要会说 MCP 就能接任何工具,工具只要会说 MCP 就能服务任何 Agent。
它在整个 Agent 技术栈里的位置(三层格局):

- 协议层 :模型如何调工具------OpenAI tools 协议(模型返回
tool_calls、Agent 本地执行、结果以role=tool回写的循环),事实标准 - 接入层:工具从哪来------MCP,标准化的工具接入协议,本文主角
- 应用层:能力打包复用------SKILL 技能包(提示词 + 工具清单的打包)
二、MCP 协议原理:Agent 与服务间的 JSON-RPC
2.1 三个角色与传输方式

- Host:Agent 程序本身,面向用户、负责编排与决策。就是常说的"Agent"这一角色
- MCP Client :藏在 Host 内部的连接器(SDK 提供),一个 Client 对接一个 Server,Host 可并行连多个。代码里就是建立连接时创建的那些
Client实例 - MCP Server :独立进程或常驻服务,工具的宿主 。注意:具体的工具(
get_weather)不是第三个角色,它是 Server 暴露出来的能力,一个 Server 可以暴露多个工具。社区有海量现成的(GitHub、Postgres、filesystem......)
本文用的是 Streamable HTTP 传输:每条报文一次独立的 HTTP 请求,响应以 SSE(event: message / data: {...})包裹,服务独立常驻(7890 端口),任何客户端都能接入。这也是远程共享服务(飞书、Pixso 这类)的标准形态。

2.2 通信的本质:一次请求一条 JSON-RPC
不看 SDK 封装,直接用 curl 把 JSON-RPC 报文一条一条 POST 到 7890 端口,服务端把响应包在 SSE 里回传------这就是 MCP 通信的全部真相。以一次完整的接入为例,发出去的报文:
bash
► {"method":"initialize", "params":{protocolVersion, clientInfo}, "id":0}
► {"method":"notifications/initialized"} ← 通知:没有 id,不需要回复
► {"method":"tools/list", "id":1}
► {"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"北京"}},"id":2}
swift
◄ {"id":0,"result":{"capabilities":{"tools":{"listChanged":true}},"serverInfo":{...}}}
◄ {"id":1,"result":{"tools":[{"name":"get_weather","description":"...","inputSchema":{...}}]}}
◄ {"id":2,"result":{"content":[{"type":"text","text":"{\"city\":\"北京\",\"temp\":\"24°C\",...}"}]}}
每对请求/响应靠 id 配对 (id:1 的请求配 id:1 的响应),报文外层是 JSON-RPC 2.0 格式。四个方法各管一件事:
| 方法 | 作用 |
|---|---|
initialize |
握手:互相确认协议版本和能力(capabilities.tools.listChanged 就是握手时声明的) |
notifications/initialized |
握手完成确认,此后服务端才处理其他请求 |
tools/list |
发现工具:拿到 name + description + inputSchema 声明 |
tools/call |
执行工具:带参数发出去,带 result.content 收回来 |

2.3 Agent 是翻译官:两层协议
一次提问的完整链路里有两套协议,Agent 站在中间当翻译:
| 层 | 协议 | 报文形态 | 谁和谁 |
|---|---|---|---|
| 模型 ↔ Agent | OpenAI tools 协议 | tools 参数 / tool_calls / role=tool 回写 |
HTTP + SSE |
| Agent ↔ MCP Server | MCP 协议 | initialize / tools/list / tools/call |
Streamable HTTP |
关键认知:Agent 从头到尾没有执行过任何工具逻辑。它只做三件翻译工作:
- 把
tools/list拿到的声明翻译成 OpenAItools参数(inputSchema直接当parameters用) - 把模型返回的
tool_calls翻译成tools/call请求转发出去 - 把 MCP 响应的
result.content[].text拍平成字符串,以role=tool回写进 messages
所以"MCP 标准化"标准的是什么就很具体了:不管右边接的是天气、ZONE 还是 GitHub,左边模型看到和产出的报文格式永远不变------Agent 的工具循环一行不用改就能接新工具。
三、实现细节:Server 与 Client 两端改造
3.1 写一个 MCP Server:7890 端口的常驻服务
用 @modelcontextprotocol/server 的高层 API,一个工具 = 一次 registerTool;2.0 SDK 再用 createMcpHandler + toNodeHandler 把它挂到原生 http 服务上,就是一个独立的常驻服务(Qoder mcp.json 里飞书/Pixso 就是这种形态):
javascript
import http from "http";
import dotenv from "dotenv";
import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { z } from "zod";
// 工厂函数:每个会话创建新 McpServer 并注册全部工具;
// registerTool 三件套:工具名 + 声明(description/inputSchema)+ 执行函数
function createServer() {
const server = new McpServer({ name: "remote-mcp", version: "1.0.0" });
server.registerTool(
"get_weather",
{
description: "查询指定城市的实时天气,包括温度、体感温度、湿度、风力",
inputSchema: z.object({ city: z.string().describe("城市名称,如:北京、上海") }),
},
async ({ city }) => {
// 真正干活的地方:fetch wttr.in
return { content: [{ type: "text", text: JSON.stringify({ city, temp: "24°C" }) }] };
}
);
// ......其他工具同理,继续 registerTool(如 get_zone_unfinished_tasks)
return server;
}
const handler = createMcpHandler(createServer);
http.createServer(toNodeHandler(handler)).listen(7890);
注意返回结构:MCP 规范规定结果必须包在 content 数组里([{ type: "text", text }]),失败时加 isError: true。
服务独立后,裸 curl 就能调协议,证明它完全脱离 Agent 存在:
bash
curl -X POST http://localhost:7890/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"青岛"}}}'
# → event: message
# data: {"result":{"content":[{"text":"{\"city\":\"青岛\",\"temp\":\"25°C\",...}"}]},"id":2}
注意响应是 event: message / data: {...} 的 SSE 包裹------Streamable HTTP 规范的标准形态。
3.2 Agent 侧接入:启动时拉声明、调用时做路由
Agent 接入层只做两件事:启动时拉声明、调用时做路由:
javascript
const MCP_SERVERS = [{ name: "remote-mcp", url: "http://localhost:7890/mcp" }];
async function connectMcpServers() {
for (const { name, url } of MCP_SERVERS) {
const client = new Client({ name: "agent-client", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL(url));
await client.connect(transport); // 内部完成 initialize 握手
const { tools } = await client.listTools(); // tools/list
for (const t of tools) {
// 声明翻译成 OpenAI tools 格式,inputSchema 直接当 parameters
MCP_TOOLS.push({ type: "function",
function: { name: t.name, description: t.description, parameters: t.inputSchema } });
MCP_TOOL_MAP.set(t.name, client); // 按工具名路由到对应 Client
}
}
}
执行分发时,先查 MCP 路由表,命中就发 tools/call,否则走本地工具表:
javascript
const mcpClient = MCP_TOOL_MAP.get(call.function.name);
if (mcpClient) {
const result = await mcpClient.callTool({ name: call.function.name, arguments: args });
return result.content.map((c) => c.text ?? "").join("\n"); // 拍平成字符串回写
}
中间的 ReAct 循环一行没改 ------模型看到的 tools 清单是本地 + MCP 合并后的,它分不清差别。
3.3 凭证放 MCP 侧:token 对模型无感知
ZONE 接口的 x-auth-token 只在 Server 端从环境变量文件读取。工具声明和模型上下文里完全没有 token------这是自建 Server 相对远程公共服务的优势:远程服务没法替你藏凭证,只能像飞书那样把 token 写进客户端配置的 headers 里代发。
3.4 执行审批层:人是敏感操作的最后一道闸
模型有权"决定"调工具,但是否真的"执行"应该由人可控------这就是 Qoder 等产品自带的 autoApprove 机制。它不属于 MCP 协议本身,而是 Host 加在"收到 tool_calls"和"发出 tools/call"之间的工程层,手写 Agent 时值得实现:
- 配置形态 :每个 MCP Server 配一个
autoApprove白名单,名单内工具直接执行,名单外每次调用都要用户在页面放行(写"*"全免)------与 Qodermcp.json的同名字段语义一致 - 挂起等待:模型返回白名单外的工具调用时,工具循环整体挂起,Host 把审批事件(requestId + 工具名 + 参数原样)推给页面渲染确认卡片,让人看清模型到底要干什么再决策
- 决策回调 :用户点"允许/拒绝"走决策接口,服务端按 requestId 放行挂起的调用------允许才真正发
tools/call,拒绝则不执行 - 拒绝的回写 :拒绝不是中断对话,而是把"用户拒绝执行该工具"作为工具结果以
role=tool回写,让模型基于这个事实组织回答------与执行失败走同一条路径,协议层零特殊处理 - 超时兜底:超时无人决策按拒绝处理(fail-safe),避免页面关了请求被永久挂住
交互语义与 Qoder 的弹窗完全同构:模型决定"想做什么",人决定"能不能做"。
四、MCP 的更新机制:三层更新
"MCP 怎么更新、配置会不会动态加载"要分三层回答:
| 层 | 内容 | 更新方式 |
|---|---|---|
| 配置层 | 连哪些 Server(MCP_SERVERS 数组 / mcp.json) |
启动时读一次,不动态加载,改完重启 |
| 进程层 | Server 代码、凭证配置 | 服务端单独发版重启,客户端无感知 |
| 工具层 | 运行中增删改工具 | 协议原生支持:Server 发 notifications/tools/list_changed,Client 收到后重新 tools/list |
第三层是协议级的热更新,握手时 capabilities.tools.listChanged: true 就是它在声明能力。但它的边界很清楚:只更新工具清单,更新不了进程内的代码------适合"连上数据库才暴露查询工具"这类运行时动态上下架场景。
一句话:MCP 的即插即用发生在启动时刻;运行时只有工具清单是协议级可热更的,配置和代码更新都靠重启。
五、这是标准规范吗:协议源头
是。你抓到的每条报文都出自官方规范,不是某家私有格式:
| 源头 | 地址 |
|---|---|
| 官方规范 | modelcontextprotocol.io/specificati... |
| 权威 Schema | github.com/modelcontex... 的 schema/2026-07-28/schema.ts(规范明文以它为准) |
| 报文格式基础 | JSON-RPC 2.0(jsonrpc:"2.0"、id 配对、通知无 id 全来自它) |
| 参考实现 | modelcontextprotocol/typescript-sdk、python-sdk 等 |
规范按日期发布修订版:2024-11-05 → 2025-03-26 → 2025-06-18 → 2025-11-25 → 2026-07-28。你日志里握手发的 "protocolVersion":"2025-11-25" 就是某个修订版的版本号,握手第一步干的事就是协商版本。想核对某条报文是不是标准的,直接去 schema.ts 搜 InitializeRequest、ListToolsRequest、CallToolResult------SDK 里那一堆 *Schema 就是从这份 schema 生成的,规范、Schema、SDK 三者同源,这也是跨厂商即插即用的根基。
延伸阅读
- MCP 不只是工具:协议还标准化了 Resources(给模型看的数据)、Prompts(预置提示词模板)、Sampling(Server 反过来请模型生成)。Agent 结合最核心的是 tools,其余按需展开
- 与 Qoder mcp.json 的对照 :Agent 内的 Server 连接清单(3.2 代码里的
MCP_SERVERS数组)就是手写的 mcp.json,语义一致;自建远程服务与 Qoder 连的远程服务同为 HTTP 形态;Qoder 的autoApprove客户端审批层,本文在 3.4 节实现了同款 - tools/list_changed 实战:给 Server 加一个"运行中动态注册新工具"的演示,日志里就能抓到那条通知,亲眼验证第四节的工具层热更新
- 模型的时间盲区:MCP 解决了工具接入,但"今天是几号"这类模型自身缺失的事实,标准补法是注入 system prompt,与 MCP 无关却常一起踩