MCP 不只是 Tool:Resource 和 Prompt 怎么写、何时用,附 3 个实测踩坑(Python SDK v2)

前几篇把 MCP 的 Tool 写熟了:模型决定调不调、传什么参、怎么处理结果。但 MCP 其实还有两个原语,很多教程一笔带过:

  1. Resource:数据暴露给应用/宿主,由应用决定何时加载进上下文;
  2. Prompt:消息模板,由用户从菜单里选、填参数,再塞进对话。

一句话分清三者:

原语 谁决定用 典型用途
Tool 模型决定调用 查天气、写文件、跑检查
Resource 应用/宿主决定加载 配置、档案、文档、实时统计
Prompt 用户从菜单选模板 代码审查、调试开场、固定话术

这篇用 Python SDK v2 把 Resource 和 Prompt 从声明到客户端调用跑一遍,最后是 3 个我实际踩出来的坑。

环境:Linux,Python 3.13.5,mcp 2.3.0(MCP Python SDK v2)。下文输出都是真实运行结果。

一、准备环境

bash 复制代码
python3 -m venv .venv && source .venv/bin/activate
pip install "mcp>=2,<3"

二、服务端:固定 Resource、模板 Resource、两种 Prompt

python 复制代码
"""server.py:Resource + Prompt 演示(MCP Python SDK v2)"""
import sys
from mcp.server import MCPServer
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage

mcp = MCPServer("resource-prompt-demo", version="0.1.0")

# 计数器:证明 list 时不跑函数,只有 read 才跑
READ_COUNT = 0


def log(msg: str):
    # stdio 模式下 stdout 是协议通道,日志只能写 stderr
    print(f"[server] {msg}", file=sys.stderr, flush=True)


# ---------- Resources ----------

@mcp.resource("config://app")
def get_config() -> str:
    """当前应用配置(固定 URI)。"""
    global READ_COUNT
    READ_COUNT += 1
    log(f"get_config 被调用,累计 read={READ_COUNT}")
    return "theme=dark\nlanguage=zh\nmax_retries=3"


@mcp.resource("users://{user_id}/profile")
def get_user_profile(user_id: str) -> str:
    """按 user_id 取用户档案(URI 模板)。"""
    log(f"get_user_profile({user_id!r}) 被调用")
    return f"User {user_id}: 12 orders since 2024."


@mcp.resource("stats://catalog")  # 故意不写 mime_type,默认 text/plain
def catalog_stats() -> dict[str, int]:
    """目录统计(返回 dict,SDK 会序列化成 JSON 文本)。"""
    return {"books": 1204, "authors": 391}


@mcp.resource("docs://readme", mime_type="text/markdown")
def readme() -> str:
    """项目说明(显式声明 mime_type)。"""
    return "# Demo\n\n用 Resource 暴露配置,用 Prompt 暴露模板。"


# ---------- Prompts ----------

@mcp.prompt()
def review_code(code: str) -> str:
    """Review a piece of code.(返回 str → 一条 user 消息)"""
    return f"Please review this code:\n\n{code}"


@mcp.prompt()
def debug_error(error: str) -> list[Message]:
    """Start a debugging conversation.(返回多条消息)"""
    return [
        UserMessage("I'm seeing this error:"),
        UserMessage(error),
        AssistantMessage("I'll help debug that. What have you tried so far?"),
    ]


if __name__ == "__main__":
    mcp.run()  # 默认 stdio

几点说明:

  • Resource 靠 URI 寻址,客户端读的是 config://app,不是函数名 get_config;
  • URI 里写 {user_id},函数参数也必须叫 user_id,这就是模板 Resource;它不出现在 resources/list,而出现在 resources/templates/list;
  • str 原样当文本;dict/Pydantic 等会被序列化成 JSON 文本;bytes 变成 blob。mime_type 默认是 text/plain,SDK 不会根据返回值去猜;
  • Prompt 返回 str → 一条 user 消息;返回 list[Message] → 可以预填多轮,含 AssistantMessage 用来「替模型说第一句」。

三、客户端:list / read / get

python 复制代码
"""client_demo.py:list / read Resource,list / get Prompt"""
import asyncio
import sys
from mcp import Client, StdioServerParameters

params = StdioServerParameters(command=sys.executable, args=["server.py"])


def dump_text_contents(contents):
    out = []
    for c in contents:
        out.append({
            "uri": str(c.uri) if c.uri is not None else None,
            "mime_type": c.mime_type,
            "text": c.text,
        })
    return out


async def main():
    async with Client(params) as client:
        resources = await client.list_resources()
        print("=== list_resources ===")
        for r in resources.resources:
            print(f"  name={r.name} uri={r.uri} mime={r.mime_type} desc={r.description}")

        templates = await client.list_resource_templates()
        print("=== list_resource_templates ===")
        for t in templates.resource_templates:
            print(f"  name={t.name} uriTemplate={t.uri_template} mime={t.mime_type} desc={t.description}")

        print("=== list again (should NOT call get_config) ===")
        await client.list_resources()

        cfg = await client.read_resource("config://app")
        print("=== read config://app ===")
        print(" ", dump_text_contents(cfg.contents))

        cfg2 = await client.read_resource("config://app")
        print("=== read config://app again ===")
        print(" ", dump_text_contents(cfg2.contents))

        profile = await client.read_resource("users://42/profile")
        print("=== read users://42/profile ===")
        print(" ", dump_text_contents(profile.contents))

        stats = await client.read_resource("stats://catalog")
        print("=== read stats://catalog ===")
        print(" ", dump_text_contents(stats.contents))

        md = await client.read_resource("docs://readme")
        print("=== read docs://readme ===")
        print(" ", dump_text_contents(md.contents))

        prompts = await client.list_prompts()
        print("=== list_prompts ===")
        for p in prompts.prompts:
            args = [(a.name, a.required) for a in (p.arguments or [])]
            print(f"  name={p.name} desc={p.description} args={args}")

        got = await client.get_prompt("review_code", {"code": "def add(a, b): return a + b"})
        print("=== get_prompt review_code ===")
        print(f"  description={got.description}")
        for m in got.messages:
            print(f"  role={m.role} text={m.content.text!r}")

        got2 = await client.get_prompt("debug_error", {"error": "TypeError: 'int' object is not iterable"})
        print("=== get_prompt debug_error ===")
        print(f"  description={got2.description}")
        for m in got2.messages:
            print(f"  role={m.role} text={m.content.text!r}")


asyncio.run(main())

运行结果(stdout):

text 复制代码
=== list_resources ===
  name=get_config uri=config://app mime=text/plain desc=当前应用配置(固定 URI)。
  name=catalog_stats uri=stats://catalog mime=text/plain desc=目录统计(返回 dict,SDK 会序列化成 JSON 文本)。
  name=readme uri=docs://readme mime=text/markdown desc=项目说明(显式声明 mime_type)。
=== list_resource_templates ===
  name=get_user_profile uriTemplate=users://{user_id}/profile mime=text/plain desc=按 user_id 取用户档案(URI 模板)。
=== list again (should NOT call get_config) ===
=== read config://app ===
  [{'uri': 'config://app', 'mime_type': 'text/plain', 'text': 'theme=dark\nlanguage=zh\nmax_retries=3'}]
=== read config://app again ===
  [{'uri': 'config://app', 'mime_type': 'text/plain', 'text': 'theme=dark\nlanguage=zh\nmax_retries=3'}]
=== read users://42/profile ===
  [{'uri': 'users://42/profile', 'mime_type': 'text/plain', 'text': 'User 42: 12 orders since 2024.'}]
=== read stats://catalog ===
  [{'uri': 'stats://catalog', 'mime_type': 'text/plain', 'text': '{\n  "books": 1204,\n  "authors": 391\n}'}]
=== read docs://readme ===
  [{'uri': 'docs://readme', 'mime_type': 'text/markdown', 'text': '# Demo\n\n用 Resource 暴露配置,用 Prompt 暴露模板。'}]
=== list_prompts ===
  name=review_code desc=Review a piece of code.(返回 str → 一条 user 消息) args=[('code', True)]
  name=debug_error desc=Start a debugging conversation.(返回多条消息) args=[('error', True)]
=== get_prompt review_code ===
  description=Review a piece of code.(返回 str → 一条 user 消息)
  role=user text='Please review this code:\n\ndef add(a, b): return a + b'
=== get_prompt debug_error ===
  description=Start a debugging conversation.(返回多条消息)
  role=user text="I'm seeing this error:"
  role=user text="TypeError: 'int' object is not iterable"
  role=assistant text="I'll help debug that. What have you tried so far?"

同一轮的服务端 stderr(只在 read 时出现):

text 复制代码
[server] get_config 被调用,累计 read=1
[server] get_config 被调用,累计 read=2
[server] get_user_profile('42') 被调用

对照一下输出,几件事已经清楚了:

  • users://{user_id}/profile 只出现在 list_resource_templates,不在 list_resources;
  • 两次 list_resources 期间,get_config 一次都没跑;两次 read 才把计数推到 1、2;
  • stats://catalog 返回的是 JSON 文本,但 mime_type 仍是默认的 text/plain;docs://readme 因为显式声明了,才是 text/markdown;
  • review_code 渲染成一条 user;debug_error 渲染成 user / user / assistant 三条。

四、何时用 Resource,何时用 Prompt

结合上面的例子,选型可以更具体一点:

  • 配置、档案、文档、实时统计 → Resource。宿主(IDE、Agent 前端、你自己的编排层)按需 read_resource,再决定要不要塞进模型上下文。模型自己看不到「菜单」,也不会主动去读 URI(除非你另外用 Tool 包一层)。
  • 固定开场白、审查模板、调试脚手架 → Prompt。它是给人填的表单:参数是扁平的字符串列表,没有 Tool 那种 JSON Schema。渲染结果直接进对话,不经过「模型要不要调」这一步。
  • 需要副作用、需要模型自己判断时机 → 还是 Tool。Resource 和 Prompt 都不该偷偷写库、发请求;读侧效应尽量克制。

一句话:Tool 是「模型的手」,Resource 是「应用准备好的资料」,Prompt 是「用户点的快捷短语」。

五、3 个实测踩坑

坑 1:URI 占位符名和函数参数名不一致 → import 就炸

python 复制代码
"""pitfall1_uri_mismatch.py"""
from mcp.server import MCPServer

mcp = MCPServer("mismatch-demo")

@mcp.resource("users://{user_id}/profile")
def get_user_profile(user: str) -> str:  # 故意写成 user,不是 user_id
    """占位符与参数名不一致。"""
    return f"User {user}"
text 复制代码
ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'}

错误发生在装饰器执行时,也就是 import / 启动阶段,客户端根本连不上。这是好事:占位符对不上一定是 bug,SDK 直接拦住。改名时 URI 和参数一起改。

坑 2:以为 list 会执行 Resource 函数

很多人把 Resource 当成「注册时就算好的缓存」。其实 resources/list 只返回元数据(name / uri / mime / description),函数体要等到 resources/read 才跑。用计数器单独验证:

python 复制代码
# pitfall2_server.py 核心
COUNT = 0

@mcp.resource("config://app")
def get_config() -> str:
    global COUNT
    COUNT += 1
    print(f"[server] get_config CALLED, count={COUNT}", file=sys.stderr, flush=True)
    return f"read_count={COUNT}"

客户端连 list 两次、再 read 两次:

text 复制代码
--- first list_resources ---
listed: ['config://app']
--- second list_resources ---
listed: ['config://app']
--- read_resource ---
text: read_count=1
--- read_resource again ---
text: read_count=2

stderr:

text 复制代码
[server] get_config CALLED, count=1
[server] get_config CALLED, count=2

含义:暴露一千个 Resource 也没关系,你只为被打开的那些付费。反过来,如果函数里有「连库 / 扫盘」之类的重活,别指望 list 时预热------要自己做缓存,或在应用启动时主动 read 一次。

坑 3:Prompt 缺 required 参数,不会得到 tool 风格的 isError

Tool 缺参时,客户端拿到的是正常返回 ,is_error=True,错误文本在 content 里,Agent 可以回灌给模型改参数。Prompt 不是这条路:缺 required 参数会整请求抛异常。

同进程对比(pitfall3_server.py 里同时挂了 echo tool 和 review_code prompt):

text 复制代码
=== tool 缺 required 参数 ===
is_error: True
content: Error executing tool echo: 1 validation error for echoArguments
text
  Field req...
=== prompt 缺 required 参数 ===
raised: MCPError
code: -32603
message: Internal server error
repr: MCPError(-32603, 'Internal server error', None)

注意两点:

  1. Prompt 这边没有 is_error 结果可解包,必须 try/except MCPError;
  2. 我这版 mcp 2.3.0 实测错误码是 -32603(Internal server error) ------服务端内部先抛了 ValueError: Missing required arguments: {'code'},再被包装成内部错误。官方文档示例写的是 -32602 Invalid params;写客户端时按「会抛 MCPError」处理即可,不要写死错误码,也不要按 Tool 的 isError 路径去接。

六、小结

  • Tool / Resource / Prompt 三分法:模型调、应用读、用户选;
  • @mcp.resource(uri) 固定地址;URI 带 {placeholder} 就变成模板,走 list_resource_templates;
  • @mcp.prompt() 返回 str 或 UserMessage/AssistantMessage 列表;
  • 客户端对应:list_resources / list_resource_templates / read_resource / list_prompts / get_prompt;
  • 占位符名必须等于参数名;list 不执行函数;Prompt 缺参是异常,不是 isError。

完整代码:server.py、client_demo.py,以及三个踩坑脚本 pitfall1_uri_mismatch.py、pitfall2_list_vs_read.py(+ pitfall2_server.py)、pitfall3_missing_prompt_arg.py(+ pitfall3_server.py),复制到同一目录就能跑。


作者在做 MCP 相关产品,这里记录的是实际开发中踩过的坑。

相关推荐
vjmap17 小时前
AI 智能体调用绘图工具画矢量地图:唯杰 AI 地图绘制上线,支持 MCP 接入 Cursor、Claude Code
gis·agent·mcp
Danny转行做跨境19 小时前
AI搜索时代选品数据采集: MCP比爬虫稳在哪
跨境电商·mcp·sorftime
Danny转行做跨境21 小时前
CLI 怎么批量拉亚马逊选品数据:61 个接口的实测
跨境电商·mcp·sorftime
小七-七牛开发者21 小时前
Agent 支线知识:Personal Agent 如何跨应用完成任务?
ai·agent·工具调用·aiagent·mcp
梦想恒远2 天前
MCP 调不通却不报错:HTTP 200 之后的两种静默失败
mcp
Danny转行做跨境2 天前
亚马逊选品数据源实测: 一个 CLI 调 61 个接口
跨境电商·mcp·sorftime
Whoami!2 天前
F-《AI接口协议:API调模型、MCP接工具、A2A连Agent》
人工智能·mcp·a2a·ai协议
情 九3 天前
MCP 实战 08:查询超时怎么办?给工具加上有限重试
mcp
nujnewnehc3 天前
给记事软件加上 mcp
agent·mcp