# Round 3 教程：Claude Code MCP 服务器开发

已基于官方 FastMCP v3 文档核实 API（资源模板、工具注解、结构化输出），选定本轮主题：**用 Resource Template + Tool Annotations 构建日志检索 MCP**——这是已覆盖主题中尚未触及的两个新原语组合。下面是完整教程正文。

---

<!-- title: 造日志检索MCP：动态资源配工具注解 -->

## 概述

本教程带你用 FastMCP v3 从零构建一个名为 `logscope` 的日志检索 MCP 服务器，重点掌握两个常被忽略却至关重要的新原语：**Resource Templates（资源模板）**让模型按 URI 模式动态寻址海量数据；**Tool Annotations（工具注解）**用 `readOnlyHint` / `destructiveHint` 给模型划安全红线。配套还会用到**结构化输出（Structured Output）**，让工具返回可被模型可靠解析的强类型结果。适合已写过基础 MCP 工具、想做出"生产级、安全可控"MCP 服务的开发者。

## 前置条件

- 已安装 Python 3.11+ 与 Claude Code（`claude --version` 可用）
- 了解 MCP 的"工具/资源/提示词"三原语（不熟可先补 Python 三原语基础教程）
- 一台能联网的终端，用于 `pip install fastmcp`
- 阅读完本文后能独立解释：为什么 `destructiveHint` 是"提示"而非"强制"

## 三个你必须补上的新原语

写过入门 MCP 的人都知道 `@mcp.tool` 把函数变成模型可调用的工具。但只靠工具原语，做不出"安全、可发现、可被模型主动浏览"的服务。下面三个原语是分水岭。

**① Resource Templates——给数据一个可寻址的 URI 模式**

普通 Resource（如 `data://config`）是静态的，一个 URI 对应一份固定数据。当你有成千上万条目（每个用户、每天日志、每个仓库），逐个注册不现实。Resource Template 用 RFC 6570 的 URI 模板语法 `{参数名}`，让一个注册点派生出无数资源：

```
@mcp.resource("repos://{owner}/{repo}/info")
```

客户端请求 `repos://PrefectHQ/fastmcp/info` 时，FastMCP 自动把 `owner=PrefectHQ`、`repo=fastmcp` 注入函数参数并执行。官方文档原话："Resource templates generate a new resource for each unique set of parameters, which means that resources can be dynamically created on-demand."模板支持多参数、通配符路径参数（`{path*}`）和 query 参数，且**与普通资源共享 name/description/mime_type/tags/annotations 配置**。

**② Tool Annotations——给模型和客户端的安全提示**

工具注解不是描述文字，而是一组布尔/对象元数据，声明工具的副作用性质。FastMCP v3 的 `ToolAnnotations` 属性如下（摘自官方 Tools 文档）：

| 属性 | 含义 |
|---|---|
| `title` | 人类可读标题 |
| `readOnlyHint` | 为真表示工具不修改环境（纯读） |
| `destructiveHint` | 为真表示可能产生破坏性副作用（删文件、清数据） |
| `idempotentHint` | 为真表示相同参数重复调用无额外副作用，可安全重试 |
| `openWorldHint` | 为真表示会与"开放世界"的外部实体交互 |
| `meta` | 透传给客户端的自定义元数据 |

注意官方强调：这些是 **hints, not guarantees**——服务端声明，客户端决定如何对待。Claude Code 等客户端会据此决定是否弹窗确认。这是"权限护栏"在协议层的标准化落地。

**③ Structured Output——返回强类型而非裸文本**

给工具加返回类型注解（如 `-> SearchResult`，其中 `SearchResult` 是 Pydantic 模型），FastMCP 会自动生成 `output_schema` 并把结果作为结构化 JSON 返回。模型拿到的是带字段语义的结果对象（`total`、`hits[].line`），而不是需要再解析的字符串，调用准确率显著提升。

理解这三点后，下面进入实战。

## 步骤 1：搭好运行环境

创建项目目录并安装依赖：

```bash
mkdir -p ~/logscope-mcp && cd ~/logscope-mcp
python3 -m venv .venv && source .venv/bin/activate
pip install "fastmcp>=2.10" pydantic
python3 -c "import fastmcp; print(fastmcp.__version__)"
```

**预期结果**：最后一行打印 FastMCP 版本号（v2.10+ 或 v3.x 均可）。

**常见问题**：
- 报 `No module named fastmcp`：确认激活了 `.venv`（`which python` 应指向项目内）。
- 国内网络慢：`pip install -i https://pypi.tuna.tsinghua.edu.cn/simple "fastmcp>=2.10"`。

## 步骤 2：用 Resource Template 暴露按日日志

新建 `logscope_mcp.py`，先把"应用+日期"两个维度的日志做成可寻址资源：

```python
import json
from pathlib import Path
from fastmcp import FastMCP

mcp = FastMCP(name="logscope")
LOG_DIR = Path.home() / ".logscope" / "logs"

# 索引资源：列出所有应用名，供模型先"看目录"
@mcp.resource("logs://index")
def log_index() -> str:
    """列出当前可被检索的所有应用名。"""
    if not LOG_DIR.exists():
        return json.dumps({"apps": []}, ensure_ascii=False)
    apps = sorted(p.name for p in LOG_DIR.iterdir() if p.is_dir())
    return json.dumps({"apps": apps}, ensure_ascii=False)

# 资源模板：任意 app + date 组合都按需生成一个资源
@mcp.resource("logs://{app}/{date}")
def read_log(app: str, date: str) -> str:
    """读取某应用在某天的完整日志。URI 示例: logs://auth/2026-07-04"""
    p = LOG_DIR / app / f"{date}.log"
    if not p.exists():
        raise ValueError(f"日志不存在: logs://{app}/{date}")
    return p.read_text(encoding="utf-8")

if __name__ == "__main__":
    mcp.run()
```

**关键点解读**：
- `logs://{app}/{date}` 里的 `{app}`、`{date}` 占位符与函数参数同名，FastMCP 自动注入。
- 客户端通过 `resources/templates/list` 发现这个模板，再对具体 URI（如 `logs://auth/2026-07-04`）发起 `resources/read`，函数才被**惰性执行**。
- 模型可以"先读 `logs://index` 再读具体某天"——这就是动态可发现性。

**常见问题**：
- 模板没被识别：检查 URI 里花括号前后**没有多余空格**（HTML 文档里看起来有空格是渲染产物，真实代码须写 `logs://{app}/{date}`）。
- 想匹配多级路径：用通配符 `logs://{app}/{path*}`，`path*` 会捕获剩余整段路径。

## 步骤 3：加一个"只读 + 结构化输出"的搜索工具

资源负责"喂全文"，但全文太长会爆上下文。补一个精准检索工具，返回结构化结果：

```python
from pydantic import BaseModel, Field

class LogHit(BaseModel):
    line: int = Field(description="命中日志的行号，从 1 开始")
    text: str = Field(description="命中行的原文")

class SearchResult(BaseModel):
    app: str = Field(description="应用名")
    date: str = Field(description="日期 YYYY-MM-DD")
    keyword: str = Field(description="检索关键字")
    total: int = Field(description="命中条数")
    hits: list[LogHit] = Field(description="命中明细，最多 50 条")

@mcp.tool(annotations={"readOnlyHint": True, "idempotentHint": True})
def search_log(app: str, date: str, keyword: str) -> SearchResult:
    """在某应用某天的日志中按关键字检索，返回带行号的命中结果。"""
    p = LOG_DIR / app / f"{date}.log"
    hits: list[LogHit] = []
    if p.exists():
        for i, line in enumerate(
            p.read_text(encoding="utf-8").splitlines(), start=1
        ):
            if keyword.lower() in line.lower():
                hits.append(LogHit(line=i, text=line))
            if len(hits) >= 50:
                break
    return SearchResult(
        app=app, date=date, keyword=keyword, total=len(hits), hits=hits
    )
```

**关键点解读**：
- `-> SearchResult` 让 FastMCP 自动生成 `output_schema`，模型在工具列表里就能看到返回字段及其描述（`description` 会进 schema），调用更准。
- `annotations={"readOnlyHint": True, "idempotentHint": True}` 告诉客户端：这是纯读、可重试。在 Claude Code 较宽松的权限模式下，这类工具通常可免确认直接跑。
- 限定最多 50 条（`if len(hits) >= 50: break`）是"防上下文爆炸"的工程素养——工具返回过大照样会撑爆会话。

**常见问题**：
- 想用官方对象而非 dict：`from fastmcp.tools import ToolAnnotations` 后 `annotations=ToolAnnotations(readOnlyHint=True)`，二者等价。
- 模型不按结构解析：确认返回的是 Pydantic 模型实例（不是 `dict`），FastMCP 才会生成结构化 schema。

## 步骤 4：加一个"破坏性"工具，看注解如何变成护栏

现在加一个清空日志的危险操作，故意标 `destructiveHint`，观察客户端如何"拦截"它：

```python
@mcp.tool(annotations={"destructiveHint": True, "idempotentHint": True})
def purge_log(app: str, date: str) -> dict:
    """清空某应用某天的日志内容（危险：不可恢复，会触发客户端二次确认）。"""
    p = LOG_DIR / app / f"{date}.log"
    if not p.exists():
        return {"purged": False, "reason": "not_found"}
    p.write_text("", encoding="utf-8")
    return {"purged": True, "path": str(p), "bytes_cleared": p.stat().st_size}
```

**为什么 `idempotentHint` 也为真**：清空后再清空还是空，重复调用无额外副作用——符合幂等定义，方便客户端在确认后安全重试。

**注解的真正威力**：`destructiveHint=True` 会让 Claude Code 在调用前**弹窗要求你确认**，即便你处于较宽松的权限模式。这就是"在协议层而非项目层做权限"的好处——换任何尊重注解的 MCP 客户端，护栏都跟着走。

## 步骤 5：注册到 Claude Code 并验证

准备一份种子日志，方便看到效果：

```bash
mkdir -p ~/.logscope/logs/auth ~/.logscope/logs/payment
printf '2026-07-04 09:01 login ok user=alice\n2026-07-04 09:03 login FAIL user=bob reason=badpwd\n2026-07-04 09:10 token refresh FAIL user=alice\n' > ~/.logscope/logs/auth/2026-07-04.log
printf '2026-07-04 09:05 pay ok order=1001 amount=99\n2026-07-04 09:06 pay FAIL order=1002 reason=insufficient_funds\n' > ~/.logscope/logs/payment/2026-07-04.log
```

把服务器注册进 Claude Code（用项目级 `.mcp.json`，便于团队共享）：

```json
{
  "mcpServers": {
    "logscope": {
      "command": "/home/gprshome/logscope-mcp/.venv/bin/python",
      "args": ["/home/gprshome/logscope-mcp/logscope_mcp.py"]
    }
  }
}
```

> 提示：`command` 尽量写 venv 内 python 的**绝对路径**，避免 Claude Code 启动子进程时找不到激活的虚拟环境。也可用 CLI 注册：`claude mcp add logscope -- /home/gprshome/logscope-mcp/.venv/bin/python /home/gprshome/logscope-mcp/logscope_mcp.py`。

启动 `claude`，先确认服务上线：

```
/mcp
```

应看到 `logscope` 处于 connected，并列出 `read_log`、`search_log`、`purge_log` 三个工具与 `logs://index` 资源及 `logs://{app}/{date}` 模板。

**预期结果**：在该会话里对模型说"帮我找出 auth 应用 2026-07-04 所有含 FAIL 的日志"，模型会调用 `search_log`（只读、免确认）并返回结构化命中（行号 2、3）。若让它"清空 payment 那天的日志"，调用 `purge_log` 时会**弹出确认**——这就是 `destructiveHint` 生效的肉眼证据。

**常见问题**：
- `/mcp` 显示连接失败：在项目目录运行 `python logscope_mcp.py` 单独看报错；通常是依赖没装在该 venv 或路径写错。
- 模型只调工具不读资源：正常现象。资源需要客户端把模板暴露给模型；Claude Code 中可手动 `@logs://index` 附加，或让模型"先列出应用"。

## 完整工作流图解

```mermaid
flowchart TD
    U[用户: 找出今天的 FAIL 日志] --> M[Claude Code / 模型]
    M -->|resources/templates/list| S[logscope MCP 服务]
    S -->|暴露模板 logs://app/date| M
    M -->|read logs://index| S
    S --> LOG[(~/.logscope/logs/)]
    M -->|"call search_log readOnly=True"| S
    S -->|扫描文件,返回 SearchResult| M
    M -->|结构化命中行号| U
    U -->|顺带: 清空 payment 日志| M
    M -->|"call purge_log destructive=True"| G{客户端确认门禁}
    G -->|destructiveHint 触发| CONFIRM[弹窗要求用户确认]
    CONFIRM -->|同意| S
    CONFIRM -->|拒绝| M
    S -->|清空文件| LOG
```

图里最关键的是 `destructiveHint` → "客户端确认门禁"那一条：护栏发生在**客户端侧**，服务端只是声明。这也意味着——如果你写的客户端不读注解，护栏就不会自动出现，需在客户端实现相应逻辑。

## 常见问题汇总

| 现象 | 原因 | 解决 |
|---|---|---|
| 资源模板没出现 | URI 占位符与参数名不一致，或有多余空格 | 严格写成 `logs://{app}/{date}`，花括号紧贴 |
| 工具返回被模型当成纯文本 | 返回了 `dict`/`str` 而非 Pydantic 模型 | 用 BaseModel 作为返回类型，触发结构化 schema |
| `purge_log` 没让确认 | 客户端未实现注解读取 / 当前权限模式放行所有 | 在更严格权限模式测试，或确认客户端支持注解 |
| 单条日志太大撑爆上下文 | 工具/资源返回全文 | 工具侧加分页与 `max_results`，资源侧只给摘要 |
| 多客户端注解行为不一致 | 注解是 hint 不是强制 | 把"必须确认"逻辑同时写在客户端，而非只依赖服务端 |

## 延伸阅读

- 给破坏性工具加 **Elicitation**：执行前让服务端主动向用户要补充参数（如"确认输入应用名拼写"），形成更强的双向确认。
- 用 `Context` 对象做**进度上报**：长任务通过 `ctx.report_progress()` 把百分比推给客户端。
- 把 `read_log` 升级为**异步资源**（`async def`），对接真实的对象存储/ELK。
- 用 `output_schema` 手写自定义输出契约，或用 `ToolResult` 同时返回文本 + 结构化数据 + 多媒体块。

## 训练营适用性评估

- **适合**：MCP 进阶训练营、AI 工程师安全实训营、企业内部"AI 工具治理"工作坊。
- **前置**：学员应先完成 Python 三原语基础 MCP 教程。
- **教学时长**：约 90–120 分钟（含动手）。核心可拆成三段：资源模板 30 分钟、结构化输出 25 分钟、工具注解与权限门禁 35 分钟，留 20 分钟做破坏性工具的"确认门禁"演示与讨论。
- **评估点**：学员能否独立解释 `readOnlyHint` 与 `destructiveHint` 在客户端侧的差异，并写出带结构化输出的只读工具 + 一个被正确标记的破坏性工具。

## 参考来源

1. [Resources & Templates — FastMCP 官方文档](https://gofastmcp.com/servers/resources) — Resource Template、RFC 6570 URI 模板、通配符与 query 参数的权威说明
2. [Tools — FastMCP 官方文档](https://gofastmcp.com/servers/tools) — `@tool` 装饰器、`ToolAnnotations`（readOnly/destructive/idempotent/openWorld）、Structured Output 与 output_schema
3. [MCP Developer Guide 2026 — Lushbinary](https://lushbinary.com/blog/mcp-model-context-protocol-developer-guide-2026/) — 2026 年 MCP 构建、部署、安全集成的完整开发者指南
4. [PrefectHQ/fastmcp — GitHub](https://github.com/prefecthq/fastmcp) — FastMCP（v3）源码与最新特性，含 ResourcesAsTools 等变换
5. [Model Context Protocol Specification](https://modelcontextprotocol.io/specification) — 工具注解、资源模板、结构化输出的协议级定义

> [!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)
