手写 MCP 接入:搞懂工具即插即用

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 从头到尾没有执行过任何工具逻辑。它只做三件翻译工作:

  1. 把 tools/list 拿到的声明翻译成 OpenAI tools 参数(inputSchema 直接当 parameters 用)
  2. 把模型返回的 tool_calls 翻译成 tools/call 请求转发出去
  3. 把 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 白名单,名单内工具直接执行,名单外每次调用都要用户在页面放行(写 "*" 全免)------与 Qoder mcp.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 无关却常一起踩
相关推荐
yumengxt1 小时前
百度 AI 搜索核心能力与实测表现深度评测
人工智能·百度
Yyq130208696821 小时前
AX8860,对标NVIDIA DGX Spark,端侧大算力芯片
人工智能
xunberg1 小时前
【Agent 生产化手记-02】Agent 怎么向人提问
人工智能·算法·架构
TK泰妞1 小时前
TikTok 商品视频素材库怎么建?从文件命名到复用管理的完整方法
人工智能·音视频
土木AI提效大古1 小时前
Vercel + Spaceship 全自动部署:8 分钟上线一个站,附 3 个坑的排查记录
人工智能
小蒋观天下1 小时前
网络抖动、乱序、丢包:UDP/RTSP/TCP对AI抓拍、轨迹跟踪的不同影响
大数据·人工智能·深度学习
移动开发者实验室1 小时前
被 App Store 截图折腾了几次后,我开源了一个 AI 截图生成工具
人工智能·开源
一只爱撸猫的程序猿1 小时前
全球出货第二,却不在 AI 的默认推荐里:一份悖论解剖报告
人工智能·python·产品
高洁011 小时前
大模型检索增强生成(RAG)的技术原理与架构设计
人工智能·深度学习·机器学习·django·迁移学习