<!-- title: Python造MCP三原语服务器 -->
## 一句话定位

用 Python + FastMCP 从零造一个**同时暴露 Tools（工具）、Resources（资源）、Prompts（提示词）三大原语**的 MCP 服务器，接入 Claude Code 让模型读你的代码库、执行扫描、套用审查模板——一次打通 MCP 全部能力，而不是只写一个 Tool 凑数。

## 适用人群与学习目标

如果你已经能用 Claude Code 跑通官方或第三方的 MCP 服务器，但自己写时只会堆 `@mcp.tool()`，那就读这篇。学完你将掌握：

- 三大原语的职责边界与「谁触发」这一核心设计哲学
- 用装饰器注册 Tool / Resource / Prompt 的完整写法，含动态 URI 模板与 Context 进度上报
- 把服务器接入 Claude Code 并用 MCP Inspector 独立验证的闭环
- 一个可直接对真实仓库跑起来的「代码仓库分析服务器」实战项目

## 前置条件

- Python 3.10+（本文用 3.12），已装 `uv` 或 `pip`
- Claude Code CLI 已可正常登录使用
- 一个待分析的本地代码仓库（任意 Git 项目均可）
- 基础：会写普通 Python 函数，懂 JSON 与命令行

## 正文

### 步骤 0：先想清楚三大原语到底差在哪

很多人写 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()`，注册一个能力只需一行。

### 步骤 1：搭项目骨架并装依赖

```bash
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`。
- 装成全局污染环境：永远在虚拟环境里装。

### 步骤 2：注册第一个 Tool（让模型动手）

Tool 是模型可调用的函数。FastMCP 用**类型注解**自动生成参数 schema，用**docstring**作为给模型看的描述——这两者决定了模型能不能正确选对你的工具。

```python
# 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 会序列化成结构化结果。

### 步骤 3：用 Resource 暴露只读数据（让模型看见）

Resource 是应用层选择、模型可读取的数据。它支持**静态 URI** 和**带参数的动态模板**两种。下面同时演示两种：

```python
@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://` 协议头是自定义的，只要不与现有冲突即可，但建议语义化。

### 步骤 4：用 Prompt 提供可复用剧本

Prompt 是带参数的提示词模板，由**用户**主动插入对话。它最适合沉淀团队反复用的「提问套路」，比如代码审查、提交信息、事故复盘。

```python
@mcp.prompt()
def review_file(path: str) -> str:
    """生成针对指定文件的代码审查提示词，引导模型按固定维度出报告。"""
    return f"""请审查仓库中 {path} 文件，按以下维度输出：
1. 潜在 bug 与边界条件漏洞
2. 可读性与命名改进建议
3. 修复后的关键代码片段（diff 形式）
开始前请先用 repo://file://{path} 资源读取真实内容，不要臆测。"""
```

**与 Tool 的关键区别**：Tool 是模型在解决问题时**自己决定**调用的；Prompt 是用户在「/」斜杠菜单里**主动选**的，本质是一段预制的、参数化的人机沟通脚本。把团队代码规范塞进 Prompt，比每次手动写一大段提问高效得多。

### 步骤 5：注入 Context 做进度上报与日志

长任务里，模型（和用户）需要知道进度。FastMCP 提供 `Context` 对象，通过类型注解自动注入：

```python
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 里。

### 步骤 6：用 stdio 启动并接入 Claude Code

在文件末尾加启动入口：

```python
if __name__ == "__main__":
    mcp.run()        # 默认 stdio 传输，最适合本地编辑器集成
```

在**待分析的仓库根目录**创建 `.mcp.json`（项目级）或写入 `~/.claude.json`（用户级）：

```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。

### 步骤 7：用 MCP Inspector 独立验证

不要只靠 Claude Code 验证——它把 MCP 错误吞得很深。用官方可视化调试器独立测每个原语：

```bash
npx @modelcontextprotocol/inspector python -m repo_analyzer.server
```

浏览器打开后，在左侧 **Tools / Resources / Prompts** 三个标签分别点调用。**这是定位「到底是服务器的问题还是模型没选对工具」的黄金手段**：Inspector 里能调通、Claude 里调不通，多半是 docstring 不够清晰或 schema 有歧义。

## 实战案例：完整的代码仓库分析服务器

把上面片段合起来，就是一个可交付的实战项目——让 Claude Code 变成「懂你这个仓库」的代码分析师：

```python
# 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 服务器」该有的样子。

## 工作流与架构图

```text
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
```

下面这张时序图说明一轮「盘点技术债 + 审查文件」请求里，三种原语如何接力：

```mermaid
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: 技术债报告 + 审查意见
```

## 常见坑与排查清单

- **Inspector 能调通、Claude 不调用**：90% 是 docstring 模糊。重写 docstring，明确「何时用、参数含义、返回什么」。
- **动态 Resource 读不到**：检查 URI 模板变量名与函数参数名是否完全一致；花括号里不能有空格。
- **`/mcp` 显示 failed to connect**：先用 `python -m repo_analyzer.server` 单独跑，看有无导入错误；再确认 `.mcp.json` 的绝对路径与 `python` 在 PATH 中。
- **进度上报不显示**：用了 `Context` 的工具必须是 `async def`，否则 `await ctx.report_progress` 报错。
- **目录穿越风险**：动态 Resource 一定要做 `startswith(REPO_ROOT)` 校验，否则模型构造 `../../etc/passwd` 就能越界读文件。

## 延伸阅读

- 官方 Python SDK 与 FastMCP 文档：`gofastmcp.com` / `github.com/modelcontextprotocol/python-sdk`
- MCP 规范与快速开始：`modelcontextprotocol.io/quickstart/server`
- 把本地 stdio 服务器升级为远程 HTTP 部署（已在相关轮次覆盖，可作为进阶）

## 训练营适用性评估

| 维度 | 评估 |
|------|------|
| 适用营型 | 中级 Python 工程营 / AI 工具链开发营 / 企业内部 Claude Code 落地营 |
| 学员基础 | 会 Python 函数与类型注解，用过 Claude Code 基本功能 |
| 建议时长 | 教学 1.5 小时 + 动手 2 小时（含把自己仓库接进去） |
| 验收标准 | 服务器三原语在 Inspector 全部调通，并能在 Claude Code 完成一次「盘点技术债 + 审查文件」复合任务 |
| 可裁剪 | 只想讲 Tool 原语可在 1 小时内完成；Resource/Prompt 是本篇区分度所在，不建议砍 |

## 参考来源

1. [Build an MCP Server in Python 2026: Tools, Resources, and Prompts from Scratch](https://tutorials.technology/tutorials/build-mcp-server-python-2026.html) — 从零构建 MCP 服务器，系统覆盖三大原语
2. [How to Build MCP Servers in Python: Complete FastMCP Tutorial](https://www.firecrawl.dev/blog/fastmcp-tutorial-building-mcp-servers-python) — Firecrawl 出品的 FastMCP 完整实战，含 tools/resources/prompts
3. [FastMCP in Python: Build a Real MCP Server (2026 Guide)](https://www.danilchenko.dev/posts/fastmcp-mcp-server/) — 生产级 FastMCP 服务器，含 GitHub OAuth 等进阶
4. [MCP 官方快速开始：Server](https://modelcontextprotocol.io/quickstart/server) — Anthropic 官方协议文档与 server quickstart
5. [How to Build a Python MCP Server with FastMCP (pyrastra)](https://pyrastra.com/posts/python-mcp-server-fastmcp-tutorial/) — Tools / Resources / Prompts 与 Streamable HTTP 传输详解

> [!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 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
