前几篇把 MCP 的 Tool 写熟了:模型决定调不调、传什么参、怎么处理结果。但 MCP 其实还有两个原语,很多教程一笔带过:
- Resource:数据暴露给应用/宿主,由应用决定何时加载进上下文;
- 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)
注意两点:
- Prompt 这边没有
is_error结果可解包,必须try/except MCPError; - 我这版 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 相关产品,这里记录的是实际开发中踩过的坑。