MCP 协议设计与实现

第7章 Prompt:可复用的交互模板

作者 杨艺韬 · 8,211 字 · 发布于 · 更新于

一个好的 Prompt 不只是一句话——它是用户和模型之间可复用的契约:双方都知道这句话代表什么,每一次调用都能给出可预期的输出。

本章要点

  • 理解 Prompt 在 MCP 三大原语中的独特定位:用户控制平面
  • 掌握 Prompt 的完整数据结构:name、description、arguments、messages
  • 学会使用动态参数让 Prompt 成为真正的可编程模板
  • 理解 Prompt 中嵌入资源(Embedded Resource)的机制及其客户端渲染意义
  • 对比 TypeScript SDK 的 registerPrompt() 与 Python SDK 的 @server.prompt() 两种注册方式
  • 通过代码审查、Bug 报告、数据分析三个模板,建立实战直觉
  • 掌握 6 条 Prompt 设计指南与 5 种常见反模式

7.1 为什么需要 Prompt

7.1.1 一位工程师一天的重复劳动

只要回头翻一遍自己和 AI 编程助手的对话记录,你大概率会发现同一批开场白反复出现——比如:

"请帮我审查以下代码,从安全性、性能、可读性三个维度分析问题并给出改进建议……"

或者:

"请帮我写一份 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 客户端通用的——任何实现了 Prompt 原语的客户端都能看到(例如 Cursor 官方文档的协议支持表把 Prompts 列为 Supported,核对于 2026-09-24)

关键词是用户控制。与 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:#333,stroke:none
    style R fill:#2563eb,color:#fff,stroke:none
    style P fill:#10b981,color:#333,stroke:none
    style M fill:#7c3aed,color:#fff,stroke:none
    style A fill:#db2777,color:#fff,stroke:none
    style U fill:#6366f1,color:#fff,stroke:none
原语 控制者 类比 典型交互 安全信任级别
Tool AI 模型 函数调用 模型判断需要查询数据库,自动调用 query_db 中(模型可能误判)
Resource 应用程序 文件附件 IDE 将当前打开的文件作为上下文附加到对话中 中(应用程序策略决定)
Prompt 用户 斜杠命令 用户输入 /review,选择代码审查模板 高(由用户明示触发;但模板内容仍由 Server 提供)

这三个控制平面的划分并非随意为之。它反映了一个核心设计原则:不同类型的决策应该由最合适的主体来做。

  • 模型擅长判断何时需要工具辅助——它能看到当前对话的意图
  • 应用程序知道当前的工作上下文是什么——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: 展示结果

五个关键时刻:

  1. 声明:Server 通过 prompts 能力声明支持 Prompt
  2. 枚举:Client 通过 prompts/list 拉取 Server 的可用 Prompt 列表
  3. 选择:用户在 UI(斜杠菜单)中选择某个 Prompt
  4. 获取:Client 通过 prompts/get 获取填入参数后的消息序列
  5. 发送:消息序列被插入对话上下文,正常发送给 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 否 参数列表 参数表单的字段
icons 否 多尺寸图标 菜单项左侧图标
_meta 否 通用扩展字段 —

(schema/2025-11-25/schema.json 里 Prompt 的 required 只有 name 一项。按各版 schema 对照:2024-11-05 首版只有 name / description / arguments;title 与 _meta 在 2025-06-18 加入,icons 在 2025-11-25 加入。)

每个参数(PromptArgument)又有:

  • name:字段名(唯一必填项)
  • title:可选的人类可读标题
  • 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:消息内容,类型就是协议里的 ContentBlock——2025-11-25 的 schema 里它是五选一:TextContent(text)、ImageContent(image)、AudioContent(audio)、ResourceLink(resource_link)、EmbeddedResource(resource)。这五种并非一开始就齐全:2024-11-05 的 PromptMessage.content 只有 text / image / resource 三种,2025-03-26 加入 audio,2025-06-18 加入 resource_link 并把这组类型统一命名为 ContentBlock。下面重点讲最常用的三种。

7.3.3 三种 content 类型

文本内容是最常见的类型:

{
  "type": "text",
  "text": "请从安全性、性能、可读性三个维度审查以下代码……"
}

图片内容支持多模态交互,数据必须经过 Base64 编码:

{
  "type": "image",
  "data": "base64-encoded-image-data",
  "mimeType": "image/png"
}

嵌入资源是 Prompt 与 Resource 原语的交汇点,我们在 7.6 节详细讨论。此外还有 audio(Base64 音频,结构同 image)和 resource_link(只带 URI 与元信息、不带内容的资源引用,让客户端自行决定是否 resources/read)。

三种常用类型的对比:

类型 适合场景 体积 客户端处理方式
text 文字指令、代码片段 小 直接作为 LLM 消息发送
image 截图、图表引用 大(base64 膨胀 33%) 多模态 LLM 直接输入
resource 文件、数据库记录、任何带 URI 的资源 中 UI 折叠展示、可重新下载

7.4 协议消息流

7.4.1 能力声明

支持 Prompt 的服务器必须在初始化阶段声明 prompts 能力:

{
  "capabilities": {
    "prompts": {
      "listChanged": true
    }
  }
}

listChanged 表示服务器是否会在 Prompt 列表变化时发送通知。如果为 true,客户端可以在收到 notifications/prompts/list_changed 通知后重新拉取列表。(2026-07-28 版规范已发布:能力改在 DiscoverResult 里声明,这条通知只发给用 subscriptions/listen 且带 promptsListChanged: true 订阅了的客户端;本专栏以 2025-11-25 为准。)

什么时候会发生列表变化?典型场景:

  • 用户在数据库客户端切换了连接——新数据库上有不同的 /analyze_X Prompt
  • 管理员在后台给某些 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->>Client: 与 LLM 正常对话(不经过 MCP Server)
    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) -> str:
    return f"Please review:\n{code}"

但 MCP Prompt 的真正威力在于:它可以在生成消息的过程中执行任意服务器端逻辑:

  • 查询数据库获取表 schema
  • 读取文件内容
  • 调用第三方 API
  • 查询向量数据库做 RAG
  • 根据用户的历史行为定制化

以一个数据分析 Prompt 为例。用户选择 /analyze_table,填入表名 users,服务器端的处理逻辑可能是:

  1. 根据表名查询数据库的表结构(schema)
  2. 采样若干行数据
  3. 计算基础统计(行数、null 比例、唯一值数等)
  4. 将 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}`
        }
      }
    ]
  })
);

几个值得注意的设计点:

  1. argsSchema 使用 Zod:TypeScript SDK 使用 Zod 等 Standard Schema 兼容库定义参数结构,框架先把它转成 JSON Schema,再拍平成 PromptArgument 列表(name / description / required)在 prompts/list 里暴露给客户端(packages/core/src/util/standardSchema.ts:185 的 promptArgumentsFromStandardSchema()),并在请求到达时执行校验——校验失败返回 -32602。
  2. 回调函数返回 GetPromptResult:回调接收经过校验的参数,返回包含 messages 数组的对象。
  3. 注册时自动处理列表和获取:首次调用 registerPrompt 时会同时设置 prompts/list 和 prompts/get 的请求处理器,开发者无需手动处理协议细节;已连接时每次注册还会自动发出 notifications/prompts/list_changed(实测)。

7.7.2 Python SDK

Python SDK 使用装饰器风格注册 Prompt,更加简洁:

from typing import Any

from mcp.server.mcpserver import MCPServer

server = MCPServer(name="my-server")

@server.prompt()
def analyze_table(table_name: str) -> str:
    """Analyze a database table structure and content."""
    schema = read_table_schema(table_name)
    return f"Analyze this schema:\n{schema}"   # 返回裸 str 会被包成一条 user 消息

@server.prompt()
async def analyze_file(path: str) -> dict[str, Any]:
    """Analyze a file with embedded resource."""
    content = await read_file(path)
    return {
        "role": "user",
        "content": {
            "type": "resource",
            "resource": {
                "uri": f"file://{path}",
                "mimeType": "text/plain",
                "text": content
            }
        }
    }

返回 dict 时

Message.content 的类型是 ContentBlock(src/mcp/server/mcpserver/prompts/base.py:27),Message.__init__ 会把 str 自动包成 TextContent。走 dict 路径时用的是模块级的 message_validator(src/mcp/server/mcpserver/prompts/base.py:53),调用点在 src/mcp/server/mcpserver/prompts/base.py:176-177 的 validate_python(msg)——由于 Message 重写了 __init__,Pydantic v2 校验时会调用这个自定义 __init__,所以 content 写成裸字符串的 dict 同样能通过,得到一条 text 消息(pin 版实测,Pydantic 2.13.5)。真正转换失败时,会被 src/mcp/server/mcpserver/prompts/base.py:184-185 转成 ValueError: Could not convert prompt result to message: …,再被外层 src/mcp/server/mcpserver/prompts/base.py:188-189 包成 ValueError: Error rendering prompt <name>: …。

Python SDK 的设计特点(对应 src/mcp/server/mcpserver/prompts/base.py 与 src/mcp/server/mcpserver/server.py:747):

1. 装饰器括号强校验——@server.prompt() 的括号不能省略。src/mcp/server/mcpserver/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(src/mcp/server/mcpserver/prompts/base.py:78)的核心流程(节选):

# src/mcp/server/mcpserver/prompts/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. 返回类型的四种多态——src/mcp/server/mcpserver/prompts/base.py:55 给出了返回值的类型别名:

SyncPromptResult = str | Message | dict[str, Any] | Sequence[...]
PromptResult     = SyncPromptResult | Awaitable[SyncPromptResult]

render 函数(src/mcp/server/mcpserver/prompts/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,src/mcp/server/mcpserver/prompts/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 控制),其他并发请求不受影响。代价是每次调用都多一次线程调度,而且占用线程池名额——所以不涉及阻塞 I/O 的 prompt 直接写成 async 更省。

5. Context 参数自动注入——src/mcp/server/mcpserver/prompts/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、日志方法、request_context.lifespan_context 里的 lifespan 资源等)。这让 prompt 函数可以写成:

@server.prompt()
async def analyze_table(table_name: str, ctx: Context) -> str:
    await ctx.info(f"Analyzing {table_name}")
    schema = await ctx.request_context.lifespan_context.db.get_schema(table_name)
    return f"Schema:\n{schema}"

ctx 不会暴露给客户端的 arguments 列表(skip_names=[context_kwarg] 把它从 schema 排除)——对客户端透明,对 prompt 实现者却是隐式的能力通道。

6. 必填参数缺失时的 ValueError——src/mcp/server/mcpserver/prompts/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 之前就拦住——客户端少传参数时拿到的是一句简洁的 Missing required arguments: {...},而不是 Pydantic ValidationError 的长文本——更适合面向客户端。但要注意线上错误码:这个 ValueError 被 get_prompt() 重新包成 ValueError(src/mcp/server/mcpserver/server.py:1110-1112)后,低层 Server 按普通异常处理成 code: 0(src/mcp/server/lowlevel/server.py:512-515),而不是规范建议的 -32602(实测)。拦截顺序是 "必填检查 → context 注入 → validate_call 校验类型 → 真正执行",层层把关。

7. 可选参数——装饰器本身接受 name(默认函数名)、title、description(默认从函数 docstring 取——src/mcp/server/mcpserver/prompts/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 对象 str / Message / dict 或它们的序列,统一规整为 list[Message]
变更通知 注册及 update() / enable() / disable() / remove() 时自动发送(已连接时),也可手动 sendPromptListChanged() 不自动发送(能力声明 listChanged: false,实测)
禁用支持 enabled 属性 + disable() / enable() 无(PromptManager 只有 add / get / list / render)
补全(completion) completable() 包装器逐参数声明 @server.completion() 注册一个全局处理器

两种 SDK 的哲学差异:TypeScript 偏向"显式声明 + 类型安全",Python 偏向"函数签名即接口 + 约定胜于配置"。这呼应了两种语言社区的设计文化。

7.8 实战用例

7.8.1 代码审查模板

from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage

@server.prompt(
    name="code_review",
    description="Multi-dimensional code review"
)
def code_review(code: str, language: str = "python") -> list[Message]:
    return [
        AssistantMessage(
            "I am a senior code reviewer. I will analyze code "
            "from three perspectives: security, performance, "
            "and readability."
        ),
        UserMessage(
            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
) -> str:
    return (
        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 名称不存在,或缺少必填参数。TypeScript SDK 对未注册、已禁用、参数校验失败三种情况都返回此错误码;Python SDK 则不然——未知 Prompt、缺参数、回调抛异常一律以 code: 0 返回(见 §7.7.2 第 6 点,实测)。
  • -32603(Internal error):服务器内部处理错误,比如回调函数中的数据库查询失败(TypeScript SDK 实测回调抛错即返回 -32603)。

从 TypeScript SDK 源码可以看到具体的错误处理逻辑:

// packages/server/src/server/mcp.ts:541 起(节选,换行有调整)
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 里的代码组织实测(行号来自 b8886e7 / 3d7b311)——

TS SDK——整个 Prompt 功能塞在 packages/server/src/server/mcp.ts(1329 行)内部,没有独立 prompts/ 目录——

位置 行号 行数 角色
handlePromptCompletion() mcp.ts:373-400 28 completion/complete 里 prompt 参数补全的分支
setPromptRequestHandlers() mcp.ts:507-555 49 prompts/list + prompts/get 两个 handler 注册,由 _promptHandlersInitialized 做一次性守卫
_createRegisteredPrompt() mcp.ts:698-765 68 构造 RegisteredPrompt,含 enable/disable/update/remove 闭包
registerPrompt() public method(含 JSDoc) mcp.ts:896-951 56 公开注册入口
sendPromptListChanged()(含 JSDoc) mcp.ts:997-1004 8 变更通知
PromptCallback / PromptHandler / RegisteredPrompt 类型 + createPromptHandler() mcp.ts:1223-1231、1239-1287 58 类型声明与 handler 工厂(中间 1233-1237 是 Tool 相关类型,不计)
TS SDK Prompt 小计 — ≈ 267 另有 completable.ts 74 行(+ examples 46 行)专门做参数补全

Python SDK——独立子目录 src/mcp/server/mcpserver/prompts/——

文件 行 角色
base.py 189 Prompt Pydantic 类 + 函数签名→arguments 的反射推导 + render() 把返回值规整为 list[Message]
manager.py 59 PromptManager——add/list/get + warn_on_duplicate_prompts 选项
__init__.py 4 export
小计 252 —

另外,顶层 src/mcp/server/mcpserver/server.py(1112 行)里的 prompt 相关代码:@server.prompt() 装饰器(747-803)、add_prompt()(739-745)、_handle_list_prompts / _handle_get_prompt(372-381)、list_prompts() / get_prompt()(1072-1112)——合计约 115 行。

Python SDK ≈ 367 行 vs TS SDK ≈ 267 行(含 completable.ts 则 ≈ 341)——差距约在 1.1~1.4 倍,远没有"数量级差异"。真正的差别不在体量而在切分方式:Python 把 Prompt 拆成 base(数据模型 + 渲染)、manager(注册表)、顶层装饰器(面向用户的 API)三层;TS 把同样的三件事全塞进 mcp.ts 的私有方法与闭包里。

§7.7.3 对比表"参数定义"那一行的源码证据——

  • TS:registerPrompt<Args extends StandardSchemaWithJSON>(name, config, cb)(mcp.ts:922),参数结构由 config.argsSchema 显式声明
  • Python:Prompt.from_function(fn)(src/mcp/server/mcpserver/prompts/base.py:78)通过 func_metadata(fn) 反射函数签名,再从生成的 Pydantic model 的 model_json_schema() 里读出 properties / required 自动推导——和《LangChain 设计与实现》专栏第 8 章 §8.4 里 LangChain @tool 装饰器从函数签名推断 schema 是同款"Python 函数签名即接口"哲学

§7.7.3 对比表"补全(completion)"那一行——两边的抽象层级完全不同:

  • TS SDK:completable() 包装器(packages/server/src/server/completable.ts,74 行)把补全回调用 Symbol.for('mcp.completable') 挂在单个参数的 schema 上,McpServer 在 handlePromptCompletion() 里按参数名取出来调用——逐参数、就地声明
  • Python SDK:用 @server.completion() 装饰器(src/mcp/server/mcpserver/server.py:582)注册一个全局处理器,没有参数级的补全元数据(SDK 内部的 PromptArgument 只有三个字段:名称、描述、是否必填),由开发者自己 isinstance(ref, PromptReference) 分派——集中式、手写分派

这条差异说明:Prompt 主体延续了 §3.8.3 / §16.9.1 看到的"TS 大文件、Python 拆开"格局("Python 三文件 + TS 单文件内联"),补全却反过来是"TS 独立模块 + Python 集中处理器"——MCP 两 SDK 的代码组织风格是 "按功能模块各自决定" 的。风格选择是按具体功能复杂度而非全局信条。

7.12 本章小结

Prompt 是 MCP 三大原语中最贴近用户的一个。它的设计看似简单——不过是预定义消息模板——但其背后蕴含了重要的设计哲学:将控制权交给最合适的主体。

回顾本章的核心内容:

  • Prompt 属于用户控制平面,由用户主动选择和触发,与 Tool(模型控制)和 Resource(应用程序控制)形成三足鼎立
  • 一个 Prompt 由 name、description、arguments 定义,通过 prompts/get 返回 messages 数组
  • Messages 的 content 就是协议里的 ContentBlock——text / image / audio / resource_link / resource 五选一,可以包含多条不同角色的消息
  • 动态参数让 Prompt 从静态模板变成可编程的消息工厂——服务器可以根据参数执行数据库查询、文件读取等操作
  • 嵌入资源将 Prompt 与 Resource 连接起来,让模板能够引用服务器管理的任意资源
  • TypeScript SDK 通过 registerPrompt() 方法注册,Python SDK 通过 @server.prompt() 装饰器注册

一句话记忆 Prompt 的本质:

Tool 是"LLM 的手",Resource 是"Client 的眼",Prompt 是"User 的嘴"——三者分工,方能有序。