<!-- title: 造文件搜索MCP配调试测试 -->

# 造文件搜索MCP配调试测试：FastMCP 全流程实战

## 概述

本教程带你用 Python 的 FastMCP 框架，从零构建一个**本地文件搜索 MCP 服务器**，并在真实开发链路中跑通它：写工具（Tools）、资源（Resources）、提示词（Prompts）三原语，用 MCP Inspector 调试 JSON-RPC 交互，写单元测试与端到端测试，最后接入 Claude Code 让模型真正调起你的搜索能力。适用人群：有 Python 基础、想让 Claude Code 长出"私有技能"的后端开发者与运维工程师。学完后你掌握的不是又一个域封装，而是**可复用的 MCP 服务器开发流水线**。

## 前置条件

- **Python 3.10+**（FastMCP 官方最低版本要求），推荐 3.11/3.12
- **uv**：Astral 出品的极速包管理器，安装命令 `curl -LsSf https://astral.sh/uv/install.sh | sh`
- **Node.js 18+**：MCP Inspector 是一个本地 Web 工具，需要 Node 运行时
- **Claude Code CLI**：用于最终接入，已通过 `npm i -g @anthropic-ai/claude-code` 安装
- 基本的 Python 装饰器、`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` 命中汇总模板 |

一个请求的生命周期是这样的：

```mermaid
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 就是给模型看的说明书**。这一条贯穿整个开发过程。

## 二、步骤 1：装环境、搭骨架

在工作目录初始化一个 uv 项目：

```bash
uv init file-search-mcp
cd file-search-mcp
uv add fastmcp
uv add --dev pytest pytest-asyncio
```

创建 `server.py`，先跑一个最小可运行的服务器：

```python
# 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 验证。

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

## 三、步骤 2：实现搜索工具（Tools）

这是服务器的核心。我们要实现两个工具：按文件名 + 内容检索，以及读取某行附近的代码片段。

```python
# 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
```

**逐点说明**：

1. **`annotations`**：`readOnlyHint=True` 告诉客户端"这个工具不改环境"，`openWorldHint=False` 表示"封闭域"（只在本地文件系统内）。这是给客户端做权限/审批决策的元数据，Claude Code 会据此减少不必要的确认弹窗。
2. **`ctx: Context = None`**：Context 通过类型注解自动注入，FastMCP 会把它从对外 schema 里**剔除**——客户端永远看不到 `ctx` 这个参数。Context 方法是异步的，所以函数用 `async def`。
3. **`await ctx.info(...)`**：把日志回传给客户端，在 Inspector 和 Claude Code 里都能实时看到，是调试利器。
4. **`_safe_resolve`**：MCP 服务器一旦被模型调用，输入就是"半不可信"的。**路径穿越是 MCP 工具最常见的安全坑**，必须用 `resolve()` + 父目录校验兜底，绝不能直接拼字符串。

**预期结果**：在 Inspector 里调 `search_files(query="mcp")`，右侧能看到分步的 `info` 日志，最终返回命中数组。

再加一个读片段的工具：

```python
@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))
```

## 四、步骤 3：暴露只读资源（Resources）

资源用于"模型主动拉取的结构化数据"。我们把索引统计做成一个资源，再把单文件读取做成**带参数的资源模板**。

```python
# 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}` 是占位符。客户端列出模板后可按需实例化。

## 五、步骤 4：固化高频提示词（Prompts）

Prompt 是"带参数的对话模板"。当用户在 Claude Code 里输入 `/mcp` 调用 prompt 时，模板内容会作为预设消息注入对话。

```python
# 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"`），这里返回单条用户消息即可。

## 六、步骤 5：用 MCP Inspector 深度调试

Inspector 不仅是"能跑就行"的玩具，它是**协议级调试器**。开发时养成三轮自检习惯：

1. **List 阶段**：点 Tools / Resources / Prompts 三个标签，确认数量和名字都对。少一个说明装饰器没生效（常见原因：函数被 `if __name__` 包住、或导入路径错）。
2. **Schema 阶段**：点开每个工具，看自动生成的输入 schema。`max_results` 应该是 `integer` 且有默认值 50；如果参数描述为空，说明你没写 docstring 的 `Args:` 段。
3. **Call 阶段**：真实调用，重点看下方 **Notifications / Logs** 面板——这里能看到你用 `ctx.info/debug` 发出的全部日志，以及 JSON-RPC 的原始报文。

```bash
# 两种等价启动方式
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`。

## 七、步骤 6：单元测试 + 端到端测试

MCP 服务器也是普通 Python 代码，**必须写测试**。FastMCP 提供 `Client`，可以直接用**内存传输**连到服务器对象，省去起进程的开销，速度极快。

```python
# 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 范围
```

运行：

```bash
uv run pytest -v
```

**预期结果**：3 个用例全绿。`Client(mcp)` 这种"客户端连服务器对象"的写法，本质是 FastMCP 用内存 transport 绕过了 stdio——这正是它能毫秒级跑完测试的原因。把它当作 MCP 版的"直接调函数"，覆盖率可以拉得很高。

**端到端测试**更进一步：用真实子进程拉起服务器，再连 stdio，验证协议层没问题。FastMCP 的 `Client` 也能接 `command://` 形式：

```python
@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)
```

## 八、步骤 7：接入 Claude Code

测试通过后，把服务器注册给 Claude Code。在项目根目录建 `.mcp.json`（项目级，可提交版本库，团队共享）：

```json
{
  "mcpServers": {
    "file-search": {
      "command": "uv",
      "args": ["run", "--directory", ".", "server.py"]
    }
  }
}
```

或者用命令行（用户级，仅本机生效）：

```bash
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`，复制即可运行：

```python
"""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 服务器，照这个套路走即可：

```mermaid
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 开发和普通后端开发最大的区别，因为你的"接口文档"直接影响模型能不能正确用上能力。

## 十一、进阶要点与避坑清单

1. **Context 只在单次请求内有效**：每次请求拿到的是新 Context，跨请求存状态要用 `ctx.session` 或外部存储（Redis/DB）。别在函数外缓存 Context。
2. **同步函数自动进线程池**：`def` 工具会被丢进线程池跑，不阻塞事件循环；但 I/O 密集场景仍推荐 `async def`，更高效。
3. **资源是只读的**：别在 `@mcp.resource` 里做写操作。需要写，就用工具。
4. **输入永远是半不可信的**：模型可能传奇怪参数（空字符串、超长字符串、负数）。对 `max_results` 加 `min(max_results, 500)` 上限，对 `query` 做 `[:200]` 截断，对路径做穿越校验——这三道防线能让服务器在生产里稳得多。
5. **传输层选择**：本教程用默认的 **stdio**（本地、零配置，适合 Claude Code 这种本地宿主）。若要做远程多租户，切 Streamable HTTP：`mcp.run(transport="http", port=8000)`，再配认证。
6. **版本钉死**：FastMCP 仍在快速演进，生产环境 `uv add fastmcp==<具体版本>`，别用浮点版本。

## 延伸阅读

- **MCP 官方规范与概念**：理解 protocol、lifecycle、capabilities 的底层约定
- **FastMCP 官方文档**（gofastmcp.com）：Context 的 sampling/elicitation、Dependency Injection、超时与版本管理等高阶主题
- **MCP Inspector 仓库**：协议调试、认证头注入、远程 SSE 服务器连接的细节
- **FastMCP → 官方 SDK 升级路径**：当你需要更底层控制时的迁移指南
- **Streamable HTTP 与远程部署**：把本地 stdio 服务器改造成多租户远程服务的实践

## 参考来源

1. [MCP Python SDK（官方）— GitHub README](https://github.com/modelcontextprotocol/python-sdk) — 官方 Python SDK，含 v2 的 `MCPServer`/`Client` 与 `mcp dev` Inspector 启动方式
2. [FastMCP — GitHub（PrefectHQ/fastmcp）](https://github.com/PrefectHQ/fastmcp) — 本教程所用框架，"70% 的 MCP 服务器跑在某个版本的 FastMCP 上"
3. [FastMCP 官方文档（gofastmcp.com）](https://gofastmcp.com) — Tools / Resources / Prompts / Context 的权威 API 参考
4. [Build an MCP server — Model Context Protocol 官方文档](https://modelcontextprotocol.io/docs/develop/build-server) — 三原语（Resources / Tools / Prompts）的概念定义
5. [FastMCP in Python: Build a Real MCP Server（2026 Guide）](https://www.danilchenko.dev/posts/fastmcp-mcp-server/) — 含 GitHub OAuth 集成与生产部署的实战博客

## 训练营适用性评估

- **适用训练营类型**：企业内部"AI 编程提效"训练营、LLM 应用开发进阶班、运维/平台工程 SRE 训练营。不适合零基础班（需要 Python 异步与类型注解基础）。
- **前置课程**：建议放在"Claude Code 入门"与"Skills 开发"之后，作为**对外能力扩展**模块的第一课。
- **教学时长**：建议 **半天（3–4 小时）**。其中概念与心智模型 30 分钟，编码实现 90 分钟，Inspector 调试 45 分钟，测试编写 45 分钟，Claude Code 联调与答疑 30 分钟。
- **可裁剪版本**：若时间紧，砍掉步骤 6 的端到端测试与步骤 4 的 Prompts，保留 Tools + 单元测试 + Claude Code 接入，可压缩到 2 小时产出可用 MVP。
- **作业建议**：让学员把 `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 等场景全覆盖。

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