2202 字
约 7 分钟
17
S21-X05 · 从零实现 MCP:握手、能力协商与工具调用

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. 自测

  1. Request 与 Notification 的唯一结构差异是什么,各自要不要回包?
  2. 客户端提的 protocolVersion 服务端不支持时,正确做法是什么?
  3. 为什么"文件不存在"应该走 isError:true 而不是 -32602?
  4. 没声明 resources 却收到 resources/read,应返回哪个错误码?
  5. stdout 写日志为什么会导致 -32700,而不是别的错误?
  6. initialize 之后少发 notifications/initialized,会出现什么症状?
S21-X05 · 从零实现 MCP:握手、能力协商与工具调用
https://www.clxhxhhr.top/posts/5688/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。