MCP 实战第三课:Prompts 菜单、参数补全与进度上报
生命不息,折腾不止。前两课我们把 Server 造出来、挂上公网,工具是能给模型用了;这一课补上另一半——让人也用得顺手:给 Server 加一份能点的菜单(Prompts)、会猜的参数框(Completion)、长任务不再干等的进度条(Progress),最后把好几个 Server 同时挂进 dsh 和 Claude Code,拼出一套混合工作流。
一、先把「给客户端看的东西」分清楚
前两课我们一直在做「模型能用的东西」:Tools 给模型调、Resources 给应用读。但一个 Server 挂在客户端上,用户打开界面看到的是什么?是三个东西:
| 原语 | 谁在控制 | 长什么样 | 典型用途 |
|---|---|---|---|
| Tools | 模型 | 模型自己决定要不要调 | 干活、改数据、调外部 API |
| Resources | 应用 | 像附件,被应用读进来当上下文 | 文件、日志、数据库行 |
| Prompts | 用户 | 客户端里的一份菜单/斜杠命令 | 固定套路的提问模板 |
除了这三个「正牌原语」,MCP 还给了两个辅助能力,专门改善「人用起来的手感」,也是这一课的重点:
- Completion:用户在参数框里打字时,Server 给出候选项——像 IDE 的代码补全;
- Progress:工具跑得久,一路回报进度,客户端画进度条/转圈/日志行,而不是傻等三十秒。
一句话记住区别:前两课解决「Agent 能不能干活」,这一课解决「人愿不愿意用」。 大部分 Server 做出来没人用,就是死在这里——工具全藏在模型背后,人根本不知道它能干什么。
先把这一课用的 Server 骨架搭好(基于 mcp 2.3.0,协议修订 2026-07-28,写法以官方文档为准):
1 | # server.py |
跑起来调试还是那两条命令:uv run mcp dev server.py 打开 Inspector,uv run mcp run server.py --transport streamable-http 换 HTTP 传输(第二课讲过怎么加锁)。
二、Prompts:给 Server 挂一份菜单
Tools 是「模型挑」,Prompts 反过来——用户从菜单里挑一个,填上参数,渲染出来的消息就相当于他自己打进去的。
定义一个 Prompt,就是在函数上加个装饰器:
1 |
|
SDK 从函数上读三样东西,和 Tool 一模一样:
- 名字:函数名,这里是
review_note; - 描述:docstring,客户端拿去展示;
- 参数:函数签名,没有默认值的参数就是必填。
但有个关键差别:Prompt 的参数是一串平铺的字符串,不是 JSON Schema。因为它是一张「人填的表单」,不是「模型拼的载荷」——所以别指望在 Prompt 参数里塞嵌套对象、数组、枚举。客户端拿到的 prompts/list 就长这样:
1 | { |
用户点了之后,客户端发 prompts/get,你的函数跑一遍,返回的字符串变成一条 user 消息:
1 | { |
这就是 Prompt 的一生:被列出来、被点开、被渲染、丢进对话。
2.1 想开场就带上下文?返回一串消息
返回 str 是一条用户消息;返回消息列表,就能给整段对话开场:
1 | from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage |
各小版本里这三个类的导出位置略有差异,导入报错就往
mcp.server.mcpserver里翻,以官方文档为准。
2.2 让表单好看一点
客户端画表单靠的是 title 和字段描述,加上它们,菜单里的提示就不再是光秃秃的变量名:
1 | from typing import Annotated |
有默认值的参数变成可选,prompts/list 里会多出 title、每个参数带 description。
2.3 把 Resource 塞进 Prompt
这是 Prompts 最实用的一个玩法:模板里直接带上一份 Resource 当附件。比如把团队的代码规范文件当上下文,一起送进对话:
1 | from pathlib import Path |
好处是规范只有一份:改 style-guide.md,所有引用它的 Prompt 一起生效,不用去十个提示词文件里搜替换。
2.4 运行时还能加菜单
想让用户「把这段常用指令存成菜单项」,可以运行中动态注册:
1 | from contextlib import suppress |
两个通知都要发:一个走 subscriptions/listen 推给 2026-07-28 的新客户端,一个直接回给正在调用的老客户端;没有听众时它们都自动变成空操作,所以别有心理负担,无脑调就行。
2.5 一个必须知道的坑
必填参数缺了,整个请求直接失败,不是一个「错误结果」。你会看到 JSON-RPC 错误码 -32603:
1 | mcp.shared.exceptions.MCPError: Internal server error |
原因很直白:Tool 报错能包成结果回给模型,Prompt 这里没有模型在环,人填错表,请求就挂了。真正的原因(Missing required arguments: {'note'})只出现在你 Server 的日志里。所以别在 Prompt 里做参数校验兜底,做也没用——客户端表单本身就拦住了。
三、Completion:让参数框会「猜」
Prompt 的参数框里让用户手打 ID,体验是灾难。Completion 就是干这个的:用户在填参数时,Server 给候选。
一个 Server 只有一个补全 handler,所有请求都进这一个人:
1 | from mcp.server import MCPServer |
三个参数,逐条说清:
ref:是哪个 Prompt 或 Resource 模板在下发请求。isinstance分流,Prompt 是PromptReference,资源是ResourceTemplateReference;argument:argument.name是正在补全的参数名,argument.value是用户已经打出来的前缀;context:已经填好的其它参数(下一节讲)。
返回 Completion(values=[...]),或者没有任何建议就返回 None——注意 None 会被 SDK 变成空列表,不是错误,客户端的表单会退回成普通输入框。
3.1 过滤得自己写
官方文档特意提醒了一句:argument.value 只是用户打的前缀,SDK 不会帮你过滤。你往 values 里放什么,界面就显示什么。所以 startswith 得自己写——想按拼音、按模糊匹配,随你,反正规则在你手里。单次响应上限 100 条。
3.2 参数之间还能联动
context.arguments 装的是已经确定的值。最典型的场景:资源模板 note://{folder}/{note_id},用户先选了目录,你才知道该推荐哪些笔记:
1 |
|
客户端会在请求里带上 context_arguments= 把这些已确定的值传过来。没有它们就返回 None——猜不出来就别硬猜。
3.3 能力是自动声明的
有个细节值得知道:你没有在任何地方写过 completions 这个能力,但连接上以后客户端会看到 client.server_capabilities.completions 存在。原因很简单:注册 handler 就是声明。SDK 看到你有 @mcp.completion() 就替你报了能力;反过来,没注册还去请求,就是 Method not found。
四、Progress:三十秒不说话的工具,看起来就是坏了
一个跑三十秒、一个字都不吐的工具,在用户眼里和「卡死了」没有区别。进度上报解决这个。
写法不能再简单——给工具加一个 Context 类型的参数,干活的时候顺手报一句:
1 | from mcp.server.mcpserver import Context |
三个参数的规矩:
progress:走到哪了。规范要求每次上报必须严格递增——不能重复、不能倒退;total:总共多少。可选,不确定分母就别给,客户端会显示「正在活动」而不是百分比;message:这一步的人话说明。可选。
ctx 是靠类型标注注入的,参数名随便叫,而且模型永远看不到它——import_notes 的输入 schema 里只有 ids 一个字段。
4.1 客户端要「按次」申请
这是最容易踩的坑:report_progress 默认是个空操作。客户端得在这次调用里明确要求接收进度:
1 | import anyio |
所以结论很简单:Server 里无条件调 report_progress,别去检测有没有人在听。有人在听就发,没人听就是空操作,一点开销没有。
还有一个时序细节要有心理准备:进度通知是各走各的路送过去的,和最终结果并行。真传输下,回调慢的话,call_tool 都返回了、回调可能还在跑。只有「内存里的测试连接」是严格同步的——写测试的时候别把真传输的时序当成同一回事。
五、日志这块有个大新闻(这次真得改代码)
上一课我预告里写的是「日志与进度上报」,写之前我去核了一遍(正好是写教程最该干的事):协议级日志已经废弃了。
2026-07-28 这一版协议里,SEP-2577 一次性把三个能力标记为废弃:roots、server 主动发起的 sampling、协议日志。准确地说:
ctx.log()/ctx.debug()/ctx.info()/ctx.warning()/ctx.error()、ctx.session.send_log_message()、客户端的client.set_logging_level()—— 全部废弃;- 没有协议内的替代品。官方建议:stdio 传输就老老实实写
stderr,要结构化可观测性就上 OpenTelemetry; - 调用它们会抛
MCPDeprecationWarning(注意它继承的是UserWarning,不是DeprecationWarning——就是故意的,免得你以为默认过滤能看见)。
顺便还有两条同批变动,一起记下来:
ping被移除,不只是废弃,2026-07-28里没有这个方法了,只有mode="legacy"的连接还能用;- 进度变成单向的:
2026-07-28起 progress 只允许 server → client,早期那个client.send_progress_notification()没得发了。
别慌,废弃是「劝告式」的:这些方法对任何协商到 2025-11-25 或更早版本的会话照旧工作,客户端 pin 上 mode="legacy" 就完全是老行为,能力协商和线上格式都没变。但如果今天写新 Server,就该这样写日志:
1 | import logging |
一个实操提醒:MCPServer("名字", log_level="DEBUG") 在构造时已经替你 logging.basicConfig() 了一个 stderr handler(默认 INFO 级)——前提是你自己没先配置过 logging。别自己再加 handler,不然日志会打两份。
六、把多个 Server 同时挂上:dsh 这一侧
到这里 Server 该有的都有了。接下来是「混用」——同时挂好几个 Server 干活。先看 dsh。
dsh 的规矩是一个插件实例 = 一个 MCP Server,写在 patch 文件里(cordis.patch.yml 或任何 --patch 指到的文件)。两个实例就是两段:
1 | - id: mcp-notes |
stdio 给 command / args / env / cwd,dsh 负责起进程管生命周期;streamable-http 给 url / headers 连过去。!!js 是 Cordis 的 YAML 标签,在加载时求值——凭据一律走环境变量,别把 token 明文写进会被提交的 patch 文件里。
工具名怎么变,是这里最该知道的:
| 规则 | 细节 |
|---|---|
| 命名格式 | mcp__<serverName>__<rawName>,和 Claude Code、Codex 一个形状 |
| 字符集 | 只允许 [A-Za-z0-9_-],最长 64 字符 |
| 冲突处理 | 归一化后撞名,dsh 追加 12 位 hex 后缀(由 (serverName, rawName) 推出) |
| 稳定性 | 名字是 (serverName, rawName) 的纯函数,跟连接顺序无关,重启也不变 |
几个平时最用得上的配置项(都有默认值,按需改):
| 字段 | 默认 | 作用 |
|---|---|---|
toolCallTimeoutMs |
60000 |
单次 callTool 超时,慢工具记得调大 |
failOnStartupError |
false |
首次连接失败要不要直接让激活失败 |
reconnect.enabled |
true |
断了自动重连 |
reconnect.initialDelayMs |
500 |
首次重连延迟,连续失败翻倍 |
reconnect.maxDelayMs |
30000 |
退避上限,也是重置重连预算所需的在线时长 |
reconnect.maxAttempts |
10 |
单次断连的连续失败次数上限 |
patch 是分层的:改某个 profile 就写进 $DSH_HOME/profiles/<名字>/cordis.patch.yml,想机器级全局生效就写进 $DSH_HOME/cordis.patch.yml(这一层排在所有 profile 之后,优先级最高),临时试验用 --patch <路径> 叠一层。而且这些文件是热更新的:改一行,那个 Server 断连重连,dsh 进程不重启。
⚠️ serverName 改不得——它一变,模型看到的所有该 Server 的工具名全变。线上跑着的时候改这个,等于把模型的记忆里所有相关记录作废。
小贴士:如果你工作区里已经有 Claude Code 格式的
.mcp.json,社区有插件(如dsh-mcp-json)能把它展开成一个个dsh-mcp-client子实例,省得两份配置各维护一遍。dsh 自己是没有项目级 MCP 发现的,一个实例连一个 Server,这是它刻意的设计。顺带一句:dsh 的模型侧配置跟 MCP 是两条线——官方 API 或 ai.aklibk.com 中转都行,一个 key 多模型切换是 dsh 的卖点;排查问题时别把「模型连不上」和「MCP 连不上」混在一起看。
七、Claude Code 这一侧:一份 .mcp.json 挂三个
Claude Code 支持一套配置挂多个 Server。项目级的写法是仓库根目录的 .mcp.json:
1 | { |
环境变量展开在 command / args / env / url / headers 里都支持,还支持 ${VAR:-默认值} 兜底。密钥永远走 ${},不要把 token 提交进仓库。
三个 scope 别搞混:
| scope | 存哪 | 谁能看到 |
|---|---|---|
local(默认) |
~/.claude.json 里该项目的键下 |
只有你、只有这个项目 |
project |
仓库根目录 .mcp.json |
克隆仓库的所有人 |
user |
~/.claude.json 全局 |
你机器上的所有项目 |
命令行加 Server 就是一句话:
1 | claude mcp add --scope project --transport http shared-api https://api.example.com/mcp |
有个容易翻车的优先级细节:同名 Server 出现在多个 scope 时,只取优先级最高的那一份完整定义,字段不合并。排在最前的是 local,然后是 project、user、插件自带、claude.ai 连接器。也就是说——你本地为调试覆盖了一份 local,那份 project 里的 headers 就不会保留下来,它会整条被换掉。项目级配置改了不想被旧的批准记录挡住,用 claude mcp reset-project-choices 清一下。
然后是这一课最爽的一点:你在第二节写的那些 Prompts,在 Claude Code 里直接变成斜杠命令:
1 | /mcp__notes__review_note 今天研究了 MCP 的 Prompts 原语…… |
格式是 /mcp__<servername>__<promptname>,参数用空格跟在后面,名字里的空格会归一化成下划线。它注入到对话里,不是起个子进程去跑。Resources 也一样能内联引用:@notes:note://note_001。工具名同理,都是 mcp__<server>__<tool>——所以第二节那份菜单不是给自己看的,它是真的会出现在你工作流的输入框里。
八、拼一个混合工作流,再收一份排障清单
把三个 Server 一起挂上,就能拼出一套「本地文件 + 数据库 + 远程服务」的混合工作流。举个真实好用的组合:
- 本地文件 Server(stdio):
notes—— 读写你的 Markdown 笔记; - 数据库 Server(stdio):
db—— 查你自己的业务库; - 远程 Server(streamable-http + 鉴权):
web—— 别人给你提供的检索服务。
在 dsh 里三个实例写进同一个 patch 文件;在 Claude Code 里三个条目写进同一个 .mcp.json。然后用 Prompt 起手:/mcp__notes__review_note 挑一篇笔记 → 模型调 mcp__db__query 查相关数据 → 碰上下文不够了调 mcp__web__search 补外网资料 → 最后调 mcp__notes__save 把结论写回去。长任务那一步记得报进度,不然用户以为你卡死了。
最后是排障清单,这一课踩过的坑基本都在里面:
- Prompts 菜单看不见:先确认客户端支不支持 Prompts 原语。很多客户端只实现了 Tools,Prompts 不是必备项。用
uv run mcp dev server.py的 Inspector 切到 Prompts 标签页验证——那边能看到就说明是你客户端的事。 - 点 Prompt 报
-32603:必填参数没填。真正原因(Missing required arguments: {...})在 Server 的日志里,别去客户端找。 - 补全永远是空的:先看有没有注册
@mcp.completion()——没注册是Method not found,注册了但返回None就是空列表,两种情况界面看起来一样。 - 补全结果看着对但过滤失效:SDK 不过滤,
startswith得自己写。 - 参数联动的建议不出现:客户端没带上
context_arguments=,或者你的 handler 在context is None时返回了None——后者是对的,前者去客户端找原因。 - 进度条一动不动:客户端这次调用没传
progress_callback。这是按次 opt-in,不是连上就有。 - 进度报了但客户端报错/跳变:
progress必须严格递增,重复值和后退都违反约定。 - 控制台刷
MCPDeprecationWarning:你在用ctx.log()/ctx.info()这一套。换成标准logging写 stderr,别指望协议层了。 - dsh 里工具名对不上:
serverName被改过,或者名字超长/撞名被加了 12 位 hex 后缀。先对齐serverName再说。 - Claude Code 里 Server 显示
Pending approval:项目级配置需要人工批准一次,claude mcp reset-project-choices可以清掉旧的批准/拒绝记录重来。 - 同一个 Server 两份配置互相打架:三个 scope 不合并字段,取一份完整的。你覆盖
local的时候,project里的其它字段一起没了。
一句话收口:Tools 让 Agent 能干活,Prompts 让人知道能干什么,Completion 让人打得下去字,Progress 让人等得下去。四样齐了,你那个挂在公网上的 Server 才算真的「有人用」。
生命不息,折腾不止。下一篇「MCP 实战第四课」,我们钻到 2026-07-28 这版协议里最实在的一个变化:Multi-round-trip 请求——roots 和 server 主动 sampling 被 SEP-2577 一起废弃之后,Server 想反过来问客户端要东西(比如「给你一个路径,帮我读一下」)该用
InputRequiredResult怎么写;再讲subscriptions/listen订阅流怎么把 Resources 的变更实时推给客户端,最后把前面三课的 Server 收尾上线:限流、可观测、灰度回滚,做一份能长期挂着的运维清单。