2749 字
约 9 分钟
19
S21-X13 · MCP 协议:工具、资源与提示的暴露方式

把"调一个函数"变成"暴露一个 MCP 服务器",动机通常是"想给模型接个数据库"。真正的难点不是连接,而是接口稳定性:客户端有几种(IDE、桌面应用、agent 框架、自建前端),工具就被复制几遍描述、schema 与错误处理。MCP 把这层收口成一个协议,代价是按协议思考,而不是按 SDK 思考。


1. 它解决的是"客户端 × 能力"的笛卡尔积

没有 MCP 有 MCP
每个客户端各写一套工具适配 服务器写一次,客户端通用
工具描述散落在各家 prompt 里 描述随 tools/list 下发
权限模型各客户端自定义 能力协商 + 客户端白名单
换框架等于重写工具层 换框架只换编排

定位很窄:模型 ↔ 外部系统的上下文与动作接口,不是 RPC 框架,也不负责编排。直觉检验:只有一个客户端、一个工具时收益接近零,收益随"客户端数 × 工具数"增长。


2. 三类能力的分工

协议里 server 暴露三样东西,混用是最常见的错误来源。

能力 用途 有无副作用 谁决定何时用
tools 可执行动作,产生副作用 有 模型决定,客户端可拦
resources 只读上下文数据(文件、记录、文档) 无 客户端/应用决定挂载
prompts 可复用模板,带参数 无 用户显式选择

判断只问一句:执行后世界有没有变化? 有 → tool;拿数据看 → resource;组装文字 → prompt。把"查订单"写成 resource 是常见偷懒:它要参数、要按 id 定位,而 resource 的语义是可枚举、可挂载的上下文,不是任意函数。


3. 传输形态与部署形态

传输 形态 适合 代价
stdio 客户端拉起子进程,走标准输入输出 本地工具、IDE 插件、单机 CLI 与客户端同生命周期,难做多租户
HTTP + SSE(或可流式 HTTP) 远程服务,独立部署 团队共享、跨机器、需鉴权 要处理会话、超时、网关

stdio 的部署形态就是"客户端开则起进程,关则死":状态放内存没关系,重启就没了;日志必须写 stderr,因为 stdout 是协议通道。

{
  "mcpServers": {
    "order": {
      "command": "python",
      "args": ["-m", "order_mcp"],
      "env": { "ORDER_DB_URL": "postgres://...", "LOG_LEVEL": "warn" }
    }
  }
}

远程形态要处理鉴权头、会话 id、SSE 重连,以及反代对长连接的缓冲。

# 远程服务器常见起法(具体参数按实现版本核对)
python -m order_mcp --transport http --host 0.0.0.0 --port 8931
# 前面挂 nginx 时必须关掉 response buffering,否则流式事件被攒住

4. 消息模型:JSON-RPC 的方法与通知

协议消息是 JSON-RPC 风格:有 id 的是请求,必须应答;没有 id 的是通知,不应答。

方向 方法 作用
client → server initialize 握手,交换协议版本与能力
client → server tools/list 拿工具清单与 schema
client → server tools/call 执行工具
client → server resources/list / resources/read 枚举与读取资源
client → server prompts/list / prompts/get 模板清单与渲染
server → client notifications/tools/list_changed 工具集变更
server → client notifications/resources/updated 资源内容变了

握手协商决定后面能用什么:客户端声明 roots、sampling,服务器声明 tools、resources。没协商过的能力别调。

{"jsonrpc":"2.0","id":1,"method":"initialize",
 "params":{"protocolVersion":"2025-06-18",
           "capabilities":{"roots":{"listChanged":true}},
           "clientInfo":{"name":"my-agent","version":"0.1.0"}}}

{"jsonrpc":"2.0","id":1,"result":
 {"protocolVersion":"2025-06-18",
  "capabilities":{"tools":{"listChanged":true}},
  "serverInfo":{"name":"order","version":"0.3.0"}}}

initialize 之后客户端必须发 notifications/initialized;漏掉这一步的典型现象是后续请求一直没回。


5. 工具定义:描述质量决定调用准确率

tools/list 返回的每个工具就三个关键字段,name、description、inputSchema。

{"name":"create_refund",
 "description":"为已支付订单创建退款单。仅在用户明确要求退款且订单状态为 PAID 时调用;不会真实打款。",
 "inputSchema":{"type":"object",
   "properties":{
     "order_id":{"type":"string","description":"订单号,形如 ORD-开头"},
     "amount":{"type":"number","description":"退款金额,单位元,不得超过订单金额"},
     "reason":{"type":"string","enum":["damaged","wrong_item","late"]}},
   "required":["order_id","amount"]}}
字段 常见错误 后果
name 用 do_stuff、process 这类泛名 模型无法在相似工具间区分
description 只写"创建退款",不写前置条件与副作用 该调的没调,不该调的乱调
inputSchema 全部字段可选、没有枚举、没有格式说明 参数拼错,重试几次才蒙对

description 必须写清做什么、何时用、有无副作用;模型对"会不会真动钱"没有直觉。


6. 资源与工具的区别

维度 resource tool
语义 挂载的上下文 可执行动作
寻址 URI(file://、db://orders/8821) 名字 + 参数
副作用 无 可能有
{"jsonrpc":"2.0","id":7,"method":"resources/read",
 "params":{"uri":"db://orders/8821/summary"}}

资源最大的坑是把整个文件或整张表塞进去:它直接进上下文,一份大 JSON 就把后面的对话挤没了。资源只给索引/摘要,明细让 tool 按需取。


7. 服务器粒度:一类系统一个

粒度 优点 缺点
一系统一 server(订单、库存、工单各自) 权限可分开、可独立部署、故障隔离 客户端要配多个
一大而全 server 客户端只配一个 权限难细分,一个 bug 全线挂
按团队分 边界清晰 跨团队工具有重复

倾向"一系统一 server":权限边界跟着系统走,因为授权粒度通常落在 server 级。工具数量也要控制:清单太长选择准确率会降,典型量级上单个 server 十几个工具尚可,几十个就该拆。


8. 权限与确认

场景 建议 原因
只读查询 自动放行 + 限流 副作用可忽略
写操作、发消息、动钱 逐次人工确认 一次错误不可撤销
批量删除 白名单 + 二次确认 + 上限 破坏面大
出网请求(抓 URL) 域名白名单 防 SSRF 与数据外带

白名单是最后一道闸,别指望服务器自律:

AUTO_ALLOW = {"search_orders", "get_ticket"}       # 只读
NEED_CONFIRM = {"create_refund", "send_notice"}    # 有副作用

async def on_tool_call(name, args):
    if name in AUTO_ALLOW:
        return await session.call_tool(name, args)
    if name in NEED_CONFIRM and await ui.confirm(name, args):
        return await session.call_tool(name, args)
    raise PermissionError(f"blocked: {name}")

未登记一律拒绝,代价只是新增工具时补一行。


9. 错误怎么返回

协议级错误和工具执行错误是两回事,混在一起会让客户端误判。

类型 表现 客户端应该做什么
协议级错误 JSON-RPC error 对象(方法不存在、参数不合法、未初始化) 修正调用方式,不要重试
工具执行错误 result.content + isError: true 可把错误文本交给模型自行决策
传输错误 连接断、超时 重连或降级,可重试
{"jsonrpc":"2.0","id":9,"result":{
  "content":[{"type":"text","text":"订单 8821 状态为 SHIPPED,不可退款"}],
  "isError": true}}

把业务失败当协议错误抛,客户端会以为"调用方式错",于是不把错误文本交给模型,流程直接断;反过来把"方法不存在"包成 isError,模型会以为自己参数写错,反复重试。

取消也要实现:notifications/cancelled 让客户端中止长任务,服务器收到后应尽快停止并释放资源。


10. 与直接写函数调用的差别

维度 直接函数调用 MCP
复用范围 一个进程内 任意客户端
描述来源 代码写死 运行时下发,可热更
调试 打日志 协议层可录制回放
成本 零 进程/网络 + 协议实现

不值得的情况:单进程、单客户端、工具不超过五个,直接写函数 + 一份 schema 字典更省。


11. 排错:五个高频坑

现象 根因 处理
模型总选错工具,或干脆不调 描述含糊、多个工具语义重叠 每个描述写清"做什么/何时用/副作用";重叠的合并
上下文突然被撑爆 资源里塞了大文件/整表 资源只给索引与摘要,明细走 tool 分页取
客户端连上就没反应 server 把日志打到 stdout,污染协议流 日志全部走 stderr 或文件
长任务卡住,无法中止 没实现超时与 notifications/cancelled 服务端设默认超时,实现取消处理
明明有工具却报"方法不存在" 忘了发 notifications/initialized,或能力未协商 补握手第二步;按协商结果裁剪调用

实操检验:把清单给一个不懂业务的人看,他能否只靠描述判断该用哪个。


12. 边界:什么时候别引入 MCP

场景 更合适 理由
单客户端、少量工具 直接函数调用 多一层没有收益
需要严格顺序的编排 在 agent 框架里写流程 MCP 只管暴露,不管编排
高频低延迟内部调用 gRPC / 进程内调用 协议开销与进程模型不合适
需要复杂事务语义 服务端 API + 补偿 MCP 没有分布式事务
只是给模型喂文档 资源挂载或直接拼 prompt 不必绕成工具

MCP 是接口标准,不是能力来源:它让同一批能力被更多客户端用到,能力本身还得自己写对。


13. 自测

  1. 一个"按订单号查详情"的能力,你会做成 tool 还是 resource,依据是什么?
  2. stdio 传输下把日志打到 stdout 会发生什么,为什么?
  3. initialize 之后漏发 notifications/initialized,客户端会看到什么现象?
  4. 工具描述里必须写清的三件事是什么?
  5. 协议级错误与 isError: true 混用,会让客户端做出什么错误决策?
  6. 什么条件下引入 MCP 的收益接近零?
S21-X13 · MCP 协议:工具、资源与提示的暴露方式
https://www.clxhxhhr.top/posts/5907/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。