
文章目录
-
- 1. 先说结论:MCP 把握手取消了
- 2. 新协议的三条硬规则,先钉死
- 3. 用纯标准库写 Server
- 4. 图一:两种时代的消息序列
- 5. 图二:一个请求要过六道闸,三种出口
- 6. 图三:10 个场景,28 项断言的实测落点
- 7. 两个真实数据坑
- 8. 踩坑清单
- 10. 参考链接
1. 先说结论:MCP 把握手取消了
先说一件很多人还不知道的事:MCP 最新规范 2026-07-28 取消了握手。
在 2025-11-25 及更早的版本里,客户端必须先用 initialize 走一轮协商,再发一条 notifications/initialized,之后才能干正事。从 2026-07-28 开始,协议变成无状态:没有协商、没有会话,每条请求自己带上版本和身份信息。规范原文写得很直接:
There is no negotiation handshake. Every request carries its protocol version, and the server accepts or rejects each request independently.
代价是版本信息要在每条请求里重复一次,收益是 Server 不再需要为「会话状态」负责。我这篇要做的,是用纯标准库手写一个同时兼容两种时代的 Server,并在真实的 stdio 管线上把协议边界逐条测出来。
用一个具体场景把它落地:香港职场排期。四个工具——查公众假期、算区间工作日、查实时天气、查九天预报——都是排会议、排团建、排出差时要问的问题。
2. 新协议的三条硬规则,先钉死
动手前把 2026-07-28 的关键约束列清楚,这三条决定了代码怎么写:
| 每请求元数据 | _meta 里 io.modelcontextprotocol/protocolVersion 与 io.modelcontextprotocol/clientCapabilities 必填(clientInfo 可选) | 缺任一 → -32602 |
| 结果类型 | 结果必须带 resultType;"complete" 表示终态。缺失时客户端按规范视为 complete | 无(向后兼容靠这条) |
| 新增错误码 | -32020 HeaderMismatch / -32021 MissingRequiredClientCapability / -32022 UnsupportedProtocolVersion | 版本不在清单时必须回 -32022 并带上 supported |
第三条最容易被忽略:版本不匹配时不能只说「不支持」,必须把 supported 清单带回去——这是客户端选版本的唯一依据。另外 server/discover 是规范要求必须实现的方法,它同时也是 stdio 上判断对端时代的探针。
3. 用纯标准库写 Server
stdio 上的 MCP 就是「一行一条 JSON-RPC」,所以不需要任何 SDK、任何依赖:
import json
import subprocess
import sys
MODERN = "2026-07-28"
SUPPORTED = [MODERN, "2025-11-25", "2025-06-18"] # 后两个是握手时代
M = "io.modelcontextprotocol/"
K_VERSION, K_CAPS = M + "protocolVersion", M + "clientCapabilities"
class Session:
"""modern 请求无状态;legacy 需要一个本进程内的握手标记。"""
def __init__(self):
self.legacy_ready = False
def check_meta(params, sess):
"""四道校验闸的前两道:有没有 _meta、必填字段齐不齐。
返回 (era, error_payload)。"""
meta = (params or {}).get("_meta")
if not isinstance(meta, dict):
return "legacy", None # 没带 _meta,按旧语义处理
missing = [k for k in (K_VERSION, K_CAPS) if meta.get(k) is None]
if missing:
return "modern", {"code": –32602,
"message": "Invalid params: 缺少必填 _meta 字段",
"data": {"missing": missing,
"required": [K_VERSION, K_CAPS]}}
if meta[K_VERSION] not in SUPPORTED:
return "modern", {"code": –32022,
"message": "Unsupported protocol version",
"data": {"supported": SUPPORTED,
"requested": meta[K_VERSION]}}
return "modern", None
def call_tool(name, args):
"""工具执行错误一律转成 isError,不往外抛——它和协议错误不是一回事。"""
if name not in HANDLERS:
return None, {"code": –32602, "message": f"Unknown tool: {name}"}
try:
payload, text = HANDLERS[name](args)
except Exception as e:
return {"content": [{"type": "text", "text": str(e)}], "isError": True}, None
return {"content": [{"type": "text",
"text": text + "\\n\\n" + json.dumps(payload, ensure_ascii=False)}],
"structuredContent": payload, "isError": False}, None
def main():
sess = Session()
for line in sys.stdin: # stdio:一行一条消息
line = line.strip()
if not line:
continue
req = json.loads(line)
resp = dispatch(req, sess) # 分发逻辑见仓库完整文件
if resp is not None: # 通知不回,返回 None
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\\n")
sys.stdout.flush()
两点值得单独说:Session 只记一个布尔值——modern 请求不带状态,只有 legacy 需要「握过手没有」这一个标记,这是两种时代能在同一进程里共存的最小代价。call_tool 把工具异常统一收进 isError,绝不让它穿透成 JSON-RPC 错误,理由在第五节展开。
建议收藏备用:这个骨架换成任何领域工具都能直接用,只需要替换 HANDLERS 与 TOOLS_SPEC。
写好之后,把它挂进支持 MCP 的客户端只差一段配置。下面是配置模板(路径换成你自己的绝对路径):
{
"mcpServers": {
"hk-workplace": {
"command": "python3",
"args": ["/absolute/path/to/hk_workplace_mcp.py"]
}
}
}
一段提醒:客户端愿不愿意连,取决于它支持哪个协议版本。这也是我把 legacy 路径一起实现的原因——版本对不上时,有退路总比只有一个入口好。
4. 图一:两种时代的消息序列

左边是 modern 路径:客户端第一个请求就可以发 server/discover,拿到版本与能力之后直接调工具——三条消息。右边是 legacy 路径:initialize → InitializeResult → notifications/initialized → 才能真正发请求——五条消息。
差别不只是少了两条。旧路径里,能力和版本是「会话属性」,Server 必须记住这个连接握过手;新路径里它们是「请求属性」,Server 处理完就可以忘掉。这直接改变部署形态:无状态意味着多个实例可以随便轮转,不需要粘性会话。
5. 图二:一个请求要过六道闸,三种出口

请求进 Server 之后要依次过六道闸,而失败落在哪个出口是有严格分工的:
- JSON-RPC 错误(-32602 / -32022 / -32601):请求本身有问题——_meta 缺字段、版本不支持、方法不存在、工具名写错。这类错误客户端代码写错了,把原文交给模型也没用。
- result + isError: true:工具真的跑了,但业务失败——日期格式不对、年份超出数据源范围、上游接口挂了。这类错误模型自己就能改:把「year 必须是 2025-2027」告诉它,它下一轮就会传对。
- result + isError: false:正常路径。
这条分流线的判据可以照着抄,收藏备用——自写 Server 时先问一句「这个错该由谁修」,能省掉一整轮来回调试。规范对这两类的建议也正好相反:协议错误「MAY 提供给模型,但不太可能带来成功恢复」,工具执行错误「SHOULD 提供给模型,以便自我纠正」。把工具失败硬塞进 JSON-RPC 错误,等于让模型看着一条它无法修复的报错干瞪眼——这是自写 Server 最常见的一处设计错位。
6. 图三:10 个场景,28 项断言的实测落点

我没有用任何 MCP SDK,而是自己起了子进程、自己写一行 JSON、自己读一行 JSON,把协议边界逐条测出来。上图是 10 个场景在三种出口上的落点,每个失败都恰好有唯一归属。三个最值得看的结果:
| 缺 clientCapabilities | _meta 少一个必填键 | -32602,且 data.missing 点名是哪个键 |
| 版本写 2025-03-26 | 一个不在支持清单里的版本 | -32022,data.supported 带回三个可用版本 |
| 年份传 1999 | 参数越界 | result 里 isError: true,不是报错码 |
顺带一个真实计算结果,正好说明「为什么要做成工具而不是让模型自己算」:
- 2026-09-21 → 09-30:10 个自然日,8 个工作日。中秋節翌日 2026-09-26 落在周六,本来就不占工作日。
- 2026-09-28 → 10-02:5 个工作日里有 1 天被国庆日(10-01,星期四)占掉,实际只剩 4 天。
同一个「一周」,工作日数从 5 变成 4,只因为假期落在周几不同。这类判断交给模型去数日历,出错概率远高于交给一个查表函数。
7. 两个真实数据坑
工具封的是公开数据,两个坑都在取数这一层:
def http_json(url, timeout=20):
"""三个坑都在这里收口:
① 1823 公众假期 JSON 带 UTF-8 BOM,按 utf-8 解码会直接抛 JSONDecodeError
② 天文台 warnsum 在「无警告」时返回 2 字节的 {},不是错误、也不是数组
③ 非 2xx / 非 JSON 都要变成可读错误,而不是穿透到协议层
"""
proc = subprocess.run(["curl", "-sL", "-m", str(timeout), url],
capture_output=True)
raw = proc.stdout.decode("utf-8-sig", errors="replace") # 坑 ①
if not raw.strip():
return None, "空响应体"
try:
return json.loads(raw), None
except ValueError as e:
return None, f"响应不是合法 JSON:{e}"
坑 ① 的现场是这样的:json.load 直接报 Unexpected UTF-8 BOM (decode using utf-8-sig),而后缀改一个参数就能解。坑 ② 更隐蔽:{} 是一个合法的 JSON 对象,json.loads 不会报错,只有当你按 ["warning"] 去取值时才炸——所以必须显式判「有没有警告内容」,而不是判「响应是否为真」。
8. 踩坑清单
| 缺 _meta 必填字段 | 直接 500 或静默按旧语义处理 | 显式回 -32602,并在 data.missing 里点名 |
| 版本不支持只回「不支持」 | 客户端无从选择 | 回 -32022 并带 supported 清单 |
| 工具失败混进协议错误 | 模型收到无法修复的报错 | 包成 isError: true,留在 result 里 |
| legacy 未握手就发请求 | 会话语义不明 | 拒绝并提示先 initialize |
| 结果缺 resultType | 现代客户端无法判断终态 | 现代路径必带;legacy 路径不带(旧端不认) |
| 假期 JSON 带 BOM | JSONDecodeError | decode("utf-8-sig") |
| 无警告时返回 {} | 按字段取值才炸 | 显式判内容,不判真假 |
| 工具名冲突 | 聚合多个 Server 时撞名 | 名字唯一性只在单个 Server 内,聚合方要加前缀 |
| 日志写进 stdout | 协议流被污染 | 日志一律走 stderr,stdout 只放 JSON |
原创声明:本文为原创技术实践。协议细节逐条核对 MCP 官方规范 2026-07-28 版;28 项断言在本地 stdio 管线上真实执行,纯标准库、零依赖、零模型调用。文中的「现代 / 旧版」时代划分以规范原文为准,客户端对 2026-07-28 的支持情况请以各自实现为准;工具数据来自 1823 与香港天文台公开接口,可能随官方调整而变化,请以自测为准。
10. 参考链接
MCP 与协议接入的实战会持续更新,关注不迷路。

