本教程手把手带你用 Python + FastMCP 从零构建一个 PDF 智能处理 MCP 服务器,让 Claude Code 能够提取文本、读取元数据、全文搜索、拆分与合并 PDF。适合已了解 MCP 基本概念、想做出第一个真正"有用且可复用"自定义工具的开发者。
在开始之前,请确认你的环境满足以下条件:
uv,它是目前 MCP 生态推荐的速度更快的替代方案;用 pip 同样可以,命令等效。claude --version 有输出)。def foo(x: int) -> str)、知道虚拟环境是什么。你不需要事先精通 MCP 协议细节——本教程会按需讲解。但如果你完全没听过 MCP,建议先读一遍官方的 "Build an MCP server" 入门文档再回来。
你可能会想:让 Claude 直接写一段 pypdf 脚本处理 PDF 不就行了,为什么要专门造一个 MCP 服务器?
核心区别在于稳定性与复用性。
pypdf vs PyMuPDF),可能把页码搞混,可能在多文件场景下手忙脚乱。更关键的是,这些"能力"无法跨会话沉淀。一句话总结:MCP 把"一次性魔法"变成"可复用的基础设施"。这正是本教程要教的核心思维——不是写脚本,而是造工具。
在动手前,用 30 秒厘清 MCP 的三个核心概念,它们决定了你的服务器长什么样:
| 原语 | 作用 | 触发方式 | 本教程中的例子 |
|---|---|---|---|
| Tool(工具) | 模型可以"调用执行"的函数,有副作用,返回结果 | 模型主动决定调用 | extract_text、search_pdf |
| Resource(资源) | 模型可以"读取"的数据,类似文件,只读为主 | 客户端/用户引用 URI | pdf://report/meta |
| Prompt(提示模板) | 预置的提示词模板,带参数 | 用户从菜单选择 | "总结这份 PDF" |
一个设计良好的 MCP 服务器通常三者结合:Tool 干活,Resource 暴露数据,Prompt 引导用法。本教程会三个都覆盖,让你做出"完整"而非"半成品"的服务器。
在安装前,必须了解 SDK 当前状态,避免踩坑:
FastMCP 类,pip install "mcp[cli]" 默认装到的就是这个。本教程基于 v1.x,因为它最稳定、文档最全。2.0.0bN),为支持 2026-07-28 的 MCP 规范发布做重大重构,类名改为 MCPServer,每个预发布版本之间可能有破坏性变更,官方明确说"不要用于生产"。pyproject.toml 依赖 mcp,请加上上限约束 mcp>=1.27,<2,避免将来 stable v2 发布时自动升级把你的代码搞崩。本教程使用 v1.x 的 FastMCP API。等 v2 stable 后迁移成本很低,迁移指南官方会提供。
先建一个干净的项目目录。打开终端:
# 1. 创建项目目录
mkdir pdf-mcp && cd pdf-mcp
# 2. 用 uv 初始化(没有 uv 可跳过,手动建文件)
uv init --python 3.11
# 3. 安装依赖
uv add "mcp[cli]>=1.27,<2" pypdf
为什么选 pypdf? 它是纯 Python 实现、无系统级二进制依赖、跨平台、Apache 协议,最适合做教学和通用工具。生产环境若需更高性能或图片提取,可换 PyMuPDF(pip install pymupdf),接口稍异但思路一致。
安装完成后,确认版本:
uv run python -c "import mcp, pypdf; print('mcp', mcp.__version__); print('pypdf', pypdf.__version__)"
预期结果:打印出 mcp 1.x.x 和 pypdf 5.x.x 之类的版本号,无报错即安装成功。
常见问题:
uv: command not found:用 pip install uv 或参考 uv 官方安装脚本。也可以全程用 pip + venv 替代。pip install --upgrade pip)后重试;pypdf 是纯 Python,几乎不会装不上。在项目根目录创建 server.py,从最简单的"提取 PDF 文本"开始:
# server.py
from pathlib import Path
from pypdf import PdfReader
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("pdf-tools")
@mcp.tool()
def extract_text(pdf_path: str, max_pages: int = 0) -> str:
"""提取 PDF 的文本内容。
Args:
pdf_path: PDF 文件的绝对路径或相对路径。
max_pages: 最多提取的页数。0 表示提取全部。
大文件建议传 5~20,避免一次性返回过多文本。
"""
path = Path(pdf_path).expanduser()
if not path.exists():
return f"错误:文件不存在 {path}"
reader = PdfReader(str(path))
pages = reader.pages[:max_pages] if max_pages > 0 else reader.pages
chunks = []
for i, page in enumerate(pages, start=1):
text = page.extract_text() or ""
chunks.append(f"--- 第 {i} 页 ---\n{text.strip()}")
return "\n\n".join(chunks)
if __name__ == "__main__":
mcp.run(transport="stdio")
关键点解析:
FastMCP("pdf-tools") 创建服务器实例,"pdf-tools" 是服务器名,会显示在客户端里。@mcp.tool() 把普通 Python 函数变成 MCP 工具。类型注解就是参数 schema,docstring 就是工具说明——你不需要手写 JSON Schema,FastMCP 自动从注解和文档字符串生成。mcp.run(transport="stdio") 用 stdio 传输启动,这是 Claude Code 接入本地 MCP 服务器的标准方式。预期结果:先别急着接 Claude,我们用 MCP Inspector 单测。
MCP Inspector 是官方提供的可视化调试工具,能在不接 Claude 的情况下手动调用你的工具,是开发期最省时间的利器。
uv run mcp dev server.py
这会启动一个本地 Web 界面(通常是 http://localhost:5173),自动加载你的 server.py。
操作步骤:
pdf-tools 服务器已连接,工具列表里出现 extract_text。extract_text,右侧出现参数表单(自动从类型注解生成:pdf_path 文本框 + max_pages 数字框)。常见问题:
server.py 有语法错误或导入失败。回到终端看 Inspector 的日志输出,通常会直接打印 Python 报错。extract_text 返回空。这是 PDF 本身的问题,需要 OCR 才能解决,后面延伸阅读会提到。这条调试链路务必养成习惯:写一个工具 → Inspector 单测 → 再写下一个。比"写完一堆代码再接 Claude 排查"高效十倍。
现在一次性补上元数据、全文搜索、拆分、合并四个工具,构成一个完整的能力闭环。在 server.py 中 extract_text 之后追加:
@mcp.tool()
def get_metadata(pdf_path: str) -> str:
"""读取 PDF 元数据(标题、作者、页数、创建时间等)。"""
path = Path(pdf_path).expanduser()
if not path.exists():
return f"错误:文件不存在 {path}"
reader = PdfReader(str(path))
meta = reader.metadata or {}
fields = {
"页数": len(reader.pages),
"标题": meta.get("/Title", "(无)"),
"作者": meta.get("/Author", "(无)"),
"主题": meta.get("/Subject", "(无)"),
"创建时间": meta.get("/CreationDate", "(无)"),
"是否加密": reader.is_encrypted,
}
return "\n".join(f"{k}: {v}" for k, v in fields.items())
@mcp.tool()
def search_pdf(pdf_path: str, keyword: str) -> str:
"""在 PDF 中全文搜索关键词,返回所在页码和上下文片段。"""
path = Path(pdf_path).expanduser()
if not path.exists():
return f"错误:文件不存在 {path}"
reader = PdfReader(str(path))
hits = []
for i, page in enumerate(reader.pages, start=1):
text = page.extract_text() or ""
low = text.lower()
kw = keyword.lower()
start = 0
while True:
idx = low.find(kw, start)
if idx == -1:
break
snippet = text[max(0, idx - 30): idx + len(keyword) + 30]
hits.append(f"第 {i} 页: ...{snippet}...")
start = idx + len(keyword)
if len(hits) >= 50: # 防止超大输出
hits.append("(命中过多,仅显示前 50 条)")
break
return "\n".join(hits) if hits else f"未找到关键词「{keyword}」"
@mcp.tool()
def split_pdf(pdf_path: str, start_page: int, end_page: int, output_path: str) -> str:
"""把 PDF 的指定页码范围拆分成一个新文件。
Args:
start_page: 起始页码,从 1 开始。
end_page: 结束页码(含)。
output_path: 输出 PDF 路径。
"""
path = Path(pdf_path).expanduser()
if not path.exists():
return f"错误:源文件不存在 {path}"
from pypdf import PdfWriter
reader = PdfReader(str(path))
total = len(reader.pages)
if start_page < 1 or end_page > total or start_page > end_page:
return f"错误:页码范围无效(共 {total} 页)"
writer = PdfWriter()
for p in range(start_page - 1, end_page):
writer.add_page(reader.pages[p])
out = Path(output_path).expanduser()
out.parent.mkdir(parents=True, exist_ok=True)
with open(out, "wb") as f:
writer.write(f)
return f"已生成 {out}(第 {start_page}-{end_page} 页,共 {end_page - start_page + 1} 页)"
@mcp.tool()
def merge_pdfs(pdf_paths: list[str], output_path: str) -> str:
"""把多个 PDF 按顺序合并为一个文件。
Args:
pdf_paths: 源 PDF 路径列表,按合并顺序排列。
output_path: 合并后的输出路径。
"""
from pypdf import PdfWriter
writer = PdfWriter()
resolved = [Path(p).expanduser() for p in pdf_paths]
for p in resolved:
if not p.exists():
return f"错误:文件不存在 {p}"
for p in resolved:
writer.append(str(p))
out = Path(output_path).expanduser()
out.parent.mkdir(parents=True, exist_ok=True)
with open(out, "wb") as f:
writer.write(f)
return f"已合并 {len(resolved)} 个文件到 {out}"
设计要点:
list[str] 这种容器类型注解也能被 FastMCP 正确识别为"字符串数组"参数,模型会传入 JSON 数组。mkdir(parents=True, exist_ok=True),避免目录不存在报错。search_pdf 限制最多返回 50 条命中并给出提示——这是 MCP 工具设计的"防过载"最佳实践,避免一次性把几十页命中结果灌进模型上下文。写完后回 Inspector 把这四个工具都点一遍,确认行为符合预期再继续。
工具是"干活",资源是"摆数据"。我们把 PDF 的元数据暴露成一个可被引用的资源,让客户端能像读文件一样读取它,而不必每次调用工具:
# 在 server.py 中,记录"当前工作目录"作为可被操作 PDF 的根
WORKDIR = Path.home() / "pdf-workspace"
WORKDIR.mkdir(parents=True, exist_ok=True)
@mcp.resource("pdf://list")
def list_workspace_pdfs() -> str:
"""列出工作区中所有 PDF 文件及其页数。"""
lines = []
for pdf in sorted(WORKDIR.glob("*.pdf")):
try:
n = len(PdfReader(str(pdf)).pages)
except Exception:
n = "?"
lines.append(f"- {pdf.name}({n} 页)")
return "\n".join(lines) if lines else "工作区暂无 PDF"
@mcp.resource("pdf://{filename}/meta")
def get_pdf_resource_meta(filename: str) -> str:
"""通过 URI 读取指定 PDF 的元数据,例如 pdf://report.pdf/meta。"""
target = (WORKDIR / filename).resolve()
# 防路径穿越:必须落在工作区内
if not str(target).startswith(str(WORKDIR)):
return "错误:越权访问"
return get_metadata(str(target))
这里有两个进阶知识点:
pdf://{filename}/meta):花括号表示动态参数,客户端可以传入 pdf://report.pdf/meta 来读取"report.pdf 的元数据"。这是 MCP 资源的高级用法——一个函数服务一族资源。{filename} 理论上可能被传入 ../../etc/passwd。代码里用 .resolve() 后检查是否仍在 WORKDIR 下,是所有"文件型 MCP 服务器"必须做的安全闸。最后补一个提示模板,让用户能一键触发"总结这份 PDF"的标准流程:
@mcp.prompt()
def summarize_pdf(pdf_path: str) -> str:
"""生成"总结一份 PDF"的提示词,引导模型先取元数据再分页提取。"""
return f"""请帮我总结这份 PDF:{pdf_path}
建议流程:
1. 先调用 get_metadata 了解页数和基本信息;
2. 调用 extract_text 分批提取文本(每次不超过 10 页);
3. 如果是长文档,用 search_pdf 定位关键章节;
4. 最后给出结构化摘要:核心结论、关键数据、章节大纲。"""
Prompt 的价值在于固化最佳实践:不依赖用户记得"先看元数据再分页",模板直接把流程写进去,模型会照着执行。
服务器开发完成,现在把它接到 Claude Code。在你的 ~/.claude.json(或项目级 .mcp.json)中添加服务器配置。推荐用命令行一键添加:
claude mcp add pdf-tools -- uv --directory /绝对路径/pdf-mcp run server.py
注意几个要点:
--directory 后面必须用绝对路径,否则 Claude Code 找不到你的项目目录。uv run 而非 python server.py,确保用对虚拟环境和依赖。-- 分隔符之后是真正的启动命令。验证接入成功:
claude mcp list
应看到 pdf-tools 出现在列表中且状态为 connected。然后在 Claude Code 对话里输入:
/mcp
能看到 pdf-tools 下的 5 个工具、2 个资源、1 个提示词。
常见问题:
uv 管理的环境里。手动跑一遍 uv --directory /路径/pdf-mcp run server.py,看是否有 Python 报错。~ 路径:用 %USERPROFILE% 或绝对路径,不要用波浪线。现在用一个端到端的真实场景,体验这套工具的威力。假设 ~/pdf-workspace/annual-report.pdf 是一份 80 页的年度技术报告,你想快速了解它讲了什么。
第一步:自然语言提问
在 Claude Code 中直接说:
"帮我了解 pdf-workspace 里的 annual-report.pdf 大概讲了什么,重点找 5G 核心网相关内容。"
第二步:观察 Claude 自主编排
Claude 会自动规划调用序列(这就是 MCP 的魅力——模型自己决定怎么组合工具):
pdf://list 确认文件存在和页数。get_metadata 看基本信息。search_pdf 搜 "5G"、"核心网" 等关键词,定位相关页码。extract_text 精读命中的那几页(max_pages 控制范围)。第三步:扩展操作
你继续追问:
"把第 12 到 18 页(核心网架构图所在)单独拆出来发我,再和另外那份 nfv.pdf 合并成一份参考资料。"
Claude 会调 split_pdf + merge_pdfs 完成文件操作,并告诉你输出路径。整个过程你没有写一行 Python,全部由 MCP 工具完成——而这些能力从此永久存在于你的 Claude Code 里,下次任何 PDF 都能直接处理。
对比一下:如果没有 MCP,你要么手动用软件操作,要么让 Claude 现写脚本、现调库、现试错。前者慢,后者不可复现。MCP 把它变成了一个"开箱即用、行为稳定的能力"。
下面这张图展示了从开发到使用的完整链路:
flowchart LR
subgraph 开发期
A[server.py<br/>FastMCP] -->|@mcp.tool<br/>@mcp.resource<br/>@mcp.prompt| B[5工具+2资源+1提示]
B --> C[MCP Inspector<br/>可视化单测]
C -->|通过| A
end
subgraph 接入期
B --> D[claude mcp add]
D --> E["~/.claude.json<br/>注册配置"]
end
subgraph 使用期
F[用户自然语言提问] --> G[Claude Code]
E --> G
G -->|自动编排调用| B
B -->|操作| H[(本地 PDF 文件)]
H --> B
B -->|结构化结果| G
G --> F
end
把基础版跑通后,可以从这几个方向迭代:
pypdf 对复杂排版、表格提取较弱。换 import fitz(PyMuPDF)能拿到更干净的文本、支持图片提取,速度也快几倍。接口封装在 extract_text 内部,对外工具签名不变。extract_text 返回空的页,自动调 pytesseract 做图像 OCR。这是扫描合同、老档案的刚需。mcp.run(transport="stdio") 改成 HTTP 传输,部署到内网,让团队所有人共享同一个 PDF 工具服务器,而非各自本地装。注意加鉴权。extract_text 结果按文件 hash 缓存,避免重复解析几百页的大文件。[!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 等场景全覆盖。
