首页 › tutorials › 底层API造MCP:三原语打通

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

概述

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

前置条件

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

写底层代码前,先想清楚一件事: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() 之前没有挂上,或函数名/签名拼错导致装饰器没绑成功。底层不报错,只是静默不声明能力,要警惕。

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

建一个空目录,初始化项目:

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 的等价写法:

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

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

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,先把骨架与工具部分写出来:

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 文件读写辅助,放文件顶部即可:

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。

常见问题:

第四步:暴露资源(Resources)给应用层

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

把下面两段加到 server.py:

# ---------- 资源:应用控制 ----------
@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 并渲染成表格。

常见问题:

第五步:定义提示词(Prompts)模板

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

# ---------- 提示词:用户控制 ----------
@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,选中后立即看到一段已组织好的站会汇报草稿,模型基于它续写。

常见问题:

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

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

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,它就不会声明模板能力——这正是底层"显式控制"的体现。

跑一下自检(不接客户端,只验证代码无语法/导入错误):

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,浏览器里能看到三个工具、若干资源、两个提示词。

常见问题:

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

用 CLI 注册(推荐,最快):

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

或用项目级 .mcp.json(团队可提交到仓库,他人 clone 即生效):

{
  "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 服务器(完整可运行)

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

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

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

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

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

工作流程与架构图

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,可平滑切换。

延伸阅读

训练营适用性评估

参考来源

  1. Model Context Protocol 官方文档 — 协议规范与三原语概念定义的权威来源
  2. MCP Python SDK(modelcontextprotocol/python-sdk) — 底层 Server 类、handler 装饰器与 stdio 传输的参考实现
  3. MCP Server Development: Build Custom Tools for Claude and Local LLMs — Botmonster — Python 侧三原语实践与认证、文件后端示例
  4. How to Build Your First MCP Server for Claude (2026) — AI for Anything — 从零构建并接入 Claude Code 的完整流程
  5. Building custom connectors — Claude 官方文档 — 用 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学通信知识星球海报-扫码加入