MCP 协议实践 —— 让 Skill 体系从“私有胶水“走向“标准协议“
一个 Agent 系统的长期维护成本不在模型,不在向量库——在工具怎么注册、能力怎么对外暴露,这些"连接层"代码不是最多的,却是最容易腐烂的。
本文以前 11 篇中实际运行的 Skill 体系为起点,讨论如何用 MCP(Model Context Protocol)标准化工具暴露,并解决由此引发的深层架构矛盾。
先看现状:工具注册的胶水
打开核心文件,胶水层一览无余:
胶水:工具注册(SkillLoader + ToolService)
新增一个工具要走三条私有路径:写 SKILL.md(私有格式定义 frontmatter)→ 在工具注册表手工加一行映射 → 在 Agent 模块注册 LangChain tool。dispatch 本身很干净——字典 O(1) 查表——但注册表仍然是手工维护的,新工具不加映射就不可达。
这里的关键词不是"能不能用",而是"能维护多久"。胶水代码没有技术壁垒,只有认知壁垒——时间长了,大家都不记得了。
MCP:让工具暴露从"私有格式"变成"标准协议"
MCP 的核心模型极其简单——客户端与服务端之间只走 JSON-RPC:
客户端(AI 应用 / MCP Inspector / n8n)
↓ JSON-RPC over stdio / Streamable HTTP
服务端(MCP Server)
├── tools/list → 我能做什么(自动从 SkillRegistry 生成)
├── tools/call → 帮我做这件事(映射到 ToolService.dispatch)
├── resources/read → 我可以看什么资料
└── prompts/get → 调用 prompt 模板
Shop-Agent 在 MCP 生态中有两个方向:
作为 Client 消费外部业务系统(订单、物流)的 MCP 工具——外部系统用什么语言写的不用关心;
作为 Server 将自己的 Skill 体系对外暴露——Claude Desktop、n8n 等通过标准协议直接调用 query-order、check-shipping。
核心原则:MCP 对外,不对内——ReActAgent ↔ ToolService 内部走直接函数调用,同进程函数调用不需要 JSON-RPC 序列化开销。
MCP Server 核心架构
自动注册,不手写映射表。 初始化时遍历 SkillRegistry,把每个 skill 自动注册为 MCP tool——新增工具只需要写 SKILL.md 并实现对应的工具方法,MCP 层自动感知。工具名、描述、参数全部来自 SkillRegistry,与 tools/list 同源。
类型注解驱动 Schema,不手写 JSON。 通过动态生成带类型注解的 Python 函数(如 async def query_order(order_id: str|None = None, phone: str|None = None) -> str),FastMCP 从类型注解自动推断 inputSchema——tools/list 返回的参数描述始终与 ToolService 的定义保持同步。
挂载到主应用,不另开端口。 MCP Server 通过 app.mount("/mcp", ...) 挂到 FastAPI,与业务 API 共用 8000 端口。传输层使用 Streamable HTTP(SSE 已在 MCP SDK 1.28 中被弃用)。
⚠ 一个坑:FastAPI 的
app.mount()不会自动执行子应用的 lifespan,需要在主 lifespan 中手动初始化 MCP 的SessionManager。另外,FastAPI 0.115.x 与 Starlette 1.3.x 的on_startup参数不兼容,导致路由匹配失效——需升级到 FastAPI ≥ 0.130。
已注册的 5 个工具(从 skills/ 目录自动加载):
| 工具名 | 参数 | 描述 |
|---|---|---|
query-order |
order_id, phone |
查询订单状态/列表 |
check-shipping |
tracking_number, order_id |
查询物流轨迹 |
request-return |
order_id, reason |
申请退货退款 |
check-balance |
(无参数) | 查询余额/积分 |
coupon-inquiry |
coupon_type |
查询优惠券 |
MCP vs HTTP
“这和 HTTP API 有什么区别?”——最常被问到的问题。
| 维度 | HTTP/REST API | MCP |
|---|---|---|
| 发现机制 | 需要开发者知道 OpenAPI 端点地址,人驱动、主动拉取 | tools/list 协议内置,客户端连接即获取,机器驱动 |
| Schema 自描述 | OpenAPI 与调用协议分离——schema 更新不保证调用端同步感知 | inputSchema 与 tools/call 在同一协议通道内——声明即生效 |
| 调用协议 | 每家自定义(URL、方法、错误格式各不相同) | 统一 tools/call(JSON-RPC),一种方式覆盖所有工具 |
| LLM 友好度 | 需要开发者把 Swagger 翻译成 function calling 格式 | inputSchema 本身就是 JSON Schema,直接喂给 LLM |
| 适用场景 | 前端调用、传统后端集成 | Agent-to-Tool 标准化协议 |
一句话:HTTP 给人用,MCP 给 Agent 用。两者不互斥——同一服务可同时提供 HTTP 端点和 MCP Server。
鉴权与暴露策略
MCP 协议尚无强制统一鉴权——stdio 靠 OS 进程隔离,Streamable HTTP 在 Header 透传 Bearer Token,与第九篇的认证体系一脉相承。实际关键是参数级鉴权:同一个 tools/call,不同 token 查询范围不同(ops-token 仅查物流状态,不能看个人信息)。
不是所有工具都对外开放。以 Shop-Agent 为例:query-order、check-shipping ——只读、无副作用;request-return、check-balance 走 HITL 安全链路。2-3 个精心挑选的工具足以覆盖 90% 的集成场景。
MCP Client:消费外部服务
上面讨论的是"把门打开让别人进来"——Shop-Agent 作为 Server 暴露工具。反过来还有"通过标准门出去调别人"——Shop-Agent 消费外部业务系统。
现状是工具调用层用硬编码的 URL 映射表把 action 翻译成 HTTP 端点,参数名也需要手动转换。新增一个外部工具就要维护两张表——又是胶水。
MCP Client 的做法:让外部业务系统也暴露 MCP Server,Shop-Agent 连接后通过 tools/list 自动发现可用工具及其 inputSchema,不再需要手写映射表。调用统一走 tools/call,不管远端是 Java、Go 还是 Python,协议一致。
MCP_CLIENT_SERVERS 配置一个 JSON 数组即可——列出各外部 Server 的名称、地址和可选鉴权头,启动时自动连接并拉取工具清单。
同一工具的调用优先级:MCP Client 优先,未命中再回退到 HTTP 映射表。MCP Server 不可用时业务照常运行,只升级了集成方式。
Server 和 Client 各司其职、互不冲突——共享 tools/list + tools/call 同一套协议语汇。
动态 Schema vs 静态正则
MCP Server 包装完成后,深层矛盾浮现。第二篇的 LocalParamExtractor 用正则提取参数——零 LLM 调用、毫秒级——但它把字段名硬编码在 _EXTRACTORS 字典里:"query-order" 对应提取 order_id、"check-shipping" 对应提取 tracking_number。MCP 引入后,tools/list 返回的 inputSchema 是动态的——订单系统把 order_id 改成 order_number,MCP Server 重启后 Agent 立刻看到新 schema,但正则还在提取 order_id。协议层动态自描述了,参数提取层还是静态硬编码的。
解法不是放弃正则,而是让正则的结构层从 MCP schema 驱动——同时用一层 alias 消除同语义字段的重复。核心设计是三层解耦:
| 层 | 数据来源 | 变更时谁改 |
|---|---|---|
| 结构层(有哪些字段、叫什么名) | MCP inputSchema |
MCP Server 自动同步,Extractor 零改动 |
| 语义层(用什么正则匹配值) | 本地 _PATTERNS |
只在出现新语义类型时加一个正则 |
| alias 层(MCP 字段名映射到哪种语义) | 本地 _FIELD_ALIASES |
字段改名时加一行 alias,正则不重复 |
alias 层的映射很简单:
"order_id" → "order" # 订单号语义,匹配 GD\d+|订单号[::]\s*(\S+)
"order_num" → "order" # 换了名,指向同一语义,复用同一个正则
"tracking_number" → "tracking" # 物流号语义
"phone" → "phone" # 手机号语义,匹配 1[3-9]\d{9}
当 order_id 改为 order_number 时,Extractor 不改一行代码——它遍历 mcp_schema["properties"] 的 key,通过 alias 找到语义类型,再拿对应的正则去匹配。正则不再硬编码"有哪些字段",它只回答一个问题:“给定语义类型,我该怎么从用户消息里找到它的值?” 如果 alias 层没有匹配,退化到 LLM 提取(P2 兜底路径)。当然一般来说确定好的接口版本,参数不会出现不可预期的变化,但是这样设计可以对参数变化保持动态适应性。
核心逻辑不动——dispatch 逻辑原封不动映射到 tools/call。MCP Server 包装仅仅在外面加了一层标准协议适配器。
MCP 不能提升意图识别的准确率,不能降低 RAG 延迟。它解决的问题是另一个层级的:当订单系统改了一个字段名,你的参数提取器是自动感知,还是运行时静默失败?
好架构是让系统自己保持一致性。
更多推荐




所有评论(0)