EP10. "MCP 客户端实现 + Resources 与 Prompts"
🔒 登录后可标记已读- MCP client 由两部分组成:自己写的
MCPClient类(负责业务逻辑)+ SDK 提供的 Client Session(负责真正管连接、要做好资源清理) - MCP 除了「工具」(Tool,做动作)还有另外两种能力:Resources(暴露数据,像 HTTP 的 GET)和 Prompts(预先写好、调好的指令模板)——三者用途不一样,不要混着用
- Resources 分两种:Direct(固定 URI,比如
docs://documents)跟 Templated(带参数的 URI,比如docs://documents/{doc_id}),后者 SDK 会自动帮你解析参数 - Prompts 让 MCP server 作者把"调好的高质量指令模板"直接交给 client 用,用户不用自己从零现写 prompt,官方说法是这样"效果更一致、质量更高"
- 前置知识:读过 [EP09](./EP09_mcp服务端开发基础.md)(MCP 基本概念、
FastMCP、@mcp.tool、Inspector),这篇笔记接着讲 client 端实现 + Resources/Prompts 这两个额外能力
重点内容
实现 MCP Client
MCP client 由两个部分组成:
- 自己写的
MCPClient类,包着业务逻辑 - SDK 提供的 Client Session,真正负责管理跟 server 的连接,用完要做好资源清理(一般靠
async with处理)
📌 再次强调这条架构原则:"实际项目里,通常只会实现 MCP client 或 MCP server 其中一个,不会两个都做。"——课程两个都写只是为了完整演示互动关系。
Client 承担两件核心工作:
- 拿到可用工具列表,传给 Claude
- Claude 要求调用某个工具时,实际去执行
list_tools 方法:直接呼叫 session 内建方法,把结果里的 tools 数组返回:
async def list_tools(self) -> list[types.Tool]:
result = await self.session().list_tools()
return result.tools
call_tool 方法:接收工具名称和参数字典(来自 Claude 的请求),返回 server 端的执行结果:
async def call_tool(
self, tool_name: str, tool_input: dict
) -> types.CallToolResult | None:
return await self.session().call_tool(tool_name, tool_input)
测试写法:用 async context manager 连上 server,直接呼叫 client 方法验证:
async with MCPClient(
command="uv", args=["run", "mcp_server.py"]
) as client:
result = await client.list_tools()
print(result)
完整应用流程:client 拿到工具定义 → 传给 Claude → Claude 要求调用时,client 透过 server 真正执行 → 把结果传回给 Claude 继续处理。
Resources:暴露数据,不是做动作
Resources 是 MCP server 用「请求-响应」模式,把数据暴露给 client 的另一种机制——概念上更接近 HTTP server 里的 GET handler,专门用来拿信息,不是用来执行动作(执行动作是 Tool 的职责)。
两种 Resource 类型:
| 类型 | URI 写法 | 特点 |
|---|---|---|
| Direct Resource | docs://documents | 固定 URI,不带参数 |
| Templated Resource | docs://documents/{doc_id} | URI 里带参数,Python SDK 自动解析、当函数参数传进去 |
Direct Resource 示范:
@mcp.resource(
"docs://documents",
mime_type="application/json"
)
def list_docs() -> list[str]:
return list(docs.keys())
Templated Resource 示范:
@mcp.resource(
"docs://documents/{doc_id}",
mime_type="text/plain"
)
def fetch_doc(doc_id: str) -> str:
if doc_id not in docs:
raise ValueError(f"Doc with id {doc_id} not found")
return docs[doc_id]
几个技术细节:
@mcp.resource()装饰器同时定义 URI 和 MIME type- 常见 MIME type 有
application/json、text/plain,也能用其他合法格式 - SDK 会自动处理序列化,不用自己手动转 JSON
- 一样可以用
uv run mcp dev mcp_server.py起 MCP Inspector 来测试
💡 实用场景:文档「@ 提及」功能——用户打 @ 时列出可用文档(对应 Direct Resource),选中某份文档后,把它的内容注入 prompt(对应 Templated Resource),两种 Resource 配合着用。
Client 端读取 Resource
Resource 的价值在于:server 能把数据直接塞进 prompt 里,不用像 Tool 那样绕一圈"调用-返回"的流程,给 Claude 提供上下文更直接。
Client 在中间做的事:处理跟 MCP server 的通讯、自动解析数据。用户在输入框打出 "@report.pdf" 这类引用时,应用会透过 MCP client 抓这份 Resource,把内容塞进要发给 Claude 的 prompt 里。
核心函数 read_resource:
async def read_resource(self, uri: str) -> Any:
result = await self.session().read_resource(AnyUrl(uri))
resource = result.contents[0]
回应里的 contents 是个列表,一般只需要取第一个元素。
根据 MIME type 处理不同内容类型:
if isinstance(resource, types.TextResourceContents):
if resource.mimeType == "application/json":
return json.loads(resource.text)
return resource.text
Resource 可能返回不同格式的内容,靠 MIME type 判断该用什么方式 parse。
要用到的 import:
import json
from pydantic import AnyUrl
测试方式:用户输入类似 "What's in the @report.pdf document?" 这种带 @ 引用的问句,触发自动补全 → 选中对应 Resource → 自动抓取内容 → 塞进 prompt——整个过程完全不需要走 Tool Call。
Prompts:预先写好、调好的指令模板
MCP server 上的 Prompts,让 server 作者能定义"预先写好、经过打磨的高质量指令模板",client 直接拿来用,不用自己从零现写 prompt。
📌 官方说法:"用户理论上自己也能完成这些任务,但用 MCP server 作者精心开发、测试过的 prompt,通常能拿到更一致、更高质量的结果。"
Prompt 的运作方式:函数返回一份消息列表,直接供 Claude 使用。要用到:
- 引入基础消息类型:
from mcp.server.fastmcp import base - 用
@mcp.prompt()装饰器,带上name和description - 函数返回一份组成完整 prompt 的消息列表
示范:文档格式化 Prompt:
@mcp.prompt(
name="format",
description="Rewrites the contents of the document in Markdown format."
)
def format_document(
doc_id: str = Field(description="Id of the document to format")
) -> list[base.Message]:
prompt = f"""
Your goal is to reformat a document to be written with markdown syntax.
The id of the document you need to reformat is:
{doc_id}
Add in headers, bullet points, tables, etc as necessary. Feel free to add in extra formatting.
Use the 'edit_document' tool to edit the document. After the document has been reformatted...
"""
return [
base.UserMessage(prompt)
]
写 Prompt 的最佳实践:
- 聚焦在跟这个 server 核心用途相关的任务上
- 指令要写得详细、具体
- 用各种不同输入充分测试
- 附上清楚的、给用户看的说明(
description) - 考虑跟 server 自己的 tools、resources 怎么配合使用(比如上面这个例子最后一步就叫 Claude 用
edit_document这个 tool)
Client 端使用 Prompts
列出所有可用 Prompt:
async def list_prompts(self) -> list[types.Prompt]:
result = await self.session().list_prompts()
return result.prompts
取出某个具体 Prompt(带参数内插):
async def get_prompt(self, prompt_name, args: dict[str, str]):
result = await self.session().get_prompt(prompt_name, args)
return result.messages
参数怎么传:Server 端的 prompt 函数本身可以接参数——比如上面那个文档格式化 prompt 就要一个 doc_id。Client 调 get_prompt 时,args 字典里要带上对应的 key,MCP server 会把这些值当关键字参数传给 prompt 函数。
CLI 测试流程,三步:
- 用户选一个 prompt(比如
"format") - 系统询问这个 prompt 需要的参数
- 把内插好参数的完整 prompt 发给 Claude
最佳实践总结:Prompt 要跟 server 本身的用途相关、要充分测试过、写清楚、围绕 server 已有的 tools 设计、同时要考虑用户需要填哪些参数。
常见错误
- ❌ 把 Resource 当 Tool 用——要"拿数据"该用 Resource(像 GET),需要"执行动作/产生副作用"才该用 Tool;用错类型会让架构变得混乱
- ❌ Client Session 用完不做资源清理——用
async with管好生命周期,避免连接泄漏 - ❌ 处理 Resource 内容时没检查 MIME type 就直接当纯文字处理——
application/json类型的内容要先json.loads(),不能直接当字符串用 - ❌ Prompt 函数需要的参数,client 端
get_prompt没有对应传全——参数名对不上,server 端拿不到预期的值,模板内插会出错 - ❌ 以为一个项目要同时把 client 和 server 都做全——真实项目通常只做一边,另一边用现成/第三方的实现就够了
Sources
官方文档:
- Building with the Claude API(Claude Academy 课程)— https://academy.claude.com/courses/building-with-the-claude-api