一个能用的 MCP 服务器三十行就够:一份工具清单、一个发送函数、一个按方法名分支的循环。 但那三十行里一半的坑都在协议细节上:标准输出只能放协议、每行都要刷新、id 要原样带回。
上一节连的是别人写好的服务器,这一节自己写一个。 这一节有真跑的服务器代码——它在演示里起过、收过四帧请求、回过四帧回应。
一、服务器一共几块
工具清单:名字、说明、参数 schema。tools/list 时原样返回。 发送函数:把对象序列化成一行 JSON,写到标准输出并刷新。 循环:逐行读标准输入,按 method 分支,调对应的处理函数。
三块之外没有别的。没有框架、没有依赖、没有生命周期管理——因为协议只要求这么多。 这也解释了为什么 MCP 服务器能几十行写完:它不需要成为一个「应用」,只需要成为一个「应答机」。
二、真跑四帧
演示里四帧:initialize、tools/list、两次 tools/call——一次 echo、一次 now。 每一帧的 id 都对上,返回内容都在 result 里。 这就已经是一个能用的服务器了:宿主能连上、能看到工具、能调。
模板可以从这儿开始:先跑通这四帧,再往里加业务。反过来先写业务,会卡在协议上动不了。
三、三个坑
第一个:标准输出只能放协议。任何调试输出都会破坏一行一个 JSON 的约定,调试信息必须走标准错误。 第二个:每写一行都要刷新缓冲。不刷新,客户端会一直等,看起来像卡死。 第三个:id 要原样带回去。忘了带,客户端认不出这是哪次请求的回应。
三个坑全是协议细节,不是业务逻辑。写服务器的麻烦一半都在这里,而且它们都不会给出有用的报错。
四、从三十行到一个能用的服务器
还要补的:参数校验(缺了必填参数要报 -32602)、超时(长任务要能被打断)、日志(写文件, 不能写标准输出)。 再往上才是「工具怎么切」:一个工具查全部,还是五个工具各查一部分。 这个设计决定和 3.10 节的四种模式是一路的:先定动作形状,再写实现。
顺序反了会很别扭:先写五个工具,再发现该合成一个,前面写的全要拆。
五、技术门槛低,设计门槛不低
三十行就能跑,但「该暴露哪几个工具、每个工具的参数怎么收窄」得想很久。 判据还是那三条:工具够不够少、schema 够不够短、权限够不够窄。 写服务器的人最容易犯的错是「把内部 API 一比一暴露出去」。
内部 API 是按人设计的——人知道上下文、会看文档、出错会问。模型不会。 所以给模型的工具要重新设计,不是搬运。
六、动手 Demo:从零写一个服务器:三十行、两个工具、四帧对话
这一段代码可以直接落盘跑。它做的就是三个方法的分支,没有框架、没有依赖。 演示里真起了这个服务器,走了四帧往返。
def send(obj):
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + u"\n")
sys.stdout.flush() # 不刷新,客户端会一直等
for line in sys.stdin:
req = json.loads(line)
{"initialize": on_init, "tools/list": on_list, "tools/call": on_call}[req["method"]](req)
实跑输出:
--------------------------------------------------------------------
从零写一个服务器:三十行、两个工具、四帧对话
--------------------------------------------------------------------
这一段代码可以直接落盘跑。它做的就是三个方法的分支,没有框架、没有依赖。
① 服务器一共几块
--------------------------------------------------------------------
| 块 | 内容 | 什么时候用 |
|---|---|---|
| 工具清单 | 名字、说明、参数 schema | tools/list 时返回 |
| 发送函数 | 写一行 JSON 到标准输出并刷新 | 每次回应都用它 |
| 循环 | 逐行读标准输入、按方法名分支 | 整个服务器的骨架 |
② 真跑四帧
--------------------------------------------------------------------
| 方法 | 请求 id | 回应 id | 返回(截断) |
|---|---:|---|---|
| initialize | 1 | 1 | {"protocolVersion": "2025-06-18", "serve |
| tools/list | 2 | 2 | {"tools": [{"name": "echo", "description |
| tools/call | 3 | 3 | {"content": [{"type": "text", "text": "你 |
| tools/call | 4 | 4 | {"content": [{"type": "text", "text": "2 |
③ 三个坑
--------------------------------------------------------------------
第一个:标准输出只能放协议。任何调试输出都会破坏一行一个 JSON 的约定——调信息必须走标准错误。
第二个:每写一行都要刷新缓冲。不刷新,客户端会一直等,看起来像卡死。
第三个:id 要原样带回去。忘了带,客户端认不出这是哪次请求的回应。
三个坑全都是「协议细节」,不是业务逻辑。写服务器的麻烦一半都在这里。
④ 从三十行到一个能用的服务器
--------------------------------------------------------------------
还要补的:参数校验(缺了必填参数要报 -32602)、超时(长任务要能被打断)、日志(写文件,
不能写标准输出)。
再往上才是「工具怎么切」:一个工具查全部,还是五个工具各查一部分。
这个设计决定和 3.10 节的四种模式是一路的:先定动作形状。
! 一句话:MCP 服务器的技术门槛很低,设计门槛不低。
三十行就能跑,但「该暴露哪几个工具、每个工具的参数怎么收窄」得想很久。
①服务器只有三块:工具清单(名字、说明、参数 schema)、发送函数(写一行 JSON 并刷新)、 按方法名分支的循环。三块之外没有别的。 ②真跑四帧:initialize、tools/list、两次 tools/call。 每一帧的 id 都对上,返回内容都在 result 里,这就已经能用了。 ③三个坑全是协议细节:标准输出只能放协议(调试走标准错误)、每写一行都要刷新缓冲、id 要原样带回去。 这三个坑都不会给出有用的报错,现象只是「连不上」或者「卡住」——所以写服务器一半的麻烦在这儿。 ④还要补的:参数校验(缺必填报 -32602)、超时(长任务要能被打断)、日志(写文件,不能写标准输出)。 再往上才是「工具怎么切」。
30 秒实验
- 把刷新缓冲那行(flush)注释掉,重跑一遍。客户端会怎样?这个现象和「服务器崩了」能区分开吗?
- 在服务器里加一行 print 打印请求,重跑一遍。客户端还能解析吗?为什么?
- 给 echo 工具加一个必填参数,故意不传,重跑一遍。你自己写的服务器会报 -32602 吗? 不报的话,客户端看到的是什么?
常见误区
- 在服务器里用 print 调试。标准输出是协议通道,print 会把 JSON 冲掉,整条连接失效。
- 写完不刷新缓冲。客户端一直等,看起来像卡死,而且报错信息里什么线索都没有。
- 回应不带 id 或者乱填。客户端认不出这是哪次请求的回应。
- 把内部 API 一比一暴露成工具。内部 API 是按人设计的,给模型的工具要重新切。
- 先写业务逻辑,最后才补协议。会卡在协议上动不了,因为业务没法单独验证。
自测三问(点开对答案)
1. 一个能用的 MCP 服务器最少要哪几块? —— 工具清单、一个写一行 JSON 并刷新的发送函数、一个按方法名分支读标准输入的循环。 2. 为什么调试信息不能写标准输出? —— 因为标准输出是协议通道,必须保持一行一个 JSON。 混进别的东西,客户端解析就失败,整条连接失效。 3. 写服务器最容易犯的设计错误是什么? —— 把内部 API 一比一暴露成工具。内部 API 是按人设计的,给模型的工具要按「动作形状」重新切。自己写的服务器能跑了,下一节退一步看生态:现成的一堆服务器,装之前该算什么账。
- ☐ (本篇完,下一篇 6.7 常用 MCP 服务器生态)
相关笔记
- 架构:协议的三帧骨架
- 四种设计模式:四种模式怎么定动作形状
- Claude Code 橙皮书 · 索引
- 🐎 Claude Code 实战 · 索引