用 Python + FastMCP 从零造一个同时暴露 Tools(工具)、Resources(资源)、Prompts(提示词)三大原语的 MCP 服务器,接入 Claude Code 让模型读你的代码库、执行扫描、套用审查模板——一次打通 MCP 全部能力,而不是只写一个 Tool 凑数。
如果你已经能用 Claude Code 跑通官方或第三方的 MCP 服务器,但自己写时只会堆 @mcp.tool(),那就读这篇。学完你将掌握:
uv 或 pip很多人写 MCP 服务器翻车,不是代码写错,而是把所有东西都做成 Tool。MCP 之所以分成三类原语,是因为它们的控制权归属不同——这是协议设计的第一性原理:
| 原语 | 谁决定调用 | 类比 HTTP | 有副作用吗 | 典型场景 |
|---|---|---|---|---|
| Tools | 模型自主决定 | POST | 通常有 | 查数据库、发请求、跑脚本 |
| Resources | 应用/用户选择 | GET | 无 | 读文件、看 schema、拉配置 |
| Prompts | 用户主动选 | 模板 | 无 | 代码审查、提交规范、Bug 复盘 |
记住这个心智模型:Tool 让模型「动手」,Resource 让模型「看见」,Prompt 给模型「剧本」。一个设计良好的 MCP 服务器应该三者分工,而不是用 Tool 模拟一切(比如用 get_file 这种 Tool 去读静态数据,就是典型的反模式——那本该是 Resource 的活)。
FastMCP 3.x 把这三类原语都收敛成装饰器:@mcp.tool()、@mcp.resource()、@mcp.prompt(),注册一个能力只需一行。
mkdir repo-analyzer-mcp && cd repo-analyzer-mcp
uv init --no-readme # 或 python -m venv .venv && source .venv/bin/activate
uv add "fastmcp>=2.3" # pip install "fastmcp>=2.3"
建立如下目录结构:
repo-analyzer-mcp/
├── pyproject.toml
└── repo_analyzer/
└── server.py
预期结果:uv add 成功后,pyproject.toml 会出现 fastmcp 依赖。FastMCP 是官方 Python SDK 之上的高层封装,是目前写 MCP 服务器最省事的姿势——比手写 JSON-RPC 样板少 80% 代码。
常见问题:
- 报 No module named 'fastmcp':确认激活了虚拟环境,旧文里常见的 from mcp.server.fastmcp import FastMCP 是 1.x 写法,2.x 起独立成包,直接 from fastmcp import FastMCP。
- 装成全局污染环境:永远在虚拟环境里装。
Tool 是模型可调用的函数。FastMCP 用类型注解自动生成参数 schema,用docstring作为给模型看的描述——这两者决定了模型能不能正确选对你的工具。
# repo_analyzer/server.py
from fastmcp import FastMCP
from pathlib import Path
import os, json
mcp = FastMCP("repo-analyzer")
REPO_ROOT = Path(os.environ.get("REPO_ROOT", ".")).resolve()
@mcp.tool()
def count_lines(pattern: str = "**/*.py") -> dict:
"""统计仓库内匹配 pattern 的文件总数与代码总行数。
pattern 例如 "**/*.py" 或 "src/**/*.ts"。"""
files = total = 0
for p in REPO_ROOT.glob(pattern):
if p.is_file():
total += sum(1 for _ in p.open(encoding="utf-8", errors="ignore"))
files += 1
return {"files": files, "lines": total}
要点:函数名就是工具名,docstring 务必写清楚「这个工具干什么、参数怎么填」,模型据此决定是否调用。返回值用 dict/list 而非手拼字符串,FastMCP 会序列化成结构化结果。
Resource 是应用层选择、模型可读取的数据。它支持静态 URI 和带参数的动态模板两种。下面同时演示两种:
@mcp.resource("repo://manifest")
def manifest() -> str:
"""仓库清单:顶层目录与文件,供模型快速了解结构。"""
entries = [str(p.relative_to(REPO_ROOT)) for p in sorted(REPO_ROOT.iterdir())]
return json.dumps({"root": str(REPO_ROOT), "entries": entries}, ensure_ascii=False)
@mcp.resource("repo://file://{path}")
def read_file(path: str) -> str:
"""读取仓库内任意文本文件。{path} 是相对仓库根的路径。"""
target = (REPO_ROOT / path).resolve()
# 防目录穿越:禁止跳出仓库根
if not str(target).startswith(str(REPO_ROOT)):
raise ValueError(f"路径越界: {path}")
return target.read_text(encoding="utf-8", errors="ignore")
预期结果:客户端能看到两个资源——repo://manifest 和一个 URI 模板 repo://file://{path}。模型可以列出资源、按模板读取具体文件。
常见问题:动态模板里花括号变量名要和函数参数名一致;uri:// 协议头是自定义的,只要不与现有冲突即可,但建议语义化。
Prompt 是带参数的提示词模板,由用户主动插入对话。它最适合沉淀团队反复用的「提问套路」,比如代码审查、提交信息、事故复盘。
@mcp.prompt()
def review_file(path: str) -> str:
"""生成针对指定文件的代码审查提示词,引导模型按固定维度出报告。"""
return f"""请审查仓库中 {path} 文件,按以下维度输出:
1. 潜在 bug 与边界条件漏洞
2. 可读性与命名改进建议
3. 修复后的关键代码片段(diff 形式)
开始前请先用 repo://file://{path} 资源读取真实内容,不要臆测。"""
与 Tool 的关键区别:Tool 是模型在解决问题时自己决定调用的;Prompt 是用户在「/」斜杠菜单里主动选的,本质是一段预制的、参数化的人机沟通脚本。把团队代码规范塞进 Prompt,比每次手动写一大段提问高效得多。
长任务里,模型(和用户)需要知道进度。FastMCP 提供 Context 对象,通过类型注解自动注入:
from fastmcp import Context
@mcp.tool()
async def deep_scan(ctx: Context, pattern: str = "**/*.py") -> dict:
"""深度扫描,带实时进度上报与日志。"""
files = [p for p in REPO_ROOT.glob(pattern) if p.is_file()]
todos = fixmes = 0
for i, p in enumerate(files, 1):
await ctx.report_progress(i, len(files)) # 进度条
await ctx.info(f"已扫描 {p.name}") # 日志
for line in p.open(encoding="utf-8", errors="ignore"):
if "TODO" in line: todos += 1
if "FIXME" in line: fixmes += 1
return {"scanned": len(files), "todos": todos, "fixmes": fixmes}
要点:带 Context 参数的工具要写成 async def;ctx 还能 read_resource() 读取本服务器暴露的资源、log.warning() 打不同级别日志。不要在 docstring 里描述 ctx 参数——它是框架注入的,不该出现在给模型的 schema 里。
在文件末尾加启动入口:
if __name__ == "__main__":
mcp.run() # 默认 stdio 传输,最适合本地编辑器集成
在待分析的仓库根目录创建 .mcp.json(项目级)或写入 ~/.claude.json(用户级):
{
"mcpServers": {
"repo-analyzer": {
"command": "python",
"args": ["-m", "repo_analyzer.server"],
"env": { "REPO_ROOT": "/绝对路径/你的仓库" }
}
}
}
注意:
env.REPO_ROOT用绝对路径;python -m需要repo_analyzer/__init__.py存在(空文件即可)。若用 uv 跑,command换成uv、args前面加["run","python","-m",...]。
预期结果:在仓库目录启动 Claude Code,输入 /mcp 应看到 repo-analyzer 为 connected,并列出 repo://manifest、三个工具、一个 prompt。
不要只靠 Claude Code 验证——它把 MCP 错误吞得很深。用官方可视化调试器独立测每个原语:
npx @modelcontextprotocol/inspector python -m repo_analyzer.server
浏览器打开后,在左侧 Tools / Resources / Prompts 三个标签分别点调用。这是定位「到底是服务器的问题还是模型没选对工具」的黄金手段:Inspector 里能调通、Claude 里调不通,多半是 docstring 不够清晰或 schema 有歧义。
把上面片段合起来,就是一个可交付的实战项目——让 Claude Code 变成「懂你这个仓库」的代码分析师:
# repo_analyzer/server.py
from fastmcp import FastMCP, Context
from pathlib import Path
import os, json
mcp = FastMCP("repo-analyzer")
REPO_ROOT = Path(os.environ.get("REPO_ROOT", ".")).resolve()
# ---------- Tools:执行动作 ----------
@mcp.tool()
def count_lines(pattern: str = "**/*.py") -> dict:
"""统计匹配 pattern 的文件数与总行数。"""
files = total = 0
for p in REPO_ROOT.glob(pattern):
if p.is_file():
total += sum(1 for _ in p.open(encoding="utf-8", errors="ignore"))
files += 1
return {"files": files, "lines": total}
@mcp.tool()
def find_todos(pattern: str = "**/*.py", limit: int = 50) -> list[dict]:
"""扫描 TODO/FIXME 注释,返回文件、行号、内容。"""
hits = []
for p in REPO_ROOT.glob(pattern):
if not p.is_file():
continue
for i, line in enumerate(p.open(encoding="utf-8", errors="ignore"), 1):
if "TODO" in line or "FIXME" in line:
hits.append({"file": str(p.relative_to(REPO_ROOT)),
"line": i, "text": line.strip()})
if len(hits) >= limit:
return hits
return hits
@mcp.tool()
async def deep_scan(ctx: Context, pattern: str = "**/*.py") -> dict:
"""深度扫描,带进度上报,统计 TODO/FIXME 数量。"""
files = [p for p in REPO_ROOT.glob(pattern) if p.is_file()]
todos = fixmes = 0
for i, p in enumerate(files, 1):
await ctx.report_progress(i, len(files))
await ctx.info(f"扫描 {p.name}")
for line in p.open(encoding="utf-8", errors="ignore"):
if "TODO" in line: todos += 1
if "FIXME" in line: fixmes += 1
return {"scanned": len(files), "todos": todos, "fixmes": fixmes}
# ---------- Resources:暴露只读数据 ----------
@mcp.resource("repo://manifest")
def manifest() -> str:
"""仓库顶层清单。"""
entries = [str(p.relative_to(REPO_ROOT)) for p in sorted(REPO_ROOT.iterdir())]
return json.dumps({"root": str(REPO_ROOT), "entries": entries}, ensure_ascii=False)
@mcp.resource("repo://file://{path}")
def read_file(path: str) -> str:
"""读取仓库内文本文件。"""
target = (REPO_ROOT / path).resolve()
if not str(target).startswith(str(REPO_ROOT)):
raise ValueError("路径越界")
return target.read_text(encoding="utf-8", errors="ignore")
# ---------- Prompts:可复用剧本 ----------
@mcp.prompt()
def review_file(path: str) -> str:
"""生成针对某文件的代码审查提示词。"""
return (f"审查 {path}:\n1. bug 与边界条件\n2. 可读性改进\n"
f"3. 修复 diff\n先用 repo://file://{path} 读真实内容。")
@mcp.prompt()
def tech_debt_report() -> str:
"""生成技术债务盘点提示词。"""
return ("先调 find_todos 收集所有 TODO/FIXME,"
"再按文件聚合、按严重度排序,输出一份 Markdown 技术债报告。")
if __name__ == "__main__":
mcp.run()
端到端验证对话:接入后对 Claude Code 说——
「用 repo-analyzer 帮我盘点这个仓库的技术债,并审查 src/auth.py」
模型会:调 find_todos / deep_scan(Tool)、读 repo://file://src/auth.py(Resource)、若你用了 /review_file 斜杠命令则套用 Prompt 模板——三大原语在同一轮对话里协同工作,这就是「完整的 MCP 服务器」该有的样子。
flowchart LR
U([用户在 Claude Code]) -->|斜杠命令选 Prompt| CC[Claude Code 主机]
CC -->|JSON-RPC over stdio| SV[FastMCP 服务器]
SV --> T[Tools 执行动作]
SV --> R[Resources 读数据]
SV --> P[Prompts 给剧本]
T --> FS[(本地文件系统)]
R --> FS
T -->|ctx.report_progress| CC
subgraph 谁触发
T ~>|模型自主| CC
R ~>应用/用户选择
P ~>|用户主动|
end
下面这张时序图说明一轮「盘点技术债 + 审查文件」请求里,三种原语如何接力:
sequenceDiagram
participant U as 用户
participant CC as Claude Code
participant S as FastMCP 服务器
participant FS as 文件系统
U->>CC: 盘点技术债并审查 auth.py
CC->>S: tools/call find_todos
S->>FS: glob + 读取
FS-->>S: 命中列表
S-->>CC: TODO/FIXME 数组
CC->>S: resources/read repo://file://src/auth.py
S->>FS: 读取文件
FS-->>S: 源码
S-->>CC: 文件内容
CC->>S: tools/call deep_scan (带 Context)
S-->>CC: report_progress (n/total)
CC->>CC: 套用 review_file 提示词模板组织报告
CC-->>U: 技术债报告 + 审查意见
/mcp 显示 failed to connect:先用 python -m repo_analyzer.server 单独跑,看有无导入错误;再确认 .mcp.json 的绝对路径与 python 在 PATH 中。Context 的工具必须是 async def,否则 await ctx.report_progress 报错。startswith(REPO_ROOT) 校验,否则模型构造 ../../etc/passwd 就能越界读文件。gofastmcp.com / github.com/modelcontextprotocol/python-sdkmodelcontextprotocol.io/quickstart/server| 维度 | 评估 |
|---|---|
| 适用营型 | 中级 Python 工程营 / AI 工具链开发营 / 企业内部 Claude Code 落地营 |
| 学员基础 | 会 Python 函数与类型注解,用过 Claude Code 基本功能 |
| 建议时长 | 教学 1.5 小时 + 动手 2 小时(含把自己仓库接进去) |
| 验收标准 | 服务器三原语在 Inspector 全部调通,并能在 Claude Code 完成一次「盘点技术债 + 审查文件」复合任务 |
| 可裁剪 | 只想讲 Tool 原语可在 1 小时内完成;Resource/Prompt 是本篇区分度所在,不建议砍 |
[!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 等场景全覆盖。
