<!-- title: 底层API造MCP：三原语打通 -->

# 用底层API造MCP：工具、资源、提示词三原语一网打尽

## 概述

本教程带你用 Python 官方 MCP SDK 的**底层 `Server` 类**，从零造一台完整的服务器，把 MCP 的三大原语——**工具（Tools）、资源（Resources）、提示词（Prompts）**——全部打通。多数教程只讲"造个工具"，结果你只会写 `call_tool`。本教程的差异化在于：用底层逐个注册 handler，让你看清三原语各自的控制方（谁触发、谁控制），并在结尾交付一台可接进 Claude Code 的"项目状态看板"MCP 服务器。适合已有 Python 基础、想系统掌握 MCP 协议、追求"不被高层框架黑箱遮蔽"的开发者。

## 前置条件

- Python 3.10 及以上（MCP SDK 大量使用 `match`、`|` 联合类型注解）
- 已安装并跑通过 Claude Code（命令行能识别 `claude`）
- 推荐 `uv` 作为包管理器（速度更快、隔离更干净），没有也行，可用 `pip`
- 能看懂异步 `async/await` 与 JSON Schema 的基本结构
- 一台可联网的开发机（仅安装依赖时需要）

## 第一步：建立心智模型——三原语与"控制方"

写底层代码前，先想清楚一件事：MCP 不是"又一组 API"，它是一份**协议约定**。服务器对外只暴露三类原语，每类原语的"控制方"不同。记住这张对照表，下面所有 handler 都在围绕它转：

| 原语 | 谁来触发 | 控制方 | 生命周期入口 | 典型用途 |
|------|----------|--------|--------------|----------|
| 工具 Tools | 模型自主决定调用 | **模型控制**（Model-controlled） | `tools/list` → `tools/call` | 跑命令、查库、调外部 API |
| 资源 Resources | 宿主应用决定是否注入 | **应用控制**（App-controlled） | `resources/list` → `resources/read` | 把文件、配置、日志作为上下文喂进去 |
| 提示词 Prompts | 用户主动选中 | **用户控制**（User-controlled） | `prompts/list` → `prompts/get` | 可复用的斜杠命令模板 |

一句话区分：**工具是给模型用的、资源是给应用挂的、提示词是给人选的。** 三者谁也不替代谁。如果你的服务器只有工具，那你只用了 MCP 三分之一的表达能力——这正是很多人对 MCP 的误解来源。

底层 `Server` 类的设计哲学是"显式胜于隐式"：你为哪个原语注册了 handler，服务器就在 `initialize` 握手时自动声明哪项能力（capability），客户端收到声明后才会发起对应请求。这与高层 `FastMCP`（装饰器自动推断）的路数相反——底层让你**对自己声明的每一项能力负责**。

**常见问题**：为什么我注册了 `read_resource` 却报"capability 不支持"？通常是 handler 在 `app.run()` 之前没有挂上，或函数名/签名拼错导致装饰器没绑成功。底层不报错，只是静默不声明能力，要警惕。

## 第二步：搭建项目骨架与依赖

建一个空目录，初始化项目：

```bash
mkdir mcp-status-board && cd mcp-status-board
uv init --name mcp-status-board          # 或手动建 pyproject.toml
uv add "mcp[cli]>=1.2"                   # 装官方 Python SDK（含 CLI 辅助）
```

不用 `uv` 的等价写法：

```bash
python -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]>=1.2"
```

`mcp[cli]` 这个 extras 会顺带装上 `typer` 等工具，方便用 `mcp` 命令做自检。装完验证一下：

```bash
mcp version                                # 能打印版本号即装通
```

**预期结果**：终端回显一行版本号，例如 `mcp 1.x.x`。若提示 `command not found`，多半是没装 `[cli]` extras，或 `uv` 的环境没激活。

**常见问题**：报 `ImportError: cannot import name 'Server'`——你装的可能是远古版本（早期包名是 `mcp` 但无 `server` 子模块）。务必 `>=1.2`。报 Python 版本错误——SDK 要 3.10+，`pyenv` 切版本即可。

## 第三步：用底层 `Server` 类注册工具（Tools）

工具是绝大多数 MCP 教程唯一覆盖的原语。底层写法分两步：先 `list_tools` 声明清单，再 `call_tool` 处理调用。注意 `inputSchema` 必须是**合法的 JSON Schema**，模型据此生成参数。

新建 `server.py`，先把骨架与工具部分写出来：

```python
import asyncio
import json
import os
from pathlib import Path
from typing import Any

import mcp.server.stdio
import mcp.types as types
from mcp.server import NotificationOptions, Server
from pydantic import AnyUrl

app = Server("status-board")

STORE = Path(os.environ.get("BOARD_STORE", "board.json"))

# ---------- 工具：模型控制 ----------
@app.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name="add_task",
            description="向看板新增一个任务，返回新任务 ID",
            inputSchema={
                "type": "object",
                "properties": {
                    "title": {"type": "string", "description": "任务标题"},
                    "priority": {
                        "type": "string",
                        "enum": ["low", "medium", "high"],
                        "default": "medium",
                    },
                },
                "required": ["title"],
            },
        ),
        types.Tool(
            name="transition",
            description="把任务流转到下一状态：todo -> doing -> done",
            inputSchema={
                "type": "object",
                "properties": {
                    "task_id": {"type": "string"},
                    "to": {"type": "string", "enum": ["todo", "doing", "done"]},
                },
                "required": ["task_id", "to"],
            },
        ),
        types.Tool(
            name="summary",
            description="统计各状态的任务数量，返回简洁文本",
            inputSchema={"type": "object", "properties": {}},
        ),
    ]


@app.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
    board = _load()
    if name == "add_task":
        tid = f"T{len(board['tasks']) + 1:03d}"
        board["tasks"].append({
            "id": tid, "title": arguments["title"],
            "priority": arguments.get("priority", "medium"),
            "status": "todo",
        })
        _save(board)
        return [types.TextContent(type="text", text=f"已新增 {tid}: {arguments['title']}")]
    if name == "transition":
        for t in board["tasks"]:
            if t["id"] == arguments["task_id"]:
                t["status"] = arguments["to"]
                _save(board)
                return [types.TextContent(type="text", text=f"{t['id']} -> {t['status']}")]
        raise ValueError(f"找不到任务 {arguments['task_id']}")
    if name == "summary":
        from collections import Counter
        c = Counter(t["status"] for t in board["tasks"])
        return [types.TextContent(type="text", text=json.dumps(dict(c), ensure_ascii=False))]
    raise ValueError(f"未知工具: {name}")
```

`_load` / `_save` 是 JSON 文件读写辅助，放文件顶部即可：

```python
def _load() -> dict:
    if not STORE.exists():
        return {"tasks": []}
    return json.loads(STORE.read_text("utf-8"))

def _save(board: dict) -> None:
    STORE.write_text(json.dumps(board, ensure_ascii=False, indent=2), "utf-8")
```

**预期结果**：在 Claude Code 里问"帮我加一个高优任务：修登录 bug"，模型应自主调用 `add_task`，返回形如 `已新增 T001: 修登录 bug`。

**常见问题**：

- 工具不出现：检查 `list_tools` 是否 `return` 了 list，且 `@app.list_tools()` 装饰器没漏括号——它**带括号**。
- 模型调用报参数校验错：`inputSchema` 缺 `type: object` 或 `required` 写成字符串而非列表。
- `call_tool` 内打印 `print()` 后客户端崩溃：**MCP 用 stdio 通信，绝不能往 stdout 打印任何业务日志**。要日志用 `logging`（默认走 stderr）。

## 第四步：暴露资源（Resources）给应用层

资源由**宿主应用**控制注入。底层提供两个 handler：`list_resources` 列出固定 URI，`read_resource` 按 URI 返回内容（字符串或 bytes）。URI 用 `AnyUrl` 类型包装。

把下面两段加到 `server.py`：

```python
# ---------- 资源：应用控制 ----------
@app.list_resources()
async def list_resources() -> list[types.Resource]:
    board = _load()
    tasks = board["tasks"]
    return [
        types.Resource(
            uri=AnyUrl("board://tasks/all"),
            name="全部任务",
            description="看板上所有任务的原始 JSON",
            mimeType="application/json",
        ),
        types.Resource(
            uri=AnyUrl("board://config"),
            name="看板配置",
            description="状态流转规则与版本",
            mimeType="application/json",
        ),
        *[
            types.Resource(
                uri=AnyUrl(f"board://task/{t['id']}"),
                name=t["title"],
                description=f"{t['id']} [{t['status']}/{t['priority']}]",
                mimeType="application/json",
            )
            for t in tasks
        ],
    ]


@app.read_resource()
async def read_resource(uri: AnyUrl) -> str:
    s = str(uri)
    if s == "board://tasks/all":
        return json.dumps(_load()["tasks"], ensure_ascii=False)
    if s == "board://config":
        return json.dumps({"flow": ["todo", "doing", "done"], "version": "1.0"})
    if s.startswith("board://task/"):
        tid = s.split("/")[-1]
        for t in _load()["tasks"]:
            if t["id"] == tid:
                return json.dumps(t, ensure_ascii=False)
    raise ValueError(f"未知资源: {s}")
```

注意 URI 匹配是**字符串精确比较**，`AnyUrl` 会规范化（去尾斜杠等），所以建议固定写法、别留尾斜杠，避免"明明配对了却读不到"。

**预期结果**：在 Claude Code 里说"把全部任务资源读出来给我看"，模型会通过 `resources/read` 拉取 `board://tasks/all` 并渲染成表格。

**常见问题**：

- 资源列表为空：`list_resources` 返回了 `None` 或抛了异常，底层会静默跳过该能力。
- 单条资源读不出：90% 是 URI 拼写不一致。调试时把 `read_resource` 第一行加 `logging.warning(str(uri))` 打到 stderr 看。
- 想要**动态模板**资源（如 `board://task/{id}` 任意 ID）：改用 `@app.list_resource_templates()` 注册模板，客户端会做 URI 模板展开。固定列表与模板可共存。

## 第五步：定义提示词（Prompts）模板

提示词是**用户主动选中**的原语。在 Claude Code 里，服务器的 prompts 会以斜杠命令（如 `/standup`）的形式出现。底层需要 `list_prompts` 与 `get_prompt`：

```python
# ---------- 提示词：用户控制 ----------
@app.list_prompts()
async def list_prompts() -> list[types.Prompt]:
    return [
        types.Prompt(
            name="standup",
            description="生成每日站会汇报，自动汇总看板状态",
            arguments=[],
        ),
        types.Prompt(
            name="triage",
            description="找出一批任务中的阻塞项与高优项",
            arguments=[
                types.PromptArgument(
                    name="count", description="最多列出几条", required=False,
                )
            ],
        ),
    ]


@app.get_prompt()
async def get_prompt(
    name: str, arguments: dict[str, str] | None
) -> types.GetPromptResult:
    board = _load()
    tasks = board["tasks"]
    if name == "standup":
        doing = [t for t in tasks if t["status"] == "doing"]
        todo = [t for t in tasks if t["status"] == "todo"]
        body = (
            f"## 昨日/进行中\n"
            + "\n".join(f"- {t['id']} {t['title']}" for t in doing)
            + f"\n## 今日计划\n"
            + "\n".join(f"- {t['id']} {t['title']}" for t in todo[:3])
        )
    elif name == "triage":
        n = int(arguments.get("count", "5")) if arguments else 5
        top = sorted(
            [t for t in tasks if t["status"] != "done"],
            key=lambda t: (t["status"] != "doing", t["priority"] != "high"),
        )[:n]
        body = "请逐条分析以下任务是否阻塞，并给出流转建议：\n" + "\n".join(
            f"- {t['id']} [{t['status']}/{t['priority']}] {t['title']}" for t in top
        )
    else:
        raise ValueError(f"未知提示词: {name}")
    return types.GetPromptResult(
        description=f"Prompt {name}",
        messages=[
            types.PromptMessage(
                role="user",
                content=types.TextContent(type="text", text=body),
            )
        ],
    )
```

`get_prompt` 返回的是**一组预填好的消息**（messages），客户端会把它当作用户的输入注入对话。这意味着你可以在模板里塞结构化文本、把上下文预先算好，用户点一下就能触发一条"准备好了"的复杂指令。

**预期结果**：在 Claude Code 输入 `/standup`，选中后立即看到一段已组织好的站会汇报草稿，模型基于它续写。

**常见问题**：

- 斜杠命令不出现：`list_prompts` 没返回，或客户端没刷新。重启会话或 `/mcp` 里 reconnect。
- 参数传不进来：`arguments` 是 `dict[str,str] | None`，所有值都是字符串，数字要自己 `int()`。

## 第六步：接传输层与启动主循环

三原语注册完，最后把它们挂到 stdio 传输层。底层要把 `app.run()` 套进 `stdio_server()` 上下文：

```python
async def main() -> None:
    async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
        await app.run(
            read_stream,
            write_stream,
            app.create_initialization_options(
                notification_options=NotificationOptions(),
            ),
        )


if __name__ == "__main__":
    asyncio.run(main())
```

`create_initialization_options()` 会**扫描你注册过的 handler**，自动生成对应的能力声明（`tools`、`resources`、`prompts`）。你没注册 `list_resource_templates`，它就不会声明模板能力——这正是底层"显式控制"的体现。

跑一下自检（不接客户端，只验证代码无语法/导入错误）：

```bash
python -c "import server" && echo OK
# 更进一步：用 SDK 自带的 inspector
mcp dev server.py
```

`mcp dev` 会拉起一个网页版 Inspector，可在浏览器里逐个点 `tools/list`、`resources/read`、`prompts/get`，看返回的 JSON。这是底层开发期最高效的调试手段，比反复重启客户端快得多。

**预期结果**：`echo OK` 输出 OK；`mcp dev` 打开一个本地 URL，浏览器里能看到三个工具、若干资源、两个提示词。

**常见问题**：

- `mcp dev` 报找不到模块：用 `mcp dev server.py:app.create_initialization_options`？不对——`mcp dev` 接受的是**模块路径**或**Server 对象**。如果你的 `app` 对象在模块顶层，可写 `mcp dev server.py`（它会自动找名为 `app`/`server`/`mcp` 的对象，具体以你装的版本为准；不行就 `mcp dev server:app`）。
- 长连接卡死：检查 `app.run` 是否在 `async with` 内部被 `await`，少 `await` 会立即返回。

## 第七步：接进 Claude Code 并验收三原语

用 CLI 注册（推荐，最快）：

```bash
claude mcp add status-board -- uv run --directory "$(pwd)" server.py
# 或纯 python：claude mcp add status-board -- python server.py
```

或用项目级 `.mcp.json`（团队可提交到仓库，他人 clone 即生效）：

```json
{
  "mcpServers": {
    "status-board": {
      "command": "uv",
      "args": ["run", "server.py"]
    }
  }
}
```

进 Claude Code 后用 `/mcp` 查看 `status-board` 是否绿色 connected。然后做**三原语验收**：

1. **工具**：`帮我新增任务"写接口文档"，优先级 high，再统计一下` → 模型连续调 `add_task` + `summary`。
2. **资源**：`把 board://tasks/all 资源读出来` → 模型走 `resources/read`。
3. **提示词**：输入 `/standup` → 看到预生成的站会汇报。

三种触发路径各走各的协议方法，互不串扰——这就是"三原语全打通"的验收标准。

**常见问题**：

- `/mcp` 显示 failed：把 `claude mcp add` 的命令直接在终端跑一遍，stderr 报错会原样暴露（缺依赖、路径错、Python 版本不够）。
- 命令找不到 `uv`：Claude Code 用的是登录 shell 的 PATH，若 `uv` 装在非标准位置，给 `command` 写全路径 `/home/you/.local/bin/uv`。
- 远程/团队协作：想给整个团队共享这台服务器，可改用 Streamable HTTP 传输远程部署（这是另一篇主题，此处先打通本地三原语）。

## 实战案例：项目状态看板 MCP 服务器（完整可运行）

把上面三段拼起来就是一台完整服务器，目录结构：

```
mcp-status-board/
├── pyproject.toml        # 依赖 mcp[cli]>=1.2
├── server.py             # 三原语 + 主循环
└── board.json            # 运行后自动生成的存储文件
```

一个端到端的故事：你正在写一个微服务，每天要在多个任务间切换。把这台看板服务器接进 Claude Code 后，你可以这样工作——

- 早上开会前敲 `/standup`，秒出站会草稿（提示词，预填进行中与待办）。
- 开发中直接对 Claude 说"把 T002 流转到 doing""把登录 bug 加成高优"，模型调工具改看板。
- 需要全局视角时让它读 `board://tasks/all`（资源），渲染成表格再分析。

工具负责"写"、资源负责"读"、提示词负责"按套路启动"——三者各司其职，这正是 MCP 设计的精妙处，也恰恰是只懂工具的人体会不到的部分。

与高层框架的取舍也值得说清：`FastMCP`（装饰器风格，类似 FastAPI）写起来更短，适合"工具为主、快速出活"的场景；底层 `Server` 类代码更长，但你能**精确控制能力声明、URI 匹配逻辑、prompt 消息组装**，适合需要把三原语都精细打磨的产品级服务器。本教程选底层，正是为了让你不被黑箱遮住眼睛。

## 工作流程与架构图

```mermaid
flowchart TD
    subgraph Host["Claude Code（宿主）"]
        C["MCP Client"]
    end
    subgraph Srv["status-board Server（你造的）"]
        H["initialize 握手\n声明 capabilities"]
        T["tools/list → tools/call"]
        R["resources/list → resources/read"]
        P["prompts/list → prompts/get"]
        ST["board.json 存储"]
    end
    U["用户"] -->|"输入 /standup"| Host
    Host -->|"模型决定"| T
    Host -->|"应用注入"| R
    Host -->|"用户选中"| P
    C <-->|"JSON-RPC over stdio"| H
    T --> ST
    R --> ST
    P --> ST
    H --> T
    H --> R
    H --> P
```

握手阶段 `Server` 依据已注册 handler 自动声明能力；运行期三类请求走各自的 handler，最终都落在 `board.json`。控制方分别标注在三条入边上，对应模型、应用、用户。

## 底层 vs 高层 API 取舍速查

| 维度 | 底层 `Server` 类（本教程） | 高层 `FastMCP` |
|------|---------------------------|----------------|
| 写法 | 显式注册每个 handler | `@mcp.tool()` 装饰器自动推断 |
| 能力声明 | 你注册什么声明什么 | 自动声明全部 |
| 三原语覆盖 | 工具/资源/提示词一视同仁 | 同样支持，但更偏工具 |
| URI/消息精细控制 | 完全手写，最灵活 | 受框架约束 |
| 适合场景 | 产品级、需精控、学习协议 | 快速原型、工具为主 |
| 学习曲线 | 较陡，回报是"真懂 MCP" | 平缓 |

一句话：**想快用 FastMCP，想懂用底层**。两者底层都调用同一套 `mcp.server`，可平滑切换。

## 延伸阅读

- MCP 官方文档与概念章节，深入三原语的协议级定义：`modelcontextprotocol.io`
- Python SDK 仓库的 `examples/` 目录，含完整底层样例：`github.com/modelcontextprotocol/python-sdk`
- 把同一台服务器换成 Streamable HTTP 传输做远程部署（下一篇主题）
- 用 Inspector (`mcp dev`) 做交互式调试与协议抓包
- 为服务器补一套可量化的基准测试（工具触发准确率、资源命中率）

## 训练营适用性评估

- **适配营型**："AI 工具开发营 / Agent 工程实战营 / MCP 协议深度营"，定位为**协议原理 + 完整交付**型课程。
- **教学时长**：建议 **3～4 小时**（含动手）。其中模型讲解 0.5h、三原语逐段编码 2h、接入验收与排错 1h、扩展讨论 0.5h。
- **学员基础**：能独立写异步 Python、懂 JSON Schema，否则需先补 1 小时前置。
- **交付物**：每人一台接进 Claude Code、三原语全通的看板服务器，可带回家继续扩展。
- **延伸练习**：加 `list_resource_templates` 动态资源、给工具加 `progress` 进度上报、把存储换成 SQLite——刚好衔接"FastMCP 造 SQLite 查询服务器"那条线。

## 参考来源

1. [Model Context Protocol 官方文档](https://modelcontextprotocol.io) — 协议规范与三原语概念定义的权威来源
2. [MCP Python SDK（modelcontextprotocol/python-sdk）](https://github.com/modelcontextprotocol/python-sdk) — 底层 `Server` 类、handler 装饰器与 stdio 传输的参考实现
3. [MCP Server Development: Build Custom Tools for Claude and Local LLMs — Botmonster](https://botmonster.com/ai/mcp-server-development-build-custom-tools/) — Python 侧三原语实践与认证、文件后端示例
4. [How to Build Your First MCP Server for Claude (2026) — AI for Anything](https://www.aiforanything.io/blog/how-to-build-mcp-server-claude-2026) — 从零构建并接入 Claude Code 的完整流程
5. [Building custom connectors — Claude 官方文档](https://claude.com/docs/connectors/building) — 用 `mcp-server-dev` 插件引导构建与 Claude Code 接入方式

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