本教程带你用 Python 的 FastMCP 框架,从零构建一个本地文件搜索 MCP 服务器,并在真实开发链路中跑通它:写工具(Tools)、资源(Resources)、提示词(Prompts)三原语,用 MCP Inspector 调试 JSON-RPC 交互,写单元测试与端到端测试,最后接入 Claude Code 让模型真正调起你的搜索能力。适用人群:有 Python 基础、想让 Claude Code 长出"私有技能"的后端开发者与运维工程师。学完后你掌握的不是又一个域封装,而是可复用的 MCP 服务器开发流水线。
curl -LsSf https://astral.sh/uv/install.sh | shnpm i -g @anthropic-ai/claude-code 安装async/await、类型注解(type hints)知识为什么用 FastMCP 而不是直接写底层 SDK?FastMCP 1.0 在 2024 年被合并进官方 MCP Python SDK,现在由 Prefect 团队独立维护、每天下载量百万级,约 70% 的各语言 MCP 服务器跑在某个版本的 FastMCP 上。它的核心价值是:你只要写带类型注解的普通函数,JSON Schema、参数校验、协议处理全自动生成。
MCP(Model Context Protocol)本质上是"为 LLM 设计的 Web API"。一个 MCP 服务器对外暴露三类能力,称为三原语:
| 原语 | 类比 | 何时用 | 本教程实现 |
|---|---|---|---|
| Tools(工具) | POST 接口 / RPC | 模型要执行动作、产生副作用、查询动态数据 | search_files、read_snippet |
| Resources(资源) | GET 接口 / 静态文件 | 模型要读取结构化数据,只读 | search://stats 索引统计 |
| Prompts(提示词) | 可复用模板 | 固化高频交互模板,带参数注入 | summarize_hits 命中汇总模板 |
一个请求的生命周期是这样的:
sequenceDiagram
participant LLM as Claude(MCP 客户端)
participant Host as Claude Code 宿主
participant Srv as 你的 MCP 服务器(stdio)
LLM->>Host: 我要调 search_files(query="TODO")
Host->>Srv: tools/call(JSON-RPC over stdio)
Srv->>Srv: FastMCP 校验参数 → 执行函数 → Context 上报日志
Srv-->>Host: 返回结构化结果
Host-->>LLM: 把结果喂回模型
LLM-->>LLM: 基于结果继续推理/回答
记住一句话:类型注解就是契约,docstring 就是给模型看的说明书。这一条贯穿整个开发过程。
在工作目录初始化一个 uv 项目:
uv init file-search-mcp
cd file-search-mcp
uv add fastmcp
uv add --dev pytest pytest-asyncio
创建 server.py,先跑一个最小可运行的服务器:
# server.py
from fastmcp import FastMCP
mcp = FastMCP("FileSearch MCP")
@mcp.tool
def ping() -> str:
"""健康检查:返回 pong,用于验证服务器是否活着。"""
return "pong"
if __name__ == "__main__":
mcp.run() # 默认 stdio 传输
操作说明:@mcp.tool 装饰器把普通函数注册成 MCP 工具。注意这里没有写任何 JSON Schema——def ping() -> str 这个签名本身就是 schema,docstring 会自动成为工具描述。
预期结果:用 FastMCP 自带的 dev 命令打开 Inspector 验证。
uv run fastmcp dev server.py
终端会提示打开浏览器(通常是 http://localhost:6274)。在 Inspector 左侧看到 FileSearch MCP,点 List Tools 能看到 ping,点 Run Tool 返回 "pong",就说明骨架通了。
常见问题:
- 报错 command not found: fastmcp:你没装上,或没用 uv run 前缀。FastMCP 安装后提供 fastmcp CLI,但 uv 项目里必须用 uv run fastmcp ...。
- Inspector 打开是空白页:Node 版本太低,升级到 18+。
这是服务器的核心。我们要实现两个工具:按文件名 + 内容检索,以及读取某行附近的代码片段。
# server.py —— 工具部分
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.context import Context
mcp = FastMCP("FileSearch MCP")
# 允许搜索的根目录,防止路径穿越越权
ALLOWED_ROOT = Path(__file__).resolve().parent.parent
def _safe_resolve(raw_path: str) -> Path:
"""把用户输入解析成绝对路径,并校验必须落在 ALLOWED_ROOT 内。"""
p = (ALLOWED_ROOT / raw_path).resolve()
if ALLOWED_ROOT not in p.parents and p != ALLOWED_ROOT:
raise PermissionError(f"路径越界,禁止访问: {p}")
return p
@mcp.tool(
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def search_files(
query: str,
directory: str = ".",
file_glob: str = "*",
max_results: int = 50,
ctx: Context = None,
) -> list[dict]:
"""在目录下递归搜索包含 query 的文件,同时支持按文件名 glob 过滤。
Args:
query: 要搜索的关键词,大小写不敏感。
directory: 相对项目根的搜索起点目录,默认当前目录。
file_glob: 文件名通配符,如 "*.py" 只搜 Python 文件。
max_results: 最多返回多少条命中,默认 50。
Returns:
命中列表,每条含 path / line / snippet / matched_text。
"""
root = _safe_resolve(directory)
await ctx.info(f"开始搜索 query={query!r} under {root}")
hits = []
needle = query.lower()
scanned = 0
for path in root.rglob(file_glob):
if not path.is_file():
continue
scanned += 1
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except (OSError, UnicodeDecodeError):
continue
for lineno, line in enumerate(text.splitlines(), start=1):
if needle in line.lower():
hits.append({
"path": str(path.relative_to(ALLOWED_ROOT)),
"line": lineno,
"snippet": line.strip()[:200],
})
if len(hits) >= max_results:
await ctx.info(f"达到上限 {max_results},停止扫描")
return hits
await ctx.info(f"扫描 {scanned} 个文件,命中 {len(hits)} 处")
return hits
逐点说明:
annotations:readOnlyHint=True 告诉客户端"这个工具不改环境",openWorldHint=False 表示"封闭域"(只在本地文件系统内)。这是给客户端做权限/审批决策的元数据,Claude Code 会据此减少不必要的确认弹窗。ctx: Context = None:Context 通过类型注解自动注入,FastMCP 会把它从对外 schema 里剔除——客户端永远看不到 ctx 这个参数。Context 方法是异步的,所以函数用 async def。await ctx.info(...):把日志回传给客户端,在 Inspector 和 Claude Code 里都能实时看到,是调试利器。_safe_resolve:MCP 服务器一旦被模型调用,输入就是"半不可信"的。路径穿越是 MCP 工具最常见的安全坑,必须用 resolve() + 父目录校验兜底,绝不能直接拼字符串。预期结果:在 Inspector 里调 search_files(query="mcp"),右侧能看到分步的 info 日志,最终返回命中数组。
再加一个读片段的工具:
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def read_snippet(
path: str,
line_number: int,
context: int = 3,
ctx: Context = None,
) -> str:
"""读取文件中指定行号附近若干行,带行号标注,供模型精读上下文。
Args:
path: 相对项目根的文件路径。
line_number: 关注的行号(从 1 开始)。
context: 上下各取多少行,默认 3。
"""
p = _safe_resolve(path)
if not p.is_file():
raise FileNotFoundError(f"不是文件或不存在: {path}")
await ctx.debug(f"读取 {p}:{line_number} ±{context}")
lines = p.read_text(encoding="utf-8", errors="ignore").splitlines()
start = max(1, line_number - context)
end = min(len(lines), line_number + context)
return "\n".join(f"{i:>4} │ {lines[i-1]}" for i in range(start, end + 1))
资源用于"模型主动拉取的结构化数据"。我们把索引统计做成一个资源,再把单文件读取做成带参数的资源模板。
# server.py —— 资源部分
import json
@mcp.resource("search://stats")
def index_stats() -> str:
"""返回当前可搜索范围的统计信息:文件数、按扩展名分布。"""
counts = {}
total = 0
for path in ALLOWED_ROOT.rglob("*"):
if path.is_file() and ".git" not in path.parts:
ext = path.suffix or "(无扩展名)"
counts[ext] = counts.get(ext, 0) + 1
total += 1
return json.dumps({"total_files": total, "by_extension": counts}, ensure_ascii=False)
@mcp.resource("search://file/{path}")
def file_content(path: str) -> str:
"""读取指定相对路径文件的完整文本内容(只读)。"""
p = _safe_resolve(path)
return p.read_text(encoding="utf-8", errors="ignore")
关键点:
- @mcp.resource("search://stats") 里的 URI 是资源的唯一标识,懒加载——只有客户端真的去 resources/read 这个 URI 时,函数才执行。
- search://file/{path} 是资源模板(URI Template,RFC 6570 风格),{path} 是占位符。客户端列出模板后可按需实例化。
Prompt 是"带参数的对话模板"。当用户在 Claude Code 里输入 /mcp 调用 prompt 时,模板内容会作为预设消息注入对话。
# server.py —— 提示词部分
from fastmcp.prompts import Message
@mcp.prompt
def summarize_hits(query: str, top_n: int = 10) -> list[Message]:
"""生成一段提示词,要求模型把刚才的搜索命中整理成结构化摘要。
Args:
query: 用户最初搜索的关键词。
top_n: 只汇总前 N 条命中,默认 10。
"""
return [
Message(
f"我刚才用关键词「{query}」在代码库里搜索,请调用 search_files "
f"拿到前 {top_n} 条命中,然后按文件分组、给出每处的简要说明,"
f"最后用一句话总结这个词在项目里的主要用途。"
),
]
返回 list[Message] 可以构造多轮对话(指定 role="assistant"),这里返回单条用户消息即可。
Inspector 不仅是"能跑就行"的玩具,它是协议级调试器。开发时养成三轮自检习惯:
if __name__ 包住、或导入路径错)。max_results 应该是 integer 且有默认值 50;如果参数描述为空,说明你没写 docstring 的 Args: 段。ctx.info/debug 发出的全部日志,以及 JSON-RPC 的原始报文。# 两种等价启动方式
uv run fastmcp dev server.py # FastMCP 封装版
npx @modelcontextprotocol/inspector uv run server.py # 官方原版
常见问题:
- 工具列表里没有新加的工具:server.py 里有语法错误,Inspector 启动时会在终端打印 traceback,回看终端即可。
- 调用一直转圈不返回:大概率是 rglob 扫到了超大目录或 node_modules。在 _safe_resolve 后加个 if ".git" in path.parts or "node_modules" in path.parts: skip。
MCP 服务器也是普通 Python 代码,必须写测试。FastMCP 提供 Client,可以直接用内存传输连到服务器对象,省去起进程的开销,速度极快。
# test_server.py
import pytest
from fastmcp import Client
from server import mcp
@pytest.mark.asyncio
async def test_ping_tool_via_in_memory_client():
async with Client(mcp) as client:
tools = await client.list_tools()
assert any(t.name == "ping" for t in tools)
result = await client.call_tool("ping", {})
# FastMCP 2.x 返回结构化内容
assert result.data == "pong" or "pong" in str(result)
@pytest.mark.asyncio
async def test_search_files_returns_hits(tmp_path, monkeypatch):
# 造一个临时目录当搜索根,避免污染真实项目
import server
(tmp_path / "a.py").write_text("print('hello mcp world')\n")
(tmp_path / "b.txt").write_text("nothing here\n")
monkeypatch.setattr(server, "ALLOWED_ROOT", tmp_path)
async with Client(server.mcp) as client:
res = await client.call_tool(
"search_files",
{"query": "mcp", "directory": "."},
)
paths = [h["path"] for h in res.data]
assert "a.py" in paths
assert "b.txt" not in paths
@pytest.mark.asyncio
async def test_read_snippet_respects_context(tmp_path, monkeypatch):
import server
f = tmp_path / "x.py"
f.write_text("\n".join(f"line {i}" for i in range(1, 11)))
monkeypatch.setattr(server, "ALLOWED_ROOT", tmp_path)
async with Client(server.mcp) as client:
res = await client.call_tool(
"read_snippet", {"path": "x.py", "line_number": 5, "context": 2}
)
text = res.data if isinstance(res.data, str) else str(res.data)
assert "line 3" in text and "line 7" in text
assert "line 1" not in text # 超出 context 范围
运行:
uv run pytest -v
预期结果:3 个用例全绿。Client(mcp) 这种"客户端连服务器对象"的写法,本质是 FastMCP 用内存 transport 绕过了 stdio——这正是它能毫秒级跑完测试的原因。把它当作 MCP 版的"直接调函数",覆盖率可以拉得很高。
端到端测试更进一步:用真实子进程拉起服务器,再连 stdio,验证协议层没问题。FastMCP 的 Client 也能接 command:// 形式:
@pytest.mark.asyncio
async def test_e2e_subprocess():
async with Client(["uv", "run", "server.py"]) as client:
res = await client.call_tool("ping", {})
assert "pong" in str(res)
测试通过后,把服务器注册给 Claude Code。在项目根目录建 .mcp.json(项目级,可提交版本库,团队共享):
{
"mcpServers": {
"file-search": {
"command": "uv",
"args": ["run", "--directory", ".", "server.py"]
}
}
}
或者用命令行(用户级,仅本机生效):
claude mcp add file-search -- uv run server.py
claude mcp list # 确认已注册
claude mcp get file-search # 查看详情
进入 Claude Code,输入:
帮我搜一下代码库里所有用到 mcp 的地方,并读取第一处的上下文
模型会自主决定调用 search_files → 拿到命中 → 调用 read_snippet 读上下文 → 给出汇总。整个过程你不需要手写任何"先搜后读"的编排逻辑——工具描述(docstring)就是模型理解能力的说明书,写清楚它就能串起来。
常见问题:
- claude mcp list 显示 server failed to start:命令路径不对。uv run server.py 必须在项目目录执行;用 .mcp.json 时务必加 --directory 指明工作目录。
- 工具被调用但报参数错误:schema 推断和你预期不一致。回 Inspector 看 schema,多半是类型注解写成了 list(无参数化)或 dict,FastMCP 没法生成精确 schema,改成 list[dict] 即可。
把前面片段拼起来,下面是完整的 server.py,复制即可运行:
"""FileSearch MCP —— 本地文件搜索服务器(FastMCP 实现)"""
import json
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.context import Context
from fastmcp.prompts import Message
mcp = FastMCP("FileSearch MCP")
ALLOWED_ROOT = Path(__file__).resolve().parent.parent
_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "dist", "build"}
def _safe_resolve(raw_path: str) -> Path:
p = (ALLOWED_ROOT / raw_path).resolve()
if ALLOWED_ROOT not in p.parents and p != ALLOWED_ROOT:
raise PermissionError(f"路径越界: {p}")
return p
@mcp.tool
def ping() -> str:
"""健康检查,返回 pong。"""
return "pong"
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def search_files(
query: str,
directory: str = ".",
file_glob: str = "*",
max_results: int = 50,
ctx: Context = None,
) -> list[dict]:
"""递归搜索包含 query 的文件,大小写不敏感。
Args:
query: 关键词。
directory: 相对根目录,默认当前目录。
file_glob: 文件名通配符,如 "*.py"。
max_results: 最大命中数,默认 50。
"""
root = _safe_resolve(directory)
await ctx.info(f"搜索 query={query!r} under {root}")
needle, hits = query.lower(), []
for path in root.rglob(file_glob):
if not path.is_file() or _SKIP_DIRS & set(path.parts):
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), 1):
if needle in line.lower():
hits.append({
"path": str(path.relative_to(ALLOWED_ROOT)),
"line": lineno,
"snippet": line.strip()[:200],
})
if len(hits) >= max_results:
return hits
return hits
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def read_snippet(
path: str, line_number: int, context: int = 3, ctx: Context = None
) -> str:
"""读取文件指定行号附近若干行,带行号。
Args:
path: 相对根的文件路径。
line_number: 关注行号(从 1 开始)。
context: 上下行数,默认 3。
"""
p = _safe_resolve(path)
if not p.is_file():
raise FileNotFoundError(path)
await ctx.debug(f"读取 {p}:{line_number} ±{context}")
lines = p.read_text(encoding="utf-8", errors="ignore").splitlines()
start = max(1, line_number - context)
end = min(len(lines), line_number + context)
return "\n".join(f"{i:>4} │ {lines[i-1]}" for i in range(start, end + 1))
@mcp.resource("search://stats")
def index_stats() -> str:
"""返回可搜索范围的统计:文件数 + 按扩展名分布。"""
counts, total = {}, 0
for path in ALLOWED_ROOT.rglob("*"):
if path.is_file() and not (_SKIP_DIRS & set(path.parts)):
ext = path.suffix or "(无扩展名)"
counts[ext] = counts.get(ext, 0) + 1
total += 1
return json.dumps({"total_files": total, "by_extension": counts},
ensure_ascii=False)
@mcp.resource("search://file/{path}")
def file_content(path: str) -> str:
"""读取指定相对路径文件的完整文本(只读)。"""
return _safe_resolve(path).read_text(encoding="utf-8", errors="ignore")
@mcp.prompt
def summarize_hits(query: str, top_n: int = 10) -> list[Message]:
"""生成提示词,让模型把搜索命中整理成结构化摘要。
Args:
query: 原始搜索关键词。
top_n: 汇总前 N 条,默认 10。
"""
return [Message(
f"用关键词「{query}」调 search_files 拿前 {top_n} 条命中,"
f"按文件分组说明,再用一句话总结该词在项目中的主要用途。"
)]
if __name__ == "__main__":
mcp.run()
把前面 7 步串成一条可复用的流水线——以后做任何新 MCP 服务器,照这个套路走即可:
flowchart TD
A[uv init + add fastmcp] --> B[写函数 + 类型注解 + docstring]
B --> C[加 annotations<br/>readOnly/destructive/idempotent]
C --> D[fastmcp dev 开 Inspector<br/>List→Schema→Call 三轮自检]
D --> E{协议/数据对吗?}
E -- 否 --> B
E -- 是 --> F[pytest 内存 Client<br/>单测 + 子进程 E2E]
F --> G{测试绿?}
G -- 否 --> B
G -- 是 --> H[.mcp.json / claude mcp add<br/>接入 Claude Code]
H --> I[真实对话验证<br/>模型自主串工具]
I --> J{效果达标?}
J -- 否 --> K[优化 docstring 措辞<br/>调整 annotations]
K --> I
J -- 是 --> L[🚀 完成]
style D fill:#fef3c7
style F fill:#dbeafe
style H fill:#dcfce7
注意虚线反馈环:调试反馈到代码、测试反馈到代码、真实效果反馈到 docstring/annotations——这是 MCP 开发和普通后端开发最大的区别,因为你的"接口文档"直接影响模型能不能正确用上能力。
ctx.session 或外部存储(Redis/DB)。别在函数外缓存 Context。def 工具会被丢进线程池跑,不阻塞事件循环;但 I/O 密集场景仍推荐 async def,更高效。@mcp.resource 里做写操作。需要写,就用工具。max_results 加 min(max_results, 500) 上限,对 query 做 [:200] 截断,对路径做穿越校验——这三道防线能让服务器在生产里稳得多。mcp.run(transport="http", port=8000),再配认证。uv add fastmcp==<具体版本>,别用浮点版本。MCPServer/Client 与 mcp dev Inspector 启动方式search_files 改造成支持正则表达式搜索,并新增一个 count_occurrences 工具——考察类型注解、annotations、Context 日志、单元测试四项是否都到位。[!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 等场景全覆盖。
