📑 本页目录(点开跳转)
08b · 动手写一个 MCP Server
⏱ 64 分钟 | ⭐ 上一章把「加一个系统 = 只写一个 Server」当成整章的落点,却没说那个 Server 怎么写
🎯 一句话
一个 MCP Server 就是「报家门 + 按名字执行函数」的进程。
先分清协议版本:下面的手写练习使用 2025-06-18 的旧握手,只演示工具相关的 JSON-RPC 子集。2026-07-28 已改为逐请求声明版本与能力,并使用 server/discover 发现服务,不再使用初始化握手。完整应用使用锁定版本的 SDK;08c 的互操作实验把新旧协议与 HTTP 授权分开验收。
工具描述决定模型是否会选对;参数校验、资源授权、错误处理和副作用控制决定调用是否可靠。两部分都要验收。
🧱 一、先手写一次,看清协议本身
MCP 消息使用 JSON-RPC;stdio 用换行分隔 JSON,HTTP 有自己的响应协商,不能把“一行一个 JSON”推广到所有传输。
下面是 2025-06-18 工具消息子集练习,不是完整协议实现。它不实现完整生命周期、分页或取消。复制到空文件能运行;没有输入时会等待 stdin。普通说明只写 stderr,stdout 每行都必须是协议消息。
import json
import sys
VERSION = "2025-06-18"
TOOLS = [{"name": "word_count", "description": "统计 text 的字符数,上限 20000 字符。",
"inputSchema": {"type": "object", "required": ["text"],
"additionalProperties": False,
"properties": {"text": {"type": "string", "maxLength": 20000}}}}]
def error(rid, code, message):
return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
def handle(req):
if not isinstance(req, dict) or req.get("jsonrpc") != "2.0" or not isinstance(req.get("method"), str):
return error(None, -32600, "Invalid Request")
if "id" not in req: # 通知不回复;完整生命周期交给 SDK
return None
rid, method = req["id"], req["method"]
if isinstance(rid, bool) or not isinstance(rid, (str, int)):
return error(None, -32600, "Invalid request id")
params = req.get("params", {})
if not isinstance(params, dict):
return error(rid, -32602, "params must be an object")
if method == "initialize":
if params.get("protocolVersion") != VERSION:
return error(rid, -32602, "This teaching subset only supports " + VERSION)
result = {"protocolVersion": VERSION, "capabilities": {"tools": {}},
"serverInfo": {"name": "bare-notes", "version": "0.2.0"}}
elif method == "tools/list":
result = {"tools": TOOLS}
elif method == "tools/call":
args = params.get("arguments", {})
if params.get("name") != "word_count":
return error(rid, -32602, "Unknown tool")
if not isinstance(args, dict) or set(args) != {"text"} or not isinstance(args["text"], str):
return error(rid, -32602, "Expected only a string text argument")
if len(args["text"]) > 20000:
return error(rid, -32602, "text too long")
result = {"isError": False, "content": [{"type": "text", "text": str(len(args["text"]))}]}
else:
return error(rid, -32601, "Method not found")
return {"jsonrpc": "2.0", "id": rid, "result": result}
if __name__ == "__main__":
print("读取 stdin;本示例是旧版协议教学子集", file=sys.stderr)
for line in sys.stdin:
if not line.strip():
continue
try:
result = handle(json.loads(line))
except json.JSONDecodeError:
result = error(None, -32700, "Parse error")
if result is not None:
print(json.dumps(result, ensure_ascii=False, allow_nan=False), flush=True)
发送 tools/call、参数为 {"name":"word_count","arguments":{"text":"你好 MCP"}} 时,返回文本为 6。通知没有响应;坏 JSON 返回 -32700;未知工具与参数类型错返回 -32602。这些结果由本轮独立进程测试核对,不能只凭退出码判断协议正常。
⭐ 三件值得记住的事: ①
tools/list返回的每个工具就是名字 + 描述 + 一份 JSON Schema,和第 7 章讲的东西一模一样,MCP 只是给它加了个信封。 ② 业务失败放进result里(isError: true,模型看得到并据此改正),error字段留给协议层错误(方法不存在、参数结构非法),那是给宿主看的。 ③ 整个 Server 没有一行代码关心模型怎么想 —— 那是 Function Calling 那一层的事,这正是上一章说的「三层是叠加的」。
🛠️ 二、官方 SDK:一个能跑的 Server 只要十行
手写练习帮助阅读报文,日常使用 SDK。以下示例锁定 mcp==2.1.1;安装命令为 python -m pip install "mcp==2.1.1"。升级时重跑互操作测试,不混用不同版本教程。
# 一个完整可跑的 stdio MCP Server
from mcp.server import MCPServer
mcp = MCPServer("notes")
@mcp.tool()
def word_count(text: str) -> int:
"""数一段文本有多少个字符。""" # ⭐ 这句 docstring 就是模型看到的 description
return len(text)
if __name__ == "__main__":
mcp.run() # 默认 stdio
把它当子进程拉起来发一条 tools/list,实测拿回来的是:
{"name": "word_count", "description": "数一段文本有多少个字符。",
"inputSchema": {"type": "object", "required": ["text"],
"properties": {"text": {"title": "Text", "type": "string"}}},
"outputSchema": {"type": "object", "properties": {"result": {"type": "integer"}}}}
SDK 从下面两类 Python 信息生成工具定义;此外还处理协议、校验和传输:
| 你写的 | 变成了 |
|---|---|
| 函数的 docstring | description —— 模型判断「什么时候该调它」的重要依据 |
| 函数的类型注解和参数名 | inputSchema / outputSchema —— 参数结构与类型约束的来源 |
🗓️ 一个已经过期过一次的点:这套装饰器早期叫
FastMCP(from mcp.server.fastmcp import FastMCP), SDK 的 2.x 改名成了MCPServer;本章实测于 mcp 2.1.1。⭐ 好在报错很直白:旧写法会拿到
ModuleNotFoundError: No module named 'mcp.server.fastmcp',后面直接跟着「FastMCP was renamed to MCPServer」和迁移链接。这两个最小工具的迁移很短,但不能据此推断整个应用只改 import。Client、授权、会话与结果类型需要按官方迁移指南逐项核对。
📦 三、三类东西在代码里长什么样
上一章那张表说 Server 能暴露 Tools / Resources / Prompts。在 SDK 里就是三个装饰器:
# 三类东西各写一个,完整可跑
from mcp.server import MCPServer
mcp = MCPServer("notes")
NOTES: dict[str, str] = {}
@mcp.tool() # ① 工具:模型自己决定要不要调
def save_note(title: str, body: str) -> str:
"""把一条笔记存起来。title 是唯一键,同名会覆盖。"""
NOTES[title] = body
return f"已保存《{title}》,共 {len(body)} 字"
@mcp.resource("note://{title}") # ② 资源:像文件一样被读,用 URI 寻址
def read_note(title: str) -> str:
"""按标题读一条笔记的正文。"""
return NOTES.get(title, "(没有这条笔记)")
@mcp.prompt() # ③ 提示模板:用户从菜单里挑
def review(title: str) -> str:
"""生成一个「审阅这条笔记」的提示模板。"""
return f"请审阅笔记《{title}》,指出三处可以更清楚的地方。"
if __name__ == "__main__":
mcp.run()
实测它报出来的家底:工具 save_note;资源模板 note://{title};提示 review(参数 ['title'])。
读 note://MCP 拿回 ReadResourceContents(content='先写 Server 再接宿主', mime_type='text/plain')。
| 谁触发 | 该放什么 | |
|---|---|---|
| Tool | 模型自己决定 ⭐ 九成场景选它 | 有副作用的操作、需要参数的查询 |
| Resource | 宿主/用户挑了才读 | 一份文档、一张表、一段日志 |
| Prompt | 用户从菜单里点 | 预置的工作流开场白 |
只读查询可以是 Tool,也可以通过 Resource 暴露。Tool 通常便于模型提出带参数的调用;Resource 由应用控制怎样读取、怎样放入上下文,并不要求用户先手动点击。需要模型直接查工单状态时,Tool 是方便的接口;需要稳定 URI、订阅或文档浏览时再考虑 Resource。检查实际宿主支持什么,不把“动词/名词”口诀当成协议限制。
🔌 四、stdio 和 HTTP,只差 run() 那一行
上一章说「远程 Server 是生产主流形态」。好消息是工具代码一行都不用改:
# 🧩 骨架:和上一段是同一个文件,只有最后这一行不一样
mcp.run(transport="streamable-http", host="127.0.0.1", port=8931)
切换传输后,工具函数可以复用,授权与连接行为仍须重测。
| 项目 | stdio | Streamable HTTP |
|---|---|---|
| 消息边界 | stdout 每行一个 JSON;日志走 stderr | 按协议和请求协商使用 JSON 或 SSE;代理不能擅自缓冲流 |
| 2025-11-25 会话 | 旧版握手与能力协商 | Server 可选择建立会话;只有它返回会话 ID 后才按规范回传;ID 不是用户身份 |
| 2026-07-28 | 每请求元数据;server/discover |
无协议级 session ID;断流重发用新 RPC ID,业务幂等键仍保持稳定 |
| 身份与隔离 | 宿主限制子进程权限、环境和文件范围 | 每次请求验证 token;每个业务资源继续做租户和权限检查 |
| 故障 | 子进程退出、管道阻塞、错误输出、取消后未清理 | 授权失败、超时、断流、重发后的重复动作 |
旧版观察到的 SSE 响应和 mcp-session-id 不能写成跨版本的固定规则。依据:2025 传输、2026 变更。完整 HTTP Client、授权负例与排障入口在08c。
🛑 读到这里可以停 —— 前半章讲完了(约 27 分钟):协议本身、SDK 的十行 Server、三类东西、两种传输。 后半章还有:描述就是这个 Server 的全部界面 · 接进宿主(配置 / 确认 / 排查)· 三个真实的坑 回来的时候不用重读,直接从下一节接着看就行。
✍️ 五、描述就是这个 Server 的全部界面
⭐⭐ 这是全章最该记住的一节。 模型看不到你的源码、数据库和注释,它能看到的只有 tools/list 里那份名字 + 描述 + schema。
实测把 docstring 删掉之后:
no_doc -> description = ''
read_file -> description = '读一个文件的内容。path 必须是绝对路径。'
⚠️ description = '' 不报错、不警告、Server 照常连上,模型只是从此对这个工具一无所知 ——
然后你会觉得「它怎么老不调我这个工具」。
第 7 章那五条原则在这里逐条对得上:
| 第 7 章原则 | 在 Server 代码里怎么落 |
|---|---|
| ① 选对工具,而非更多工具 | 一个函数对应一个用户意图,别按数据表建函数 —— 上一章「别镜像 API」就是这条 |
| ② 命名空间 | 函数名带前缀(notes_save 而不是 save),模型才不会拿 A 系统的 id 去调 B 系统 |
| ③ 返回有意义的上下文 | return 的是给模型读的字符串,不是 UUID —— 短索引更防幻觉 |
| ④ 为 token 效率优化 | limit 要有默认值和硬上限,截断时说明还剩多少、怎么拿下一页 |
| ⑤ ⭐ 工具描述就是提示词 | 就是那句 docstring:取值范围、单位、什么时候不该用 |
🔑 一条很便宜的自检:把
tools/list的结果原样打印出来读一遍,问自己 「只看这段,我知道该在什么时候调它、每个参数填什么吗?」你答不上来的地方,模型只会猜。
🔧 六、接进宿主:配置、确认、排查
stdio Server 的配置就是告诉宿主「用什么命令把你拉起来」:
{"mcpServers": {
"notes": {"command": "python",
"args": ["C:/work/notes_server.py"],
"env": {"NOTES_DB": "C:/work/notes.sqlite"}}}}
🗓️ 这个文件叫什么、放在哪各家宿主不一样(用户级配置、项目根目录 .mcp.json 都有),
这些键常见于部分宿主配置,不能假定所有宿主都使用相同文件结构。路径去宿主的文档核对。
⭐ 命令行往往比手改文件省事。以 Claude Code 为例,实测这台机器上:
claude mcp add my-server -e API_KEY=xxx -- npx my-mcp-server
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp
claude mcp list # 列出来并做健康检查,实测每行结尾是 ✔ Connected
⭐ 「怎么确认它真被加载了」的答案就是这条:别看配置文件写没写,看宿主的健康检查说没说连上。
⚠️ 连不上时按这个顺序排查,越靠前越常见:
- 手动跑一遍那条启动命令 ——
python xxx.py自己都起不来,宿主更起不来。一半的问题到这步就结束了。 - 💀 看有没有往标准输出打别的东西。 stdio 下标准输出就是协议信道,
print()出来的任何一行都会被当成一条 JSON-RPC 消息,一句调试日志就能让握手失败。⭐ 日志一律走stderr。 - 路径和解释器写绝对的 —— 宿主拉子进程时的工作目录和
PATH跟你的终端不一样。 - 用同协议版本的 Client 做真实调用:旧版检查初始化,新版检查发现与每请求元数据。连通之后仍需验证工具、身份和错误;HTTP 同时检查授权头、JSON/SSE 与代理行为,旧版仅在 Server 创建会话时回送 session ID。
🕳️ 七、三个真实的坑
坑一:工具太多,Agent 还没干活预算就没了
上一章叫它甜蜜陷阱,这里给一个你自己能跑出来的量级:
# 量一遍:一个工具定义在线上占多少字符
import asyncio, json
from mcp.server import MCPServer
mcp = MCPServer("issues")
@mcp.tool()
def search_issues(query: str, project: str, assignee: str = "", state: str = "open",
limit: int = 20) -> str:
"""在项目里按关键词搜索工单。
query 是搜索词;project 是项目 key(如 ENG);assignee 传用户名可只看某人的;
state 取 open / closed / all;limit 是最多返回几条,默认 20,上限 100。
返回每条工单的短索引、标题、状态、负责人。"""
return "(这里返回搜索结果)"
async def main():
t = (await mcp.list_tools())[0]
s = json.dumps({"name": t.name, "description": t.description,
"inputSchema": t.input_schema}, ensure_ascii=False)
print("一个工具的定义占", len(s), "个字符;20 个就是", len(s) * 20)
asyncio.run(main())
运行后记录当前版本的字符数;它不是 token 数。实际请求是否每轮携带全量工具、是否缓存或按需发现,由宿主与模型接口决定。
⚠️ 这条坑的形状是:它不是"慢",是"挤" —— 工具菜单占掉的位置本来留给任务本身(第 4 章的核心命题)。 所以上一章那张表里 Tool Search「省 token 的同时准确率反而上升」才不反直觉:工具菜单本身就是噪声。 你能做的是:一个意图一个工具,能给默认值就给默认值。
坑二:返回体积不控制
return 的内容可能被宿主加入后续上下文;保留、截断与压缩策略由宿主决定。最常见的形态是「查询工具默认返回全部」——
第 7 章那个 list_contacts() 返回 5000 条的例子换成 MCP 一模一样,只是现在坏的是别人的上下文。
对策:limit 给默认值和硬上限;截断时说明还剩多少、怎么取下一页;返回摘要而不是原始记录。
坑三:💀 错误信息对模型没用 —— 而且默认就是没用的
这是本章实测里最值钱的一条:
# 同一个失败,两种写法 —— 完整可跑
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("t")
@mcp.tool()
def read_file_bad(path: str) -> str:
"""读文件(❌ 错误示范:抛一个普通异常)。"""
raise ValueError("path 必须是绝对路径,你传的是 " + path)
@mcp.tool()
def read_file_good(path: str) -> str:
"""读文件(✅ 正确示范:抛 ToolError)。"""
raise ToolError("path 必须是绝对路径。收到的是 '" + path + "'。"
"请改成以盘符或 / 开头的完整路径,例如 /data/a.txt,然后重试。")
if __name__ == "__main__":
mcp.run()
两个都传 path="a.txt",模型实际收到的 content 是:
| 写法 | 返回给调用方的信息 |
|---|---|
read_file_bad |
普通异常被屏蔽为通用工具错误 |
read_file_good |
ToolError 的预期错误说明可以传给调用方;只包含可公开的信息 |
这是错误类型对照;确切字符串可能随 SDK 版本变化,以真实 Client 返回为准。
💀 第一行那句精心写好的报错,模型一个字都看不到。 SDK 把普通异常当成「崩溃」:堆栈只进服务端日志,
给客户端的只剩一句 Error executing tool <名字>。这是防信息泄露的正确默认,但你必须知道有这么回事,
否则你会以为自己已经把话说清楚了。
⭐ 判据是「这个失败你预见到了吗」:预见到的(参数不合法、资源不存在、超出配额)用 ToolError 抛,
消息原样送到模型面前;没预见到的让它崩,别把内部细节漏出去。
⭐⭐ 这和《AI 全栈》03 · 后端骨架第五节「对外一种形状,对内保留细节」是同一条工程纪律,
只不过这里的「对外」是模型。
⭐ 一个白送的好处:参数校验失败时 SDK 自己就给出可行动的报错 —— 实测漏传
path拿到的是1 validation error ... path / Field required,正是第 7 章「错误信息四要素」里的错在哪 + 规则,你只要补上该怎么办。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 08 · MCP:接入外部世界 | 本章是它的实现侧。要不要写这个 Server、写成什么粒度、值不值得上 MCP,判据全在那一章(三层协议、Host/Client/Server 的分工、决策表) |
| 07 · 工具设计:能力的上限 | 本章第五节那句 docstring 的写法全在那里。写完 Server 要回去对一遍五条原则和「错误信息四要素」 |
| 《AI 全栈》03 · 后端骨架 | 换成 HTTP 之后 Server 就是普通 Web 服务:async 怎么不被同步调用卡死、错误怎么做到「对外一种形状对内保留细节」,那一章讲的就是这两件事 |
| 15 · 安全:沙箱与提示注入 | ⚠️ 你的 Server 收到的参数是模型生成的,而模型可能读过外部内容。 路径、SQL、shell 参数一律当不可信输入校验 |
✅ 检查点
- 本章的 2025 教学子集实现哪三个方法?为何不能直接代表 2026 协议?工具执行失败时该放
error字段还是result里? - 用官方 SDK 时,函数的 docstring 和类型注解分别变成了协议里的什么?没写 docstring 会怎样?
- Tool / Resource / Prompt 分别由谁触发?「查一下这个工单现在什么状态」该做成哪一类,为什么?
- 从 stdio 换成 HTTP,工具代码要改吗?工程上多出来哪四件事?
- 💀 stdio 下为什么绝对不能用
print()打日志?该打到哪? - 一个描述像样、五个参数的工具,序列化成线上 JSON 大约多少字符?20 个是多少?这个数字为什么要紧?
- 💀 在工具里
raise ValueError("参数要写绝对路径"),模型实际会看到什么?正确写法和判据是什么? - 怎么确认一个 Server 真的被宿主加载了?连不上时头两步查什么?
👀 答案
- 本章 2025 教学子集包含
initialize、tools/list与tools/call;完整生命周期还包含通知等规则。2026-07-28 移除了初始化握手,见 08c。业务失败放入结果并标isError,协议结构错误用 JSON-RPC error;通知不回复。 - docstring 通常成为 description,函数参数及类型注解参与生成 schema;名字、参数与上下文也影响工具选择。缺描述会降低可理解性,不代表模型对工具一无所知。输出结构需核对实际 outputSchema 与 structuredContent。
- Tool 通常供模型提出调用,Resource 由应用控制读取与上下文装配,Prompt 通常由用户选择。查工单可以做 Tool,也可由宿主读取 Resource;看目标宿主支持与交互需要,不能规定必须手动点击。
- 纯业务函数可以复用;传输、身份获取与启动配置需要适配。检查认证授权、超时、断流恢复与协议版本。旧版 session 是可选机制,2026 已移除;HTTP 响应可能是 JSON 或 SSE。
- stdio 下标准输出就是协议信道,
print()出来的任何一行都会被当成一条 JSON-RPC 消息,一句调试日志就能让握手失败。日志一律走stderr。 - 字符数由 schema、SDK 版本与描述决定,用本章脚本实测;字符不等于 token。工具定义是否全量重发、缓存或按需暴露由宿主和模型接口决定,预算必须测实际请求。
- 💀 模型只看到
Error executing tool read_file_bad,那句话一个字都传不到 —— SDK 把普通异常当崩溃,堆栈只进服务端日志。正确写法是raise ToolError("..."),消息会原样出现在模型面前。⭐ 判据是这个失败你预见到了吗:预见到的用ToolError,没预见到的让它崩、别漏内部细节。 - 看宿主的健康检查说没说连上,不是看配置文件写没写 —— 例如
claude mcp list输出里那行结尾的✔ Connected。前两步:① 终端手动跑一遍启动命令;② 检查有没有往标准输出打日志。
🛑 可以停在这里
⚡ 走神救援
先记住这几件事
- 本章手写示例属于 2025 教学子集;新协议实作看 08c。
- 请求带有 id,需要对应响应;通知没有 id,不应当作请求回复。
- 区分协议错误与工具业务失败,让调用方和模型都能获得可处理的信息。
实践下一站 👉 08c · 鉴权与互操作;随后继续 09 · 计算机与浏览器使用。