上一章我们以 Tool 为核心写了第一个 MCP server。MCP 还有两件大杀器没用上:Resources 让 Agent 主动拉数据;Prompts 把常见对话模板做成快捷指令。再深一层,还能让 server 反过来调模型——sampling。本章打完 MCP 的完整能力包。

18.1 三原语职责回顾

Tool      模型决定调用 / 有副作用       类比"实习生执行命令"
Resource  客户端按需读取 / 无副作用      类比"调用 卡片盒"
Prompt    用户主动选择的快捷方式         类比"slash command / 应用菜单"
Sampling  server 反过来调 LLM           类比"反向 function-call"

理解这四件事的发起方向是关键。混用 = bug;分开 = 协议清晰。

18.2 Resources 实战:把"工单列表"做成可拉数据

18.2.1 何时 Resources > Tools

判据 Resources 适合 Tools 适合
调用方 harness 周期性拉、缓存 模型按需 invoke
副作用
数据形态 静态文档 / 列表 / 配置 函数调用结果
大小 中等(KB-MB) 小(KB 以内为佳)

举几个常见 Resource 设计:

tickets://open              → 当前 open 工单列表 (JSON)
tickets://3201              → 单条工单详情 (JSON)
tickets://stats/weekly      → 本周统计 (JSON)
notion://spec/agent-design  → 某 Notion 文档 (markdown)

18.2.2 动态与静态 URI

MCP Resource 的 URI 可以是固定(“tickets://open”)也可以带模板(“tickets://{id}")。前者用于 list_resources 一次性列出,后者用 resource templates:

from mcp.types import ResourceTemplate

@server.list_resource_templates()
async def list_templates() -> list[ResourceTemplate]:
    return [
        ResourceTemplate(
            uriTemplate="tickets://{id}",
            name="Ticket by ID",
            description="按 ID 取工单",
            mimeType="application/json",
        )
    ]

@server.read_resource()
async def read(uri: str) -> str:
    from urllib.parse import urlparse
    p = urlparse(uri)
    if p.scheme != "tickets":
        raise ValueError(f"unknown scheme: {p.scheme}")
    if p.path == "open":
        return json.dumps([t.model_dump() for t in client.list(status="open")])
    if p.netloc.isdigit():
        return json.dumps(client.get(int(p.netloc)).model_dump())
    raise ValueError(f"unknown resource: {uri}")

resource templates 让客户端先知道 schema 再生成 URI,省一组"动态列举"成本。

18.2.3 让 Resource 可订阅

如果数据会变(如工单被关闭),告诉客户端"哪些 resource 改了” 是更高级的用法:

from mcp.server import NotificationManager

@server.list_resources()
async def list_resources():
    return resources_cache

# 当某个工单状态变化时 → 通知
async def notify_change(res_updated_uri: str):
    await server.request_handlers["notifications/resources/list_changed"]({})
    await server.request_handlers["notifications/resources/updated"](
        {"params": {"uri": res_updated_uri}}
    )

支持通知的资源对实时 dashboard 这类场景价值大。

18.3 Prompts:把"常见任务"做成快捷指令

Prompt 不是"prompt 工程的 prompt"——是 MCP 协议里的"预制对话模板"。

含义:
  Prompt = 一个带 name + arguments 的对话剧本片段
作用:
  在 IDE / harness 里被作为 slash command 显示给用户选用
关键:
  Prompt 不直接调 LLM,它让 harness 生成一段 messages 发给 LLM

举一个:把"review 这个 PR"做成 prompt:

@server.list_prompts()
async def list_prompts() -> list[types.Prompt]:
    return [
        types.Prompt(
            name="pr-review",
            description="Review 当前分支的 diff",
            arguments=[
                types.PromptArgument(name="branch",
                                     description="分支名",
                                     required=True),
            ],
        )
    ]

@server.get_prompt()
async def get_prompt(name: str, arguments: dict) -> types.GetPromptResult:
    if name == "pr-review":
        branch = arguments["branch"]
        return types.GetPromptResult(
            messages=[
                types.PromptMessage(
                    role="user",
                    content=types.TextContent(
                        type="text",
                        text=(f"请 review 分支 {branch} 的 diff\n"
                              f"关注:\n"
                              f"1. 类型安全问题\n"
                              f"2. 安全风险\n"
                              f"3. 性能\n"
                              f"4. 测试覆盖\n"
                              f"输出按严重程度排序的 5 条以内问题清单")
                    )
                )
            ]
        )
    raise ValueError(f"unknown prompt: {name}")

OpenCode 把 prompts 当 slash command 显示:

/pr-review branch=feature/agent-graph

击中 Enter 后,harness 把 prompt 注入到对话开头——这是为什么 MCP 的 prompts 跟 OpenCode 的 slash command 像孪生兄弟。

18.4 Sampling:MCP server 反过来调 LLM

最有意思的原语来了。Sampling 让 server 在执行一个 tool 时反过来向 client 请求一次 LLM 调用

方向反转:
  正常:  LLM → tool_call → server.tool
  Sampling: server.tool → request LLM call → client 反馈 LLM 输出给 server

用例:你的 server 接收"用户给 ticket 一段自然语言评论",需要先总结成一行摘要:

@server.call_tool()
async def call_tool(name: str, arguments: dict):
    if name == "tickets.summarize_comment":
        body = arguments["body"]
        summary = await server.sample(
            messages=[
                {"role": "user",
                 "content": f"用一句话中文总结这条评论:\n\n{body}"},
            ],
            max_tokens=50,
        )
        return [types.TextContent(type="text", text=summary)]

注意三件事:

  1. server 自身不调外部 LLM API,它通过协议请 client 帮它调——避免 server 持 API key 又是合规与成本问题
  2. client 可以拒绝 sampling(danger 模式 / 已达额度),server 必须有 fallback
  3. server.sample() 在 OpenCode / Claude Code 等 client 上是支持的,但 stdio 的简单 server 可能根本不响应你

18.5 一次实战:组合三原语

编写"工单助手" server,目标是 Agent 能:

  1. 用 prompt /summarize-comment 把长评论总结成一句话
  2. 用 tool tickets.comment 写回工单
  3. 用 resource tickets://{id} 给后续读取
@server.list_prompts()
async def list_prompts():
    return [
        types.Prompt(
            name="summarize-comment",
            description="总结长评论为一句,并自动写入工单",
            arguments=[
                types.PromptArgument(name="ticket_id", required=True),
                types.PromptArgument(name="raw_body",  required=True),
            ],
        )
    ]

@server.get_prompt()
async def get_prompt(name, arguments):
    if name != "summarize-comment":
        raise ValueError
    tid = int(arguments["ticket_id"])
    raw = arguments["raw_body"]
    summary = await server.sample(
        messages=[{"role":"user",
                   "content": f"用一句中文总结这条评论:\n\n{raw}"}],
        max_tokens=80,
    )
    # 把 summary 当作 internal comment 写入工单
    client.comment(tid, f"[AI summary] {summary}")
    return types.GetPromptResult(
        messages=[types.PromptMessage(role="user",
            content=types.TextContent(type="text",
                text=f"已为 #{tid} 添加摘要 comment: {summary}"))]
    )

模型在此并不显式 invoke 任何 tool,是 sampling 帮它把"总结"短暂外包给 LLM 一次。这是 MCP 协议最有"生态互操"特征的玩法。

18.6 Roe Tooling:用 logging / progress / cancellation

MCP 还带三个轻量原语帮你做"长任务" server:

# 告诉 client 进度
await server.request_handlers["notifications/progress"](
    {"params": {"progress": 50, "total": 100, "message": "syncing 50/100"}}
)

# 告诉 client 日志
await server.send_log("info", "Synced 50 tickets from upstream")

# 让 server 处理取消 (client abort)
@server.cancel_request()
async def on_cancel(request_id: str):
    # 取消 httpx 调用
    ...

长跑 server(如迁徙数据、批量 fetch)务必接这几个——否则 client 看着等不了,会以为 server 卡死。

18.7 安全:sampling 信任边界

Sampling 把 client 的 LLM 调用权让渡给 server,是个反信任动作。三条加固:

  1. Server 必须在 prompt 里"声明用途",让 client 决定是否同意
  2. Client 端可配 sampler policy:每次 sampling 弹 confirm,不要 auto-yes
  3. Server 不应通过 sampling 调用模型"修改数据" ——只用作"读取的二次加工"

OpenCode 的 mcp 配置里有 samplingPolicy 字段:deny / confirmEach / allowWithBudget。第三档配 budget,避免失控烧 token。

18.8 把 4 个原语揉成"工单助手"全部能力图

┌──────────────────────── MCP Server mcp-tickets ───────────────────────────┐
│                                                                            │
│  Tools (model 调用)               Resources (client 拉取)                  │
│  ── tickets.list                  ── tickets://open                        │
│  ── tickets.get                   ── tickets://{id} (template)            │
│  ── tickets.comment               ── tickets://stats/weekly                │
│                                                                            │
│  Prompts (user 选择)              Sampling (server 借 LLM)                 │
│  ── /pr-review                    ── summarize(text) -> short text         │
│  ── /summarize-comment            ── classify(text, labels) -> choice      │
│                                                                            │
└────────────────────────────────────────────────────────────────────────────┘

一个 server 覆盖四种使用模式,对 Agent 端开放几乎是"一项全能"。

18.9 小结

  • 三原语 + sampling,发起方向各异:模型 / 客户端 / 用户 / server
  • Resources 走 read-only;用 URI templates 让客户端构造动态路径
  • Prompts 是 server 端的 slash command 雏形,本系列 23 Skill 设计 详谈
  • Sampling 把 server 借 LLM 这件事标准化;务必加 confirm 策略 + budget

下一篇:《19 MCP 生态与未来》——MCP 之外的协同竞争、跨平台桥接、未来一年趋势。

Summary: Resources 拉数据、Prompts 是快捷指令、Sampling 反向借 LLM;四原语组合才是一个完整 MCP server。