把"调一个函数"变成"暴露一个 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. 自测
- 一个"按订单号查详情"的能力,你会做成 tool 还是 resource,依据是什么?
stdio传输下把日志打到 stdout 会发生什么,为什么?initialize之后漏发notifications/initialized,客户端会看到什么现象?- 工具描述里必须写清的三件事是什么?
- 协议级错误与
isError: true混用,会让客户端做出什么错误决策? - 什么条件下引入 MCP 的收益接近零?