SDK 好用,但它藏了三件事:握手到底交换了什么、能力协商决定了哪些方法可用、工具失败为什么有时是 JSON-RPC 错误、有时只是 content 加 isError: true。这三个问题在线上排查时全要命——宿主只说"工具失败",剩下得自己看报文。
这篇不用 SDK,用不到 80 行 Python 从 stdin 读行、分发、写 stdout,把骨架搭出来。协议按 2024-11-05 版的 JSON-RPC 2.0 定义;Streamable HTTP 只在传输层有差异,报文形状不变。
1. 报文只有三种形状
| 形状 | 有 id |
期望响应 | 例子 |
|---|---|---|---|
| Request | 有 | 是 | initialize、tools/list、tools/call |
| Response | 有(回显) | 否 | 带 result 或 error |
| Notification | 无 | 否 | notifications/initialized、notifications/cancelled |
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"disk_usage","arguments":{"path":"/"}}}
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"/ used=61.2%"}],"isError":false}}
jsonrpc 必须存在,id 必须原样回显,可以是字符串或数字,但同一条连接内不许重复。重复的后果是客户端把两个响应配错,症状表现为"调用 A 返回了 B 的结果"。
2. initialize:版本与能力的双向报价
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2024-11-05",
"capabilities":{"sampling":{},"roots":{"listChanged":true}},
"clientInfo":{"name":"host","version":"1.4.0"}}}
服务端的响应把自己那一半报价回来:
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2024-11-05",
"capabilities":{"tools":{"listChanged":false},"resources":{"subscribe":false},"logging":{}},
"serverInfo":{"name":"raw-server","version":"0.1"},
"instructions":"查询类工具优先使用 disk_usage,不要在参数里传相对路径。"}}
| 字段 | 方向 | 作用 |
|---|---|---|
protocolVersion |
双向 | 客户端提最想用的,服务端回自己支持的 |
capabilities(客户端) |
C→S | 告诉服务端"我能替你采样、能提供 roots" |
capabilities(服务端) |
S→C | 告诉客户端"我提供哪些原语" |
instructions |
S→C | 给模型看的使用说明,宿主通常拼进系统提示 |
协商规则:以服务端返回的版本为准。服务端不支持客户端提的版本时,回自己支持的那个,由客户端决定继续还是断开。客户端硬编码 == "2024-11-05" 是常见 bug,升版时全线报错而双方其实都能跑。
initialize 之后必须由客户端发一条 notifications/initialized,服务端收到后才应接受业务方法。少这一条,严格实现会拒绝对 tools/list 作答。
3. capabilities 声明决定方法能不能调
| 声明 | 解锁的方法 | 备注 |
|---|---|---|
tools |
tools/list、tools/call |
listChanged:true 时还要推 list_changed 通知 |
resources |
resources/list、resources/read |
加 subscribe:true 才有 subscribe/unsubscribe |
prompts |
prompts/list、prompts/get |
模板入参用 arguments |
logging |
logging/setLevel + notifications/message |
服务端日志经协议回传 |
客户端 sampling |
sampling/createMessage |
服务端反向请求宿主跑模型 |
客户端 roots |
roots/list |
服务端问"你能访问哪些目录" |
没声明的能力被调用,标准回应是 -32601 Method not found。它的价值是收敛暴露面:声明了 resources 却不实现 read,等于留一个必然失败的按钮。
4. tools/list:inputSchema 就是给模型看的表单
TOOLS = [{
"name": "disk_usage",
"description": "返回指定挂载点的磁盘使用率与剩余空间。只接受已挂载的绝对路径。",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "挂载点绝对路径,例如 / 或 /data"},
"unit": {"type": "string", "enum": ["percent", "mb"],
"description": "percent 返回百分比,mb 返回剩余兆字节"}
},
"required": ["path"],
"additionalProperties": False
}
}]
| 写法 | 效果 |
|---|---|
每个属性都带 description |
填参准确率明显高于只有类型 |
required 只列真正必填的 |
多列一项,模型就开始编默认值 |
enum 与 description 双写 |
部分宿主不渲染下拉框,模型只读描述 |
additionalProperties: false |
阻断自造字段导致的 -32602 |
避免用 oneOf/anyOf 做主分支 |
多数宿主渲染不出,模型只能猜 |
5. tools/call:两种失败必须分开表达
| 失败 | 报文表现 | 归因 | 客户端该做什么 |
|---|---|---|---|
| 协议错误 | error:{code:-32602,...} |
参数不符合 schema | 修正参数后重试 |
| 工具执行失败 | result.content + isError:true |
业务失败:文件不存在、权限不足 | 把文本交给模型,由模型决定下一步 |
| 未捕获异常 | error:{code:-32603,...} |
代码 bug | 不该出现在生产,出现即修 |
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"FileNotFoundError: /data 未挂载"}],"isError":true}}
区分的意义很实际:isError:true 是模型可见的信息,模型读到"未挂载"会换个路径再试;而 JSON-RPC 的 error 是宿主可见的,多数宿主只当技术故障弹红框。业务失败塞进 error,等于剥夺 Agent 的自我纠错。
6. 不用 SDK 的最小实现
import json, sys, shutil
def disk_usage(path="/", unit="percent"):
st = shutil.disk_usage(path)
return f"{st.used / st.total:.1%}" if unit == "percent" else f"{st.free >> 20}MB free"
HANDLERS = {"disk_usage": disk_usage}
def dispatch(method, params):
if method == "initialize":
return {"protocolVersion": "2024-11-05",
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "raw-server", "version": "0.1"}}
if method == "ping":
return {}
if method == "tools/list":
return {"tools": TOOLS}
if method == "tools/call":
name = params["name"]
if name not in HANDLERS:
raise ValueError(f"未知工具:{name}")
try:
text = HANDLERS[name](**params.get("arguments", {}))
return {"content": [{"type": "text", "text": text}], "isError": False}
except TypeError as e: # 参数错 -> 协议错误
raise ValueError(str(e))
except Exception as e: # 业务失败 -> isError
return {"content": [{"type": "text", "text": f"{type(e).__name__}: {e}"}],
"isError": True}
raise LookupError(method)
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
msg = json.loads(line)
if "id" not in msg: # 通知,不回
continue
mid = msg["id"]
try:
out = {"jsonrpc": "2.0", "id": mid, "result": dispatch(msg["method"], msg.get("params", {}))}
except LookupError:
out = {"jsonrpc": "2.0", "id": mid,
"error": {"code": -32601, "message": "Method not found"}}
except ValueError as e:
out = {"jsonrpc": "2.0", "id": mid,
"error": {"code": -32602, "message": str(e)}}
except Exception:
out = {"jsonrpc": "2.0", "id": mid,
"error": {"code": -32603, "message": "Internal error"}}
sys.stdout.write(json.dumps(out, ensure_ascii=False) + "\n")
sys.stdout.flush()
三个不能省的细节:flush() 必须调,否则行卡在缓冲区,表现为"调用无响应";ensure_ascii=False 才输出中文原文而非 \u4e2d;json.dumps 默认无内嵌换行,正好满足一行一条;日志只能写 stderr。
7. 会话状态放在哪
| 状态 | 存放位置 | 理由 |
|---|---|---|
是否已 initialized |
进程内存一个 bool | 置 True 后才接业务方法 |
协商后的 protocolVersion |
内存 | 后续日志与兼容分支要用 |
| 工具调用中间态 | 不放 | 进程重启即丢,靠它做流程会碎 |
| 大结果 | 外部存储 + resource:// URI |
客户端用 resources/read 分次取 |
stdio 的会话天然等于"一个子进程的一次生命周期",内存状态够用;换成 HTTP 后这套假设失效,会话要落到 Mcp-Session-Id 上。
8. 错误码分层
| code | 名称 | 什么时候用 |
|---|---|---|
-32700 |
Parse error | 收到的不是合法 JSON(通常是 stdout 被污染) |
-32600 |
Invalid Request | 不是合法 JSON-RPC 对象,缺 method |
-32601 |
Method not found | 未声明能力却调用该方法 |
-32602 |
Invalid params | 参数不符合 inputSchema |
-32603 |
Internal error | 服务端未捕获异常 |
-32002 |
Resource not found | resources/read 的 URI 不存在 |
-32000~-32099 |
实现自定义 | 需要细分业务错误时才用 |
线上 -32700 几乎只有一个来源:代码往 stdout 写了非协议内容。见到它,先搜 print(。
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
宿主报 -32700 |
stdout 混入调试输出 | 全部输出改 stderr |
tools/list 返回空 |
注册表在 initialize 前未构建 |
注册放模块顶层 |
调用总报 -32602 |
required 与函数签名不一致 |
拿 schema 反查参数名 |
| 模型对失败无反应 | 业务失败用了 JSON-RPC error | 改 isError:true + 文本 |
| 响应顺序错乱 | 并发写 stdout 无锁 | 单线程写,一行一写 |
10. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 学协议、查报文 | 手写实现 | 每层都能打断点 |
| 生产服务 | 用 SDK | 取消、进度、分页等边界手写极易漏 |
需要 sampling |
必须实现客户端侧 | 反向请求靠客户端能力,服务端单方做不到 |
| 需要进度 | 实现 notifications/progress |
长任务不给进度,宿主先超时 |
| 多租户 | 别用内存会话 | 会话必须能跨进程恢复 |
11. 自测
- Request 与 Notification 的唯一结构差异是什么,各自要不要回包?
- 客户端提的
protocolVersion服务端不支持时,正确做法是什么? - 为什么"文件不存在"应该走
isError:true而不是-32602? - 没声明
resources却收到resources/read,应返回哪个错误码? - stdout 写日志为什么会导致
-32700,而不是别的错误? initialize之后少发notifications/initialized,会出现什么症状?