🏠 总目录📚 本教程 08b · 写一个 MCP Server ← →
📑 本页目录(点开跳转)

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

⭐ 「怎么确认它真被加载了」的答案就是这条:别看配置文件写没写,看宿主的健康检查说没说连上。

⚠️ 连不上时按这个顺序排查,越靠前越常见:

  1. 手动跑一遍那条启动命令 —— python xxx.py 自己都起不来,宿主更起不来。一半的问题到这步就结束了。
  2. 💀 看有没有往标准输出打别的东西。 stdio 下标准输出就是协议信道,print() 出来的任何一行都会被当成一条 JSON-RPC 消息,一句调试日志就能让握手失败。⭐ 日志一律走 stderr。
  3. 路径和解释器写绝对的 —— 宿主拉子进程时的工作目录和 PATH 跟你的终端不一样。
  4. 用同协议版本的 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 参数一律当不可信输入校验

✅ 检查点

  1. 本章的 2025 教学子集实现哪三个方法?为何不能直接代表 2026 协议?工具执行失败时该放 error 字段还是 result 里?
  2. 用官方 SDK 时,函数的 docstring 和类型注解分别变成了协议里的什么?没写 docstring 会怎样?
  3. Tool / Resource / Prompt 分别由谁触发?「查一下这个工单现在什么状态」该做成哪一类,为什么?
  4. 从 stdio 换成 HTTP,工具代码要改吗?工程上多出来哪四件事?
  5. 💀 stdio 下为什么绝对不能用 print() 打日志?该打到哪?
  6. 一个描述像样、五个参数的工具,序列化成线上 JSON 大约多少字符?20 个是多少?这个数字为什么要紧?
  7. 💀 在工具里 raise ValueError("参数要写绝对路径"),模型实际会看到什么?正确写法和判据是什么?
  8. 怎么确认一个 Server 真的被宿主加载了?连不上时头两步查什么?
👀 答案
  1. 本章 2025 教学子集包含 initialize、tools/list 与 tools/call;完整生命周期还包含通知等规则。2026-07-28 移除了初始化握手,见 08c。业务失败放入结果并标 isError,协议结构错误用 JSON-RPC error;通知不回复。
  2. docstring 通常成为 description,函数参数及类型注解参与生成 schema;名字、参数与上下文也影响工具选择。缺描述会降低可理解性,不代表模型对工具一无所知。输出结构需核对实际 outputSchema 与 structuredContent。
  3. Tool 通常供模型提出调用,Resource 由应用控制读取与上下文装配,Prompt 通常由用户选择。查工单可以做 Tool,也可由宿主读取 Resource;看目标宿主支持与交互需要,不能规定必须手动点击。
  4. 纯业务函数可以复用;传输、身份获取与启动配置需要适配。检查认证授权、超时、断流恢复与协议版本。旧版 session 是可选机制,2026 已移除;HTTP 响应可能是 JSON 或 SSE。
  5. stdio 下标准输出就是协议信道,print() 出来的任何一行都会被当成一条 JSON-RPC 消息,一句调试日志就能让握手失败。日志一律走 stderr。
  6. 字符数由 schema、SDK 版本与描述决定,用本章脚本实测;字符不等于 token。工具定义是否全量重发、缓存或按需暴露由宿主和模型接口决定,预算必须测实际请求。
  7. 💀 模型只看到 Error executing tool read_file_bad,那句话一个字都传不到 —— SDK 把普通异常当崩溃,堆栈只进服务端日志。正确写法是 raise ToolError("..."),消息会原样出现在模型面前。⭐ 判据是这个失败你预见到了吗:预见到的用 ToolError,没预见到的让它崩、别漏内部细节。
  8. 看宿主的健康检查说没说连上,不是看配置文件写没写 —— 例如 claude mcp list 输出里那行结尾的 ✔ Connected。前两步:① 终端手动跑一遍启动命令;② 检查有没有往标准输出打日志。

🛑 可以停在这里

⚡ 走神救援

先记住这几件事

实践下一站 👉 08c · 鉴权与互操作;随后继续 09 · 计算机与浏览器使用。

打卡记录保存在你的浏览器里,首页能看到总进度