本教程带你用 Python 官方 MCP SDK 的底层 Server 类,从零造一台完整的服务器,把 MCP 的三大原语——工具(Tools)、资源(Resources)、提示词(Prompts)——全部打通。多数教程只讲"造个工具",结果你只会写 call_tool。本教程的差异化在于:用底层逐个注册 handler,让你看清三原语各自的控制方(谁触发、谁控制),并在结尾交付一台可接进 Claude Code 的"项目状态看板"MCP 服务器。适合已有 Python 基础、想系统掌握 MCP 协议、追求"不被高层框架黑箱遮蔽"的开发者。
match、| 联合类型注解)claude)uv 作为包管理器(速度更快、隔离更干净),没有也行,可用 pipasync/await 与 JSON Schema 的基本结构写底层代码前,先想清楚一件事:MCP 不是"又一组 API",它是一份协议约定。服务器对外只暴露三类原语,每类原语的"控制方"不同。记住这张对照表,下面所有 handler 都在围绕它转:
| 原语 | 谁来触发 | 控制方 | 生命周期入口 | 典型用途 |
|---|---|---|---|---|
| 工具 Tools | 模型自主决定调用 | 模型控制(Model-controlled) | tools/list → tools/call |
跑命令、查库、调外部 API |
| 资源 Resources | 宿主应用决定是否注入 | 应用控制(App-controlled) | resources/list → resources/read |
把文件、配置、日志作为上下文喂进去 |
| 提示词 Prompts | 用户主动选中 | 用户控制(User-controlled) | prompts/list → prompts/get |
可复用的斜杠命令模板 |
一句话区分:工具是给模型用的、资源是给应用挂的、提示词是给人选的。 三者谁也不替代谁。如果你的服务器只有工具,那你只用了 MCP 三分之一的表达能力——这正是很多人对 MCP 的误解来源。
底层 Server 类的设计哲学是"显式胜于隐式":你为哪个原语注册了 handler,服务器就在 initialize 握手时自动声明哪项能力(capability),客户端收到声明后才会发起对应请求。这与高层 FastMCP(装饰器自动推断)的路数相反——底层让你对自己声明的每一项能力负责。
常见问题:为什么我注册了 read_resource 却报"capability 不支持"?通常是 handler 在 app.run() 之前没有挂上,或函数名/签名拼错导致装饰器没绑成功。底层不报错,只是静默不声明能力,要警惕。
建一个空目录,初始化项目:
mkdir mcp-status-board && cd mcp-status-board
uv init --name mcp-status-board # 或手动建 pyproject.toml
uv add "mcp[cli]>=1.2" # 装官方 Python SDK(含 CLI 辅助)
不用 uv 的等价写法:
python -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]>=1.2"
mcp[cli] 这个 extras 会顺带装上 typer 等工具,方便用 mcp 命令做自检。装完验证一下:
mcp version # 能打印版本号即装通
预期结果:终端回显一行版本号,例如 mcp 1.x.x。若提示 command not found,多半是没装 [cli] extras,或 uv 的环境没激活。
常见问题:报 ImportError: cannot import name 'Server'——你装的可能是远古版本(早期包名是 mcp 但无 server 子模块)。务必 >=1.2。报 Python 版本错误——SDK 要 3.10+,pyenv 切版本即可。
Server 类注册工具(Tools)工具是绝大多数 MCP 教程唯一覆盖的原语。底层写法分两步:先 list_tools 声明清单,再 call_tool 处理调用。注意 inputSchema 必须是合法的 JSON Schema,模型据此生成参数。
新建 server.py,先把骨架与工具部分写出来:
import asyncio
import json
import os
from pathlib import Path
from typing import Any
import mcp.server.stdio
import mcp.types as types
from mcp.server import NotificationOptions, Server
from pydantic import AnyUrl
app = Server("status-board")
STORE = Path(os.environ.get("BOARD_STORE", "board.json"))
# ---------- 工具:模型控制 ----------
@app.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="add_task",
description="向看板新增一个任务,返回新任务 ID",
inputSchema={
"type": "object",
"properties": {
"title": {"type": "string", "description": "任务标题"},
"priority": {
"type": "string",
"enum": ["low", "medium", "high"],
"default": "medium",
},
},
"required": ["title"],
},
),
types.Tool(
name="transition",
description="把任务流转到下一状态:todo -> doing -> done",
inputSchema={
"type": "object",
"properties": {
"task_id": {"type": "string"},
"to": {"type": "string", "enum": ["todo", "doing", "done"]},
},
"required": ["task_id", "to"],
},
),
types.Tool(
name="summary",
description="统计各状态的任务数量,返回简洁文本",
inputSchema={"type": "object", "properties": {}},
),
]
@app.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
board = _load()
if name == "add_task":
tid = f"T{len(board['tasks']) + 1:03d}"
board["tasks"].append({
"id": tid, "title": arguments["title"],
"priority": arguments.get("priority", "medium"),
"status": "todo",
})
_save(board)
return [types.TextContent(type="text", text=f"已新增 {tid}: {arguments['title']}")]
if name == "transition":
for t in board["tasks"]:
if t["id"] == arguments["task_id"]:
t["status"] = arguments["to"]
_save(board)
return [types.TextContent(type="text", text=f"{t['id']} -> {t['status']}")]
raise ValueError(f"找不到任务 {arguments['task_id']}")
if name == "summary":
from collections import Counter
c = Counter(t["status"] for t in board["tasks"])
return [types.TextContent(type="text", text=json.dumps(dict(c), ensure_ascii=False))]
raise ValueError(f"未知工具: {name}")
_load / _save 是 JSON 文件读写辅助,放文件顶部即可:
def _load() -> dict:
if not STORE.exists():
return {"tasks": []}
return json.loads(STORE.read_text("utf-8"))
def _save(board: dict) -> None:
STORE.write_text(json.dumps(board, ensure_ascii=False, indent=2), "utf-8")
预期结果:在 Claude Code 里问"帮我加一个高优任务:修登录 bug",模型应自主调用 add_task,返回形如 已新增 T001: 修登录 bug。
常见问题:
list_tools 是否 return 了 list,且 @app.list_tools() 装饰器没漏括号——它带括号。inputSchema 缺 type: object 或 required 写成字符串而非列表。call_tool 内打印 print() 后客户端崩溃:MCP 用 stdio 通信,绝不能往 stdout 打印任何业务日志。要日志用 logging(默认走 stderr)。资源由宿主应用控制注入。底层提供两个 handler:list_resources 列出固定 URI,read_resource 按 URI 返回内容(字符串或 bytes)。URI 用 AnyUrl 类型包装。
把下面两段加到 server.py:
# ---------- 资源:应用控制 ----------
@app.list_resources()
async def list_resources() -> list[types.Resource]:
board = _load()
tasks = board["tasks"]
return [
types.Resource(
uri=AnyUrl("board://tasks/all"),
name="全部任务",
description="看板上所有任务的原始 JSON",
mimeType="application/json",
),
types.Resource(
uri=AnyUrl("board://config"),
name="看板配置",
description="状态流转规则与版本",
mimeType="application/json",
),
*[
types.Resource(
uri=AnyUrl(f"board://task/{t['id']}"),
name=t["title"],
description=f"{t['id']} [{t['status']}/{t['priority']}]",
mimeType="application/json",
)
for t in tasks
],
]
@app.read_resource()
async def read_resource(uri: AnyUrl) -> str:
s = str(uri)
if s == "board://tasks/all":
return json.dumps(_load()["tasks"], ensure_ascii=False)
if s == "board://config":
return json.dumps({"flow": ["todo", "doing", "done"], "version": "1.0"})
if s.startswith("board://task/"):
tid = s.split("/")[-1]
for t in _load()["tasks"]:
if t["id"] == tid:
return json.dumps(t, ensure_ascii=False)
raise ValueError(f"未知资源: {s}")
注意 URI 匹配是字符串精确比较,AnyUrl 会规范化(去尾斜杠等),所以建议固定写法、别留尾斜杠,避免"明明配对了却读不到"。
预期结果:在 Claude Code 里说"把全部任务资源读出来给我看",模型会通过 resources/read 拉取 board://tasks/all 并渲染成表格。
常见问题:
list_resources 返回了 None 或抛了异常,底层会静默跳过该能力。read_resource 第一行加 logging.warning(str(uri)) 打到 stderr 看。board://task/{id} 任意 ID):改用 @app.list_resource_templates() 注册模板,客户端会做 URI 模板展开。固定列表与模板可共存。提示词是用户主动选中的原语。在 Claude Code 里,服务器的 prompts 会以斜杠命令(如 /standup)的形式出现。底层需要 list_prompts 与 get_prompt:
# ---------- 提示词:用户控制 ----------
@app.list_prompts()
async def list_prompts() -> list[types.Prompt]:
return [
types.Prompt(
name="standup",
description="生成每日站会汇报,自动汇总看板状态",
arguments=[],
),
types.Prompt(
name="triage",
description="找出一批任务中的阻塞项与高优项",
arguments=[
types.PromptArgument(
name="count", description="最多列出几条", required=False,
)
],
),
]
@app.get_prompt()
async def get_prompt(
name: str, arguments: dict[str, str] | None
) -> types.GetPromptResult:
board = _load()
tasks = board["tasks"]
if name == "standup":
doing = [t for t in tasks if t["status"] == "doing"]
todo = [t for t in tasks if t["status"] == "todo"]
body = (
f"## 昨日/进行中\n"
+ "\n".join(f"- {t['id']} {t['title']}" for t in doing)
+ f"\n## 今日计划\n"
+ "\n".join(f"- {t['id']} {t['title']}" for t in todo[:3])
)
elif name == "triage":
n = int(arguments.get("count", "5")) if arguments else 5
top = sorted(
[t for t in tasks if t["status"] != "done"],
key=lambda t: (t["status"] != "doing", t["priority"] != "high"),
)[:n]
body = "请逐条分析以下任务是否阻塞,并给出流转建议:\n" + "\n".join(
f"- {t['id']} [{t['status']}/{t['priority']}] {t['title']}" for t in top
)
else:
raise ValueError(f"未知提示词: {name}")
return types.GetPromptResult(
description=f"Prompt {name}",
messages=[
types.PromptMessage(
role="user",
content=types.TextContent(type="text", text=body),
)
],
)
get_prompt 返回的是一组预填好的消息(messages),客户端会把它当作用户的输入注入对话。这意味着你可以在模板里塞结构化文本、把上下文预先算好,用户点一下就能触发一条"准备好了"的复杂指令。
预期结果:在 Claude Code 输入 /standup,选中后立即看到一段已组织好的站会汇报草稿,模型基于它续写。
常见问题:
list_prompts 没返回,或客户端没刷新。重启会话或 /mcp 里 reconnect。arguments 是 dict[str,str] | None,所有值都是字符串,数字要自己 int()。三原语注册完,最后把它们挂到 stdio 传输层。底层要把 app.run() 套进 stdio_server() 上下文:
async def main() -> None:
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await app.run(
read_stream,
write_stream,
app.create_initialization_options(
notification_options=NotificationOptions(),
),
)
if __name__ == "__main__":
asyncio.run(main())
create_initialization_options() 会扫描你注册过的 handler,自动生成对应的能力声明(tools、resources、prompts)。你没注册 list_resource_templates,它就不会声明模板能力——这正是底层"显式控制"的体现。
跑一下自检(不接客户端,只验证代码无语法/导入错误):
python -c "import server" && echo OK
# 更进一步:用 SDK 自带的 inspector
mcp dev server.py
mcp dev 会拉起一个网页版 Inspector,可在浏览器里逐个点 tools/list、resources/read、prompts/get,看返回的 JSON。这是底层开发期最高效的调试手段,比反复重启客户端快得多。
预期结果:echo OK 输出 OK;mcp dev 打开一个本地 URL,浏览器里能看到三个工具、若干资源、两个提示词。
常见问题:
mcp dev 报找不到模块:用 mcp dev server.py:app.create_initialization_options?不对——mcp dev 接受的是模块路径或Server 对象。如果你的 app 对象在模块顶层,可写 mcp dev server.py(它会自动找名为 app/server/mcp 的对象,具体以你装的版本为准;不行就 mcp dev server:app)。app.run 是否在 async with 内部被 await,少 await 会立即返回。用 CLI 注册(推荐,最快):
claude mcp add status-board -- uv run --directory "$(pwd)" server.py
# 或纯 python:claude mcp add status-board -- python server.py
或用项目级 .mcp.json(团队可提交到仓库,他人 clone 即生效):
{
"mcpServers": {
"status-board": {
"command": "uv",
"args": ["run", "server.py"]
}
}
}
进 Claude Code 后用 /mcp 查看 status-board 是否绿色 connected。然后做三原语验收:
帮我新增任务"写接口文档",优先级 high,再统计一下 → 模型连续调 add_task + summary。把 board://tasks/all 资源读出来 → 模型走 resources/read。/standup → 看到预生成的站会汇报。三种触发路径各走各的协议方法,互不串扰——这就是"三原语全打通"的验收标准。
常见问题:
/mcp 显示 failed:把 claude mcp add 的命令直接在终端跑一遍,stderr 报错会原样暴露(缺依赖、路径错、Python 版本不够)。uv:Claude Code 用的是登录 shell 的 PATH,若 uv 装在非标准位置,给 command 写全路径 /home/you/.local/bin/uv。把上面三段拼起来就是一台完整服务器,目录结构:
mcp-status-board/
├── pyproject.toml # 依赖 mcp[cli]>=1.2
├── server.py # 三原语 + 主循环
└── board.json # 运行后自动生成的存储文件
一个端到端的故事:你正在写一个微服务,每天要在多个任务间切换。把这台看板服务器接进 Claude Code 后,你可以这样工作——
/standup,秒出站会草稿(提示词,预填进行中与待办)。board://tasks/all(资源),渲染成表格再分析。工具负责"写"、资源负责"读"、提示词负责"按套路启动"——三者各司其职,这正是 MCP 设计的精妙处,也恰恰是只懂工具的人体会不到的部分。
与高层框架的取舍也值得说清:FastMCP(装饰器风格,类似 FastAPI)写起来更短,适合"工具为主、快速出活"的场景;底层 Server 类代码更长,但你能精确控制能力声明、URI 匹配逻辑、prompt 消息组装,适合需要把三原语都精细打磨的产品级服务器。本教程选底层,正是为了让你不被黑箱遮住眼睛。
flowchart TD
subgraph Host["Claude Code(宿主)"]
C["MCP Client"]
end
subgraph Srv["status-board Server(你造的)"]
H["initialize 握手\n声明 capabilities"]
T["tools/list → tools/call"]
R["resources/list → resources/read"]
P["prompts/list → prompts/get"]
ST["board.json 存储"]
end
U["用户"] -->|"输入 /standup"| Host
Host -->|"模型决定"| T
Host -->|"应用注入"| R
Host -->|"用户选中"| P
C <-->|"JSON-RPC over stdio"| H
T --> ST
R --> ST
P --> ST
H --> T
H --> R
H --> P
握手阶段 Server 依据已注册 handler 自动声明能力;运行期三类请求走各自的 handler,最终都落在 board.json。控制方分别标注在三条入边上,对应模型、应用、用户。
| 维度 | 底层 Server 类(本教程) |
高层 FastMCP |
|---|---|---|
| 写法 | 显式注册每个 handler | @mcp.tool() 装饰器自动推断 |
| 能力声明 | 你注册什么声明什么 | 自动声明全部 |
| 三原语覆盖 | 工具/资源/提示词一视同仁 | 同样支持,但更偏工具 |
| URI/消息精细控制 | 完全手写,最灵活 | 受框架约束 |
| 适合场景 | 产品级、需精控、学习协议 | 快速原型、工具为主 |
| 学习曲线 | 较陡,回报是"真懂 MCP" | 平缓 |
一句话:想快用 FastMCP,想懂用底层。两者底层都调用同一套 mcp.server,可平滑切换。
modelcontextprotocol.ioexamples/ 目录,含完整底层样例:github.com/modelcontextprotocol/python-sdkmcp dev) 做交互式调试与协议抓包list_resource_templates 动态资源、给工具加 progress 进度上报、把存储换成 SQLite——刚好衔接"FastMCP 造 SQLite 查询服务器"那条线。Server 类、handler 装饰器与 stdio 传输的参考实现mcp-server-dev 插件引导构建与 Claude Code 接入方式[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
