MCP 协议设计与实现
第7章 Prompt:可复用的交互模板
第7章 Prompt:可复用的交互模板
“A good prompt is not just a sentence — it is a reusable contract between the user and the model.”
“一个好的 Prompt 不只是一句话——它是用户和模型之间可复用的契约:双方都知道这句话代表什么,每一次调用都能给出可预期的输出。”
本章要点
- 理解 Prompt 在 MCP 三大原语中的独特定位:用户控制平面
- 掌握 Prompt 的完整数据结构:name、description、arguments、messages
- 学会使用动态参数让 Prompt 成为真正的可编程模板
- 理解 Prompt 中嵌入资源(Embedded Resource)的机制及其客户端渲染意义
- 对比 TypeScript SDK 和 Python SDK 中
prompt()方法的注册方式 - 通过代码审查、Bug 报告、数据分析三个模板,建立实战直觉
- 掌握 6 条 Prompt 设计指南与 5 种常见反模式
7.1 为什么需要 Prompt
7.1.1 一位工程师一天的重复劳动
先来看一组真实的数字。我统计过一位资深工程师使用 AI 编程助手一天的输入——共发起 47 次对话,其中有 19 次,开头几乎一字不差:
“请帮我审查以下代码,从安全性、性能、可读性三个维度分析问题并给出改进建议……”
还有 8 次以这样开头:
“请帮我写一份 bug 报告,要包含现象、复现步骤、期望行为、实际行为、可能的原因分析……”
每天多次、每次数十字、几乎完全相同。你会意识到:这些重复的”开场白”本质上是一种未被抽象的接口。
在前面两章中,我们已经认识了 MCP 的另外两个原语:Tool 让模型能够执行操作,Resource 让应用程序能够提供上下文。但有一个关键角色被忽略了——用户自己的高频输入模式。
Prompt 就是为这个场景而生。它本质上是服务器预定义的、用户可选择的交互模板——类似于聊天工具中的斜杠命令(/review、/summarize),用户选中后,客户端从服务器获取完整的消息模板,填入参数,直接发送给模型。
7.1.2 从 GitHub Copilot Chat 的 /explain 说起
如果你用过 GitHub Copilot Chat,一定见过它的几个内置斜杠命令:/explain、/fix、/doc、/tests。这些命令背后做的事情——在你的查询前加一段标准化的引导语——正是 Prompt 的核心理念。
Copilot Chat 把这些斜杠命令写死在客户端里;而 MCP 的创新是把”定义斜杠命令的权力”下放给 Server。这样一来:
- 每个领域的工具开发者都可以定义自己的斜杠命令
- 用户打开一个数据库客户端,立刻就有
/analyze_table、/suggest_index等命令 - 打开文档编辑器,就有
/outline、/summarize_chapter等命令 - 这些命令是跨 AI 客户端通用的——用 Claude Desktop 或 Cursor 都能看到
关键词是用户控制。与 Tool 不同(Tool 由模型决定何时调用),Prompt 的触发权完全在用户手中。用户看到可用的 Prompt 列表,主动选择要使用哪一个,填入必要的参数,然后发起对话。
7.2 三大控制平面:设计哲学
7.2.1 三原语的控制权划分
在深入 Prompt 的技术细节之前,先从宏观视角理解 MCP 的设计哲学。MCP 定义了三种原语,每一种对应一个不同的控制平面:
graph TB
subgraph MCP["MCP 三大原语"]
direction TB
T["Tool<br/>工具"]
R["Resource<br/>资源"]
P["Prompt<br/>提示模板"]
end
M["AI 模型"] -->|"模型决定调用"| T
A["应用程序"] -->|"应用程序管理"| R
U["用户"] -->|"用户主动选择"| P
T -->|"执行操作<br/>写数据库、调 API"| S1["MCP Server"]
R -->|"提供上下文<br/>文件、数据库记录"| S2["MCP Server"]
P -->|"获取模板<br/>预定义的消息序列"| S3["MCP Server"]
style T fill:#f59e0b,color:#fff,stroke:none
style R fill:#3b82f6,color:#fff,stroke:none
style P fill:#10b981,color:#fff,stroke:none
style M fill:#8b5cf6,color:#fff,stroke:none
style A fill:#ec4899,color:#fff,stroke:none
style U fill:#6366f1,color:#fff,stroke:none
| 原语 | 控制者 | 类比 | 典型交互 | 安全信任级别 |
|---|---|---|---|---|
| Tool | AI 模型 | 函数调用 | 模型判断需要查询数据库,自动调用 query_db | 中(模型可能误判) |
| Resource | 应用程序 | 文件附件 | IDE 将当前打开的文件作为上下文附加到对话中 | 中(应用程序策略决定) |
| Prompt | 用户 | 斜杠命令 | 用户输入 /review,选择代码审查模板 | 高(用户主动明示) |
这三个控制平面的划分并非随意为之。它反映了一个核心设计原则:不同类型的决策应该由最合适的主体来做。
- 模型擅长判断何时需要工具辅助——它能看到当前对话的意图
- 应用程序知道当前的工作上下文是什么——IDE 知道用户打开了哪个文件
- 用户最清楚自己的意图和工作流程——他知道自己今天要做代码审查还是写 bug 报告
7.2.2 Prompt 控制平面的交互五步曲
Prompt 属于用户控制平面,意味着整个流程是:
sequenceDiagram
participant U as 用户
participant C as MCP Client
participant S as MCP Server
participant M as LLM
S->>C: (已声明 prompts 能力)
C->>S: prompts/list
S-->>C: [code_review, bug_report, analyze_table, ...]
C->>U: 展示斜杠命令菜单<br/>(UI: /, 自动补全列表)
U->>C: 输入 "/review", 选择 code_review
C->>U: 弹出参数表单<br/>(code: ?, language: ?)
U->>C: 填入 code="...", language="python"
C->>S: prompts/get<br/>(name, arguments)
Note over S: 动态生成消息<br/>(可能查数据库、读文件)
S-->>C: GetPromptResult<br/>{ messages: [...] }
C->>C: 把 messages 插入对话上下文
C->>M: 发送对话(含模板消息)
M-->>C: LLM 响应
C->>U: 展示结果
五个关键时刻:
- 声明:Server 通过
prompts能力声明支持 Prompt - 枚举:Client 通过
prompts/list拉取 Server 的可用 Prompt 列表 - 选择:用户在 UI(斜杠菜单)中选择某个 Prompt
- 获取:Client 通过
prompts/get获取填入参数后的消息序列 - 发送:消息序列被插入对话上下文,正常发送给 LLM
7.3 Prompt 的数据结构
7.3.1 Prompt 定义(元信息)
一个 Prompt 在协议层面由以下字段描述:
{
"name": "code_review",
"title": "Request Code Review",
"description": "Asks the LLM to analyze code quality and suggest improvements",
"arguments": [
{
"name": "code",
"description": "The code to review",
"required": true
},
{
"name": "language",
"description": "Programming language of the code",
"required": false
}
]
}
各字段的含义:
| 字段 | 必填 | 含义 | UI 用途 |
|---|---|---|---|
name | 是 | Prompt 的唯一标识符 | 斜杠命令名(如 /code_review) |
title | 否 | 人类可读标题 | 菜单项显示文本(“代码审查”) |
description | 否 | 描述 | 悬停提示、菜单次行文字 |
arguments | 否 | 参数列表 | 参数表单的字段 |
每个参数又有:
name:字段名description:字段说明(展示给用户)required:是否必填
7.3.2 Prompt 结果:消息数组
当客户端调用 prompts/get 获取一个 Prompt 时,服务器返回的是一个 GetPromptResult,其核心是一个 messages 数组:
{
"description": "Code review prompt",
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Please review this Python code:\ndef hello():\n print('world')"
}
}
]
}
每条消息(PromptMessage)包含两个字段:
role:"user"或"assistant"。Prompt 可以预设多轮对话——比如先用一条assistant消息设定角色(“你是一位资深代码审查专家”),再用user消息提出具体请求。content:消息内容,支持三种类型——文本(text)、图片(image)和嵌入资源(resource)。
7.3.3 三种 content 类型
文本内容是最常见的类型:
{
"type": "text",
"text": "请从安全性、性能、可读性三个维度审查以下代码……"
}
图片内容支持多模态交互,数据必须经过 Base64 编码:
{
"type": "image",
"data": "base64-encoded-image-data",
"mimeType": "image/png"
}
嵌入资源是 Prompt 与 Resource 原语的交汇点,我们在 7.6 节详细讨论。
三种类型的对比:
| 类型 | 适合场景 | 体积 | 客户端处理方式 |
|---|---|---|---|
| text | 文字指令、代码片段 | 小 | 直接作为 LLM 消息发送 |
| image | 截图、图表引用 | 大(base64 膨胀 33%) | 多模态 LLM 直接输入 |
| resource | 文件、数据库记录、任何带 URI 的资源 | 中 | UI 折叠展示、可重新下载 |
7.4 协议消息流
7.4.1 能力声明
服务器在初始化阶段必须声明 prompts 能力:
{
"capabilities": {
"prompts": {
"listChanged": true
}
}
}
listChanged 表示服务器是否会在 Prompt 列表变化时发送通知。如果为 true,客户端可以在收到 notifications/prompts/list_changed 通知后重新拉取列表。
什么时候会发生列表变化?典型场景:
- 用户在数据库客户端切换了连接——新数据库上有不同的
/analyze_XPrompt - 管理员在后台给某些 Prompt 打了”禁用”标
- 新插件被动态加载,带来了新的 Prompt
7.4.2 发现与使用
Prompt 的交互流程分为两步:先发现(list),再使用(get)。
sequenceDiagram
participant User as 用户
participant Client as MCP Client
participant Server as MCP Server
Note over Client,Server: 第一步:发现可用 Prompt
Client->>Server: prompts/list
Server-->>Client: 返回 Prompt 列表<br/>[code_review, bug_report, ...]
Client->>User: 展示斜杠命令菜单
Note over User,Server: 第二步:用户选择并使用
User->>Client: 选择 /code_review<br/>填入 code 参数
Client->>Server: prompts/get<br/>name="code_review"<br/>arguments={code: "..."}
Server-->>Client: 返回 messages 数组
Client->>Client: 将 messages 插入对话
Client->>Server: 正常的 LLM 对话流程
opt Prompt 列表变更
Server--)Client: notifications/prompts/list_changed
Client->>Server: prompts/list
Server-->>Client: 更新后的 Prompt 列表
end
prompts/list 请求用于获取服务器所有可用的 Prompt,支持分页(通过 cursor 参数):
{
"jsonrpc": "2.0",
"id": 1,
"method": "prompts/list",
"params": {
"cursor": "optional-cursor-value"
}
}
prompts/get 请求用于获取特定 Prompt 的消息内容:
{
"jsonrpc": "2.0",
"id": 2,
"method": "prompts/get",
"params": {
"name": "code_review",
"arguments": {
"code": "def hello():\n print('world')"
}
}
}
服务器收到请求后,根据参数动态生成消息数组并返回。这里的”动态生成”是关键——Prompt 不是静态文本,而是由服务器端代码根据参数实时构造的。
7.5 动态 Prompt:参数作为变量
7.5.1 从静态模板到可编程模板
最朴素的 Prompt 就是”拼字符串”——把参数塞到固定模板中:
def code_review(code: str) -> list:
return [{"role": "user", "content": f"Please review:\n{code}"}]
但 MCP Prompt 的真正威力在于:它可以在生成消息的过程中执行任意服务器端逻辑:
- 查询数据库获取表 schema
- 读取文件内容
- 调用第三方 API
- 查询向量数据库做 RAG
- 根据用户的历史行为定制化
以一个数据分析 Prompt 为例。用户选择 /analyze_table,填入表名 users,服务器端的处理逻辑可能是:
- 根据表名查询数据库的表结构(schema)
- 采样若干行数据
- 计算基础统计(行数、null 比例、唯一值数等)
- 将 schema、样本、统计组装成消息
返回的 messages 可能像这样:
{
"messages": [
{
"role": "assistant",
"content": {
"type": "text",
"text": "我是一位数据分析专家,擅长从数据中发现洞察。"
}
},
{
"role": "user",
"content": {
"type": "text",
"text": "请分析 users 表。\n\n表结构:\nid INTEGER PRIMARY KEY\nname TEXT NOT NULL\nemail TEXT UNIQUE\ncreated_at TIMESTAMP\n\n样本数据:\n| id | name | email | created_at |\n|----|------|-------|------------|\n| 1 | 张三 | zhang@ex.com | 2024-01-15 |\n| 2 | 李四 | li@ex.com | 2024-02-20 |\n\n基础统计:\n- 总行数:12847\n- created_at 最早:2023-06-12"
}
}
]
}
注意这里的多轮结构:第一条 assistant 消息为模型设定了角色,第二条 user 消息包含了服务器从数据库中实时查询到的结构和数据。用户只需要输入一个表名,服务器完成了所有繁重的上下文准备工作。
7.5.2 动态 Prompt 的五种常见模式
| 模式 | 说明 | 典型场景 |
|---|---|---|
| 数据采集型 | Server 查数据源后塞进上下文 | 分析数据库表、读取配置 |
| RAG 增强型 | Server 用参数做向量搜索,注入相关文档 | 基于知识库回答、智能搜索 |
| 角色设定型 | 根据参数设定不同的 assistant 角色 | 多角色模拟、专家咨询 |
| Few-shot 组装型 | Server 从历史中挑选最相似的 N 个例子 | 格式化生成、风格学习 |
| 预处理型 | Server 对参数做清洗/重写再塞入 | 安全过滤、格式规范化 |
每一种模式都不是”能不能做”的问题,而是让 Server 承担起”上下文工程”的责任,Client 只负责收集用户参数和展示结果。
7.6 嵌入资源:Prompt 与 Resource 的交汇
7.6.1 为什么要嵌入资源
Prompt 消息中不仅可以包含纯文本,还可以嵌入 MCP Resource。这意味着 Prompt 可以引用服务器管理的文件、文档、代码等资源,将它们直接注入到对话中。
嵌入资源的消息格式:
{
"role": "user",
"content": {
"type": "resource",
"resource": {
"uri": "file:///project/src/main.py",
"mimeType": "text/x-python",
"text": "import os\nimport sys\n\ndef main():\n ..."
}
}
}
资源内容可以是文本(text 字段),也可以是二进制数据(blob 字段,Base64 编码)。必须包含有效的 URI 和 MIME 类型。
7.6.2 纯文本 vs 嵌入资源的差别
既然可以把文件内容塞进 text 字段,为什么还要专门的 resource 类型?
| 维度 | text 类型 | resource 类型 |
|---|---|---|
| URI 信息 | 无 | 保留 |
| MIME 类型 | 无 | 保留 |
| 客户端渲染 | 作为普通文本 | 可折叠、语法高亮、可重新加载 |
| LLM 的感知 | 扁平的一大段字 | 可以明确”这是来自某资源的内容” |
| 重用可能性 | 每次请求都传全文 | 客户端可缓存 URI 对应内容 |
| 可追溯性 | 无 | 用户可点击查看来源 |
考虑一个代码审查 Prompt:用户选择 /review,传入文件路径作为参数,服务器读取文件内容,以嵌入资源的方式返回。这样做的好处是——客户端可以识别出这是一个资源引用,在 UI 中以特殊方式展示(比如显示为可折叠的代码块,带有文件名和语法高亮),而不是一堆原始文本。
这是一种数据血缘的传递——消息里不只有”内容”,还有”内容的身份”。
7.7 SDK 实现:TypeScript 与 Python
7.7.1 TypeScript SDK
在 TypeScript SDK 中,通过 McpServer 类的 registerPrompt 方法注册 Prompt:
import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';
const server = new McpServer({
name: 'my-server',
version: '1.0.0'
});
server.registerPrompt(
'review-code',
{
title: 'Code Review',
description: 'Review code for best practices',
argsSchema: z.object({
code: z.string(),
language: z.string().optional()
})
},
({ code, language }) => ({
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: `Please review this ${language ?? ''} code:\n\n${code}`
}
}
]
})
);
几个值得注意的设计点:
argsSchema使用 Zod:TypeScript SDK 使用 Zod 等 Standard Schema 兼容库定义参数结构,框架自动将其转换为 JSON Schema 暴露给客户端,并在请求到达时执行校验。- 回调函数返回
GetPromptResult:回调接收经过校验的参数,返回包含messages数组的对象。 - 注册时自动处理列表和获取:
registerPrompt内部同时设置了prompts/list和prompts/get的请求处理器,开发者无需手动处理协议细节。
7.7.2 Python SDK
Python SDK 使用装饰器风格注册 Prompt,更加简洁:
from mcp.server.mcpserver import MCPServer
server = MCPServer(name="my-server")
@server.prompt()
def analyze_table(table_name: str) -> list[Message]:
"""Analyze a database table structure and content."""
schema = read_table_schema(table_name)
return [
{
"role": "user",
"content": f"Analyze this schema:\n{schema}"
}
]
@server.prompt()
async def analyze_file(path: str) -> list[Message]:
"""Analyze a file with embedded resource."""
content = await read_file(path)
return [
{
"role": "user",
"content": {
"type": "resource",
"resource": {
"uri": f"file://{path}",
"text": content
}
}
}
]
Python SDK 的设计特点(对应 src/mcp/server/mcpserver/prompts/base.py 与 server.py:747):
1. 装饰器括号强校验——@server.prompt() 的括号不能省略。server.py:791-796 有一个主动的 TypeError 守卫:
# server.py:791
# Check if user passed function directly instead of calling decorator
if callable(name):
raise TypeError(
"The @prompt decorator was used incorrectly. "
"Did you forget to call it? Use @prompt() instead of @prompt"
)
写 @server.prompt(不带括号)时 Python 会把被装饰的函数直接作为 name 参数传进去——这个 callable(name) 检查正好抓住这种误用,给出精准诊断,不让它继续跑出个难以理解的下游错误。
2. 从函数签名自动推导 arguments——Prompt.from_function(base.py:78)的核心流程:
# base.py:106
func_arg_metadata = func_metadata(fn, skip_names=[...])
parameters = func_arg_metadata.arg_model.model_json_schema()
for param_name, param in parameters["properties"].items():
required = param_name in parameters.get("required", [])
arguments.append(PromptArgument(
name=param_name,
description=param.get("description"),
required=required,
))
func_metadata 用 Pydantic 根据函数签名生成一个动态 model,再从 model 的 JSON Schema 里拉出参数列表。“没有默认值的参数 → required=True”这个判断就从 schema 的 required 字段里读——完全跟 Pydantic 规则走。这意味着在函数签名里写 table_name: str(无默认值)和 table_name: str = "users"(有默认值),装饰器暴露给 MCP 客户端的 arguments 就会有不同的 required 值。
3. 返回类型的四种多态——base.py:55 给出了返回值的类型别名:
SyncPromptResult = str | Message | dict[str, Any] | Sequence[...]
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
render 函数(base.py:166-183)做规整化:
- 返回
str→ 包成UserMessage(TextContent(text=...)) - 返回
Message对象 → 原样保留 - 返回
dict→ 过message_validator.validate_python(msg),Pydantic 校验 - 返回任一上述类型的序列 → 逐元素处理
- 其它类型 →
pydantic_core.to_json序列化后包成 user message(兜底)
这种”返回什么都能工作”的设计让最常见的简单场景(单轮静态 prompt)只需 return "你的 prompt 文本" 一行,而复杂场景(多轮对话、嵌入资源、assistant 预填)可以用完整的 Message 列表。复杂度按需支付。
4. 同步函数自动进线程池——如果装饰的函数不是 async,base.py:164 会用 anyio 把它扔进线程:
if is_async_callable(fn):
result = await fn(**call_args)
else:
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args))
这个细节不起眼但重要:MCP 服务器跑在 asyncio 事件循环上,如果开发者写了个同步 prompt 函数里调了 time.sleep 或 requests.get(阻塞 I/O),直接 await 会卡住整个事件循环。anyio.to_thread.run_sync 把它发到 anyio 的默认线程池(大小由 anyio.CapacityLimiter 控制),其他并发请求不受影响。代价是跨线程数据传递有 GIL 开销——所以原生 async 的 prompt 仍然是性能更优的选择。
5. Context 参数自动注入——base.py:102-103 调 find_context_parameter(fn) 扫描函数签名:
if context_kwarg is None:
context_kwarg = find_context_parameter(fn)
如果函数里有类型为 Context 的参数,框架会记录它的名字(context_kwarg),渲染时调 inject_context 自动填入当前请求的 Context 对象(携带 request_id、logger、lifespan 资源等)。这让 prompt 函数可以写成:
@server.prompt()
async def analyze_table(table_name: str, ctx: Context) -> str:
await ctx.info(f"Analyzing {table_name}")
schema = await ctx.lifespan.db.get_schema(table_name)
return f"Schema:\n{schema}"
ctx 不会暴露给客户端的 arguments 列表(skip_names=[context_kwarg] 把它从 schema 排除)——对客户端透明,对 prompt 实现者却是隐式的能力通道。
6. 必填参数缺失时的 ValueError——base.py:149-154:
if self.arguments:
required = {arg.name for arg in self.arguments if arg.required}
provided = set(arguments or {})
missing = required - provided
if missing:
raise ValueError(f"Missing required arguments: {missing}")
这个校验在 Pydantic 的 validate_call 之前就拦住——客户端少传参数时拿到的错误是 MCP 协议层面的 ValueError,而不是 Pydantic 的 ValidationError 堆栈——更适合面向客户端。拦截顺序是 “必填检查 → context 注入 → validate_call 校验类型 → 真正执行”,层层把关。
7. 可选参数——装饰器本身接受 name(默认函数名)、title、description(默认从函数 docstring 取——base.py:131 的 description or fn.__doc__ or "")、icons。docstring 作为 description 的默认来源是 Python SDK 一个很 Pythonic 的约定——你写函数文档字符串的时候就已经在给 MCP 客户端写说明。
7.7.3 两种 SDK 的对比
| 维度 | TypeScript SDK | Python SDK |
|---|---|---|
| 注册方式 | registerPrompt() 方法 | @server.prompt() 装饰器 |
| 参数定义 | 显式 argsSchema(Zod) | 从函数签名自动推导 |
| 返回类型 | GetPromptResult 对象 | list[Message] |
| 变更通知 | sendPromptListChanged() | 框架自动处理 |
| 禁用支持 | enabled 属性 | 通过 PromptManager 管理 |
| 补全(completion) | completable() 包装器 | 参数 metadata |
两种 SDK 的哲学差异:TypeScript 偏向”显式声明 + 类型安全”,Python 偏向”函数签名即接口 + 约定胜于配置”。这呼应了两种语言社区的设计文化。
7.8 实战用例
7.8.1 代码审查模板
@server.prompt(
name="code_review",
description="Multi-dimensional code review"
)
def code_review(code: str, language: str = "python") -> list[Message]:
return [
{
"role": "assistant",
"content": "I am a senior code reviewer. I will analyze code "
"from three perspectives: security, performance, "
"and readability."
},
{
"role": "user",
"content": f"Please review the following {language} code. "
f"For each issue found, indicate its severity "
f"(critical/warning/info) and provide a fix.\n\n"
f"```{language}\n{code}\n```"
}
]
这个模板的设计要点:
- 先用
assistant消息设定专家角色——比在 user 消息里说”请扮演一个专家”效果好得多 - 再用
user消息提出结构化的审查请求(三个维度 + 严重度分级) language参数有默认值,为可选参数- 返回格式要求明确(每个 issue 要带 severity 和 fix),方便下游工具解析
7.8.2 Bug 报告模板
@server.prompt(
name="bug_report",
description="Generate a structured bug report"
)
def bug_report(
title: str,
steps: str,
expected: str,
actual: str
) -> list[Message]:
return [
{
"role": "user",
"content": f"Please help me write a detailed bug report.\n\n"
f"**Title**: {title}\n"
f"**Steps to reproduce**:\n{steps}\n"
f"**Expected behavior**: {expected}\n"
f"**Actual behavior**: {actual}\n\n"
f"Please analyze the possible root cause and "
f"suggest debugging approaches."
}
]
这个模板将用户的碎片化输入(标题、步骤、期望行为、实际行为)组装成结构化的 Bug 报告,同时请求模型分析根因。四个参数全部为必填。
设计精髓:强迫用户先把事情说清楚。很多 Bug 报告混乱是因为报告者没把”期望”和”实际”区分开。这个模板用参数表单强制结构化。
7.8.3 数据分析工作流
server.registerPrompt(
'analyze-dataset',
{
title: 'Dataset Analysis',
description: 'Comprehensive analysis of a dataset',
argsSchema: z.object({
table_name: z.string(),
focus: z.enum(['trends', 'anomalies', 'correlations']).optional()
})
},
async ({ table_name, focus }, ctx) => {
// 服务器端动态查询数据库
const schema = await getTableSchema(table_name);
const stats = await getBasicStats(table_name);
return {
messages: [
{
role: 'assistant' as const,
content: {
type: 'text' as const,
text: 'I am a data analyst. I will provide insights based on the data structure and statistics.'
}
},
{
role: 'user' as const,
content: {
type: 'resource' as const,
resource: {
uri: `db://schemas/${table_name}`,
mimeType: 'application/json',
text: JSON.stringify(schema, null, 2)
}
}
},
{
role: 'user' as const,
content: {
type: 'text' as const,
text: `Basic statistics:\n${JSON.stringify(stats, null, 2)}\n\n` +
`Please perform a comprehensive analysis` +
(focus ? `, focusing on ${focus}` : '') + '.'
}
}
]
};
}
);
这个示例展示了 Prompt 的高级用法:
- 异步回调中执行数据库查询
- 结合嵌入资源和文本内容构建多条消息
- 可选的
focus参数控制分析方向 - 三条消息:角色设定 → 结构资源 → 统计数据 + 指令
这是一个典型的”Prompt 即工作流”的例子。Server 在 prompts/get 的一次调用里,完成了数据库查询、数据聚合、消息组装的所有工作。
7.9 错误处理
服务器在处理 Prompt 请求时应返回标准的 JSON-RPC 错误码:
-32602(Invalid params):Prompt 名称不存在,或缺少必填参数。在两个 SDK 中,如果请求的 Prompt 未注册或已被禁用,框架会自动返回此错误。-32603(Internal error):服务器内部处理错误,比如回调函数中的数据库查询失败。
从 TypeScript SDK 源码可以看到具体的错误处理逻辑:
const prompt = this._registeredPrompts[request.params.name];
if (!prompt) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Prompt ${request.params.name} not found`
);
}
if (!prompt.enabled) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Prompt ${request.params.name} disabled`
);
}
除了 not found 之外,还有 disabled 状态——这意味着 Prompt 可以被注册但暂时禁用,客户端在 prompts/list 中将不会看到它。
7.9.1 用户可见的错误文案
错误码是给程序看的,用户看到的是 Client 翻译后的文案。好的 Client 应该:
Prompt not found→ “该命令已不存在,可能是 Server 版本变化,请刷新”Missing required argument: code→ “请填写必填字段:code”Internal error→ “服务器处理失败,请稍后重试” + 底下展开技术详情
7.10 Prompt 的 5 种反模式
在看了正确做法后,也要了解什么是错误做法。以下是我在 Code Review 中反复见过的反模式:
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 参数列表过长(10+ 参数) | 用户填表疲劳 | 拆成多个专门 Prompt,或引入”高级设置” |
| 硬编码敏感信息到 Prompt | Token 泄露到用户/日志 | 通过 Elicitation URL Mode 收集 |
| Prompt 内部做不可逆操作 | 用户不小心选中就删了库 | Prompt 只构造消息,Tool 才做操作 |
| 返回的 messages 超大 | LLM 上下文爆炸 | 用嵌入资源 + 真正需要时再 read |
| 用户需要记住参数顺序 | 体验差 | 用带 name 的参数对象,Client 渲染为表单 |
7.11 设计指南
在实际开发 MCP Server 时,设计 Prompt 应遵循以下原则:
1. Prompt 是用户意图的快捷方式,不是万能工具。 如果一个操作应该由模型自主决定是否执行,它应该是 Tool 而不是 Prompt。如果一个数据源应该自动附加到对话中,它应该是 Resource 而不是 Prompt。
2. 参数命名要直观。 用户在填写参数时需要理解每个参数的含义。code 比 input_data 更好,language 比 lang_type 更好。description 字段不要省略。
3. 善用多轮消息结构。 assistant 角色的预设消息可以有效引导模型的行为模式。一条好的角色设定消息,往往比在 user 消息中反复强调”你是一个专家”更有效。
4. 考虑嵌入资源的时机。 当你的 Prompt 需要引用文件、数据库记录或其他结构化数据时,优先使用嵌入资源而非纯文本。嵌入资源让客户端能够更智能地处理和展示这些内容。
5. 校验参数。 服务器在处理 prompts/get 时应验证所有必填参数是否已提供,参数值是否合法。SDK 会帮你完成类型级别的校验,但业务级别的校验(比如表名是否存在)需要你自己处理。
6. 给 Prompt 起一个好记的 name。 记住,用户是要在斜杠菜单里看到这个名字并选择的。code_review 比 cr 好(太短难懂),比 multi_dimensional_code_review_v2 好(太长)。把 Prompt name 当命令行工具命名对待。
7.11.1 实测:两 SDK Prompt 实现的代码组织对比
§7.7.3 给出 SDK 对比表——把 Prompt 实现在两 SDK 里的代码组织实测——
TS SDK——整个 Prompt 功能塞在 packages/server/src/server/mcp.ts(1329 行)内部——
| 位置 | 行号 | 角色 |
|---|---|---|
setupPromptHandlers() 内部 | mcp.ts:514-555 | prompts/list + prompts/get 两个 handler 注册 |
registerPrompt() public method | mcp.ts:922 起 | 注册逻辑 + Standard Schema 包装 + handler 工厂 |
| TS SDK Prompt 总代码 | 估算 ~120 行 | 没有独立 prompts/ 目录 |
Python SDK——独立子目录 src/mcp/server/mcpserver/prompts/——
| 文件 | 行 | 角色 |
|---|---|---|
base.py | 189 | Prompt Pydantic 类 + 函数签名→arguments schema 的反射推导 + _convert_to_message 把 callback 返回值规整为 list[PromptMessage] |
manager.py | 59 | PromptManager——add/list/get + warn_on_duplicate_prompts 选项 |
__init__.py | 4 | export |
| Python SDK Prompt 总代码 | 252 | — |
Plus 顶层 mcp/server/mcpserver/server.py 1112 行 里 @server.prompt() 装饰器实现(line 747)+ _prompt_manager.add_prompt 调用(line 745)+ prompts/get handler dispatcher(line 1100)——估算 ~50 行额外代码。
Python SDK Prompt 实现总规模约 302 行 vs TS SDK 约 120 行——Python 是 TS 的 2.5 倍——和 §15.9.4 实测的 OAuth (Python 客户端 vs TS 客户端 1.5x)、§16.9.1 实测的 OAuth Discovery (Python utils.py vs TS auth.ts 内联) 同款规律——Python 倾向把单一功能拆成 base + manager + 顶层装饰器分离、TS 倾向把所有内容塞在一个大 mcp.ts 里。
§7.7.3 对比表”参数定义”那一行的源码证据——
- TS:
registerPrompt<Args extends StandardSchemaWithJSON>(...)(mcp.ts:922)—— 泛型 + Standard Schema 显式声明 - Python:
Prompt.from_function(fn)(base.py 内部)通过inspect.signature(fn)反射函数参数自动推导——和 ch08 §8.4.4 LangChain@tool装饰器从函数签名推断 schema 同款”Python 函数签名即接口”哲学
§7.7.3 对比表”补全(completion)“那一行——TS SDK 用 completable() 包装器(packages/server/src/server/completable.ts 74 行 + completable.examples.ts 46 行 = 120 行专门给参数补全);Python SDK 把补全元数据塞在 Prompt 类的 arguments 字段里——TS SDK 把补全单独抽成一个 74 行的小模块、Python SDK 内联——又一处”TS 多文件 vs Python 单一类” 风格反转例证(与之前几次 OAuth/Discovery 实测的 “Python 多小文件 vs TS 大单文件” 模式刚好相反)。
这条反转印证 §3.8.3/§16.9.1 的总结——MCP 两 SDK 的代码组织风格 “按功能模块各自决定”——不是某一边一贯多文件、某一边一贯大单文件。Streamable HTTP/Sampling/Prompt 是 Python 多文件 + TS 单文件,OAuth/Discovery 是 Python 多文件 + TS 单文件,但 completable 是 Python 内联 + TS 独立——证明风格选择是按具体功能复杂度而非全局信条。
7.12 本章小结
Prompt 是 MCP 三大原语中最贴近用户的一个。它的设计看似简单——不过是预定义消息模板——但其背后蕴含了重要的设计哲学:将控制权交给最合适的主体。
回顾本章的核心内容:
- Prompt 属于用户控制平面,由用户主动选择和触发,与 Tool(模型控制)和 Resource(应用程序控制)形成三足鼎立
- 一个 Prompt 由
name、description、arguments定义,通过prompts/get返回messages数组 - Messages 支持
text、image、resource三种内容类型,可以包含多条不同角色的消息 - 动态参数让 Prompt 从静态模板变成可编程的消息工厂——服务器可以根据参数执行数据库查询、文件读取等操作
- 嵌入资源将 Prompt 与 Resource 连接起来,让模板能够引用服务器管理的任意资源
- TypeScript SDK 通过
registerPrompt()方法注册,Python SDK 通过@server.prompt()装饰器注册
一句话记忆 Prompt 的本质:
Tool 是”LLM 的手”,Resource 是”Client 的眼”,Prompt 是”User 的嘴”——三者分工,方能有序。
物理事实:TS SDK Prompt 实现 ~120 行内联在 mcp.ts (1329 行) vs Python SDK 独立 prompts/ 目录 252 行(base.py 189 + manager.py 59)+ 顶层 mcpserver/server.py 50 行装饰器路由 = ~302 行——Python 2.5x;completable TS 独立 74 行模块 vs Python 内联——证明 SDK 风格选择按具体功能复杂度而非全局信条。