生命不息,折腾不止。前两课我们把 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# server.py
from mcp.server import MCPServer

mcp = MCPServer("笔记服务")

NOTES = {
"note_001": "今天研究了 MCP 的 Prompts 原语……",
"note_002": "Server 的进度上报要严格递增……",
"note_003": "客户端不传 progress_callback 就什么都不发……",
}

@mcp.resource("note://{note_id}")
def read_note(note_id: str) -> str:
"""按 ID 读一篇笔记。"""
return NOTES.get(note_id, "没有这篇笔记")

@mcp.tool()
def count_notes() -> int:
"""数一数现在有多少篇笔记。"""
return len(NOTES)

跑起来调试还是那两条命令:uv run mcp dev server.py 打开 Inspector,uv run mcp run server.py --transport streamable-http 换 HTTP 传输(第二课讲过怎么加锁)。

二、Prompts:给 Server 挂一份菜单

Tools 是「模型挑」,Prompts 反过来——用户从菜单里挑一个,填上参数,渲染出来的消息就相当于他自己打进去的。

定义一个 Prompt,就是在函数上加个装饰器:

1
2
3
4
@mcp.prompt()
def review_note(note: str) -> str:
"""把手记整理成结构化摘要。"""
return f"请把下面这段手记整理成三条要点:\n\n{note}"

SDK 从函数上读三样东西,和 Tool 一模一样:

  • 名字:函数名,这里是 review_note;
  • 描述:docstring,客户端拿去展示;
  • 参数:函数签名,没有默认值的参数就是必填。

但有个关键差别:Prompt 的参数是一串平铺的字符串,不是 JSON Schema。因为它是一张「人填的表单」,不是「模型拼的载荷」——所以别指望在 Prompt 参数里塞嵌套对象、数组、枚举。客户端拿到的 prompts/list 就长这样:

1
2
3
4
5
6
7
{
"name": "review_note",
"description": "把手记整理成结构化摘要。",
"arguments": [
{ "name": "note", "required": true }
]
}

用户点了之后,客户端发 prompts/get,你的函数跑一遍,返回的字符串变成一条 user 消息:

1
2
3
4
5
6
7
8
9
{
"description": "把手记整理成结构化摘要。",
"messages": [
{
"role": "user",
"content": { "type": "text", "text": "请把下面这段手记整理成三条要点:\n\n今天研究了……" }
}
]
}

这就是 Prompt 的一生:被列出来、被点开、被渲染、丢进对话。

2.1 想开场就带上下文?返回一串消息

返回 str 是一条用户消息;返回消息列表,就能给整段对话开场:

1
2
3
4
5
6
7
8
9
10
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage

@mcp.prompt()
def debug_note(error: str) -> list[Message]:
"""从一个报错开始排查。"""
return [
UserMessage("我在跑这个 Server 的时候遇到这个报错:"),
UserMessage(error),
AssistantMessage("我来帮你排查。你已经试过哪些手段了?"),
]

各小版本里这三个类的导出位置略有差异,导入报错就往 mcp.server.mcpserver 里翻,以官方文档为准。

2.2 让表单好看一点

客户端画表单靠的是 title 和字段描述,加上它们,菜单里的提示就不再是光秃秃的变量名:

1
2
3
4
5
6
7
8
9
10
from typing import Annotated
from pydantic import Field

@mcp.prompt(title="整理手记")
def review_note(
note: Annotated[str, Field(description="要整理的手记原文")],
style: Annotated[str, Field(description="输出风格")] = "要点清单",
) -> str:
"""把手记整理成结构化摘要。"""
return f"请按「{style}」风格整理这段手记:\n\n{note}"

有默认值的参数变成可选,prompts/list 里会多出 title、每个参数带 description。

2.3 把 Resource 塞进 Prompt

这是 Prompts 最实用的一个玩法:模板里直接带上一份 Resource 当附件。比如把团队的代码规范文件当上下文,一起送进对话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pathlib import Path
from mcp.server.mcpserver import Message, UserMessage
from mcp.types import EmbeddedResource, TextResourceContents

STYLE_FILE = Path(__file__).parent / "style-guide.md"

@mcp.resource("style://python", mime_type="text/markdown")
def style_guide() -> str:
"""团队的 Python 规范。"""
return STYLE_FILE.read_text(encoding="utf-8")

@mcp.prompt()
def review_with_style(code: str) -> list[Message]:
"""按团队规范评审代码。"""
guide = TextResourceContents(
uri="style://python", mime_type="text/markdown", text=style_guide()
)
return [
UserMessage(EmbeddedResource(resource=guide)),
UserMessage(f"按上面的规范评审这段代码:\n\n{code}"),
]

好处是规范只有一份:改 style-guide.md,所有引用它的 Prompt 一起生效,不用去十个提示词文件里搜替换。

2.4 运行时还能加菜单

想让用户「把这段常用指令存成菜单项」,可以运行中动态注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from contextlib import suppress
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.prompts import Prompt

@mcp.tool()
async def save_template(name: str, instruction: str, ctx: Context) -> str:
"""把一段常用指令存成菜单项。"""
def template(code: str) -> str:
return f"{instruction}\n\n{code}"

with suppress(ValueError): # 同名先删,实现「保存=覆盖」
mcp.remove_prompt(name)
mcp.add_prompt(Prompt.from_function(template, name=name, description=instruction))

await ctx.notify_prompts_changed() # 通知新版客户端(订阅流)
await ctx.session.send_prompt_list_changed() # 通知老版本客户端
return f"已把「{name}」存进菜单。"

两个通知都要发:一个走 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from mcp.server import MCPServer
from mcp.types import (
Completion, CompletionArgument, CompletionContext,
PromptReference, ResourceTemplateReference,
)

mcp = MCPServer("笔记服务")

@mcp.completion()
async def handle_completion(
ref: PromptReference | ResourceTemplateReference,
argument: CompletionArgument,
context: CompletionContext | None,
) -> Completion | None:
if isinstance(ref, PromptReference) and argument.name == "note_id":
ids = [k for k in NOTES if k.startswith(argument.value)]
return Completion(values=ids[:100])
return None

三个参数,逐条说清:

  • 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
2
3
4
5
6
7
8
9
@mcp.completion()
async def handle_completion(ref, argument: CompletionArgument, context: CompletionContext | None):
if isinstance(ref, ResourceTemplateReference) and argument.name == "note_id":
if context is None or context.arguments is None:
return None # 还没选目录,给不出建议
folder = context.arguments.get("folder", "")
ids = [k for k in NOTES if k.startswith(f"{folder}/") and k.startswith(argument.value)]
return Completion(values=ids[:100])
return None

客户端会在请求里带上 context_arguments= 把这些已确定的值传过来。没有它们就返回 None——猜不出来就别硬猜。

3.3 能力是自动声明的

有个细节值得知道:你没有在任何地方写过 completions 这个能力,但连接上以后客户端会看到 client.server_capabilities.completions 存在。原因很简单:注册 handler 就是声明。SDK 看到你有 @mcp.completion() 就替你报了能力;反过来,没注册还去请求,就是 Method not found。

四、Progress:三十秒不说话的工具,看起来就是坏了

一个跑三十秒、一个字都不吐的工具,在用户眼里和「卡死了」没有区别。进度上报解决这个。

写法不能再简单——给工具加一个 Context 类型的参数,干活的时候顺手报一句:

1
2
3
4
5
6
7
8
from mcp.server.mcpserver import Context

@mcp.tool()
async def import_notes(ids: list[str], ctx: Context) -> str:
"""批量导入笔记。"""
for done, note_id in enumerate(ids, start=1):
await ctx.report_progress(done, total=len(ids), message=f"已导入 {note_id}")
return f"导入完成,共 {len(ids)} 篇。"

三个参数的规矩:

  • progress:走到哪了。规范要求每次上报必须严格递增——不能重复、不能倒退;
  • total:总共多少。可选,不确定分母就别给,客户端会显示「正在活动」而不是百分比;
  • message:这一步的人话说明。可选。

ctx 是靠类型标注注入的,参数名随便叫,而且模型永远看不到它——import_notes 的输入 schema 里只有 ids 一个字段。

4.1 客户端要「按次」申请

这是最容易踩的坑:report_progress 默认是个空操作。客户端得在这次调用里明确要求接收进度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import anyio
from mcp import Client

async def show(progress: float, total: float | None, message: str | None) -> None:
print(f"{message} ({progress}/{total})")

async def main() -> None:
async with Client("http://localhost:8000/mcp") as client:
result = await client.call_tool(
"import_notes",
{"ids": ["note_001", "note_002"]},
progress_callback=show, # ← 按次申请,不是建连接时申请
)
print(result.structured_content)

anyio.run(main)

所以结论很简单: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
2
3
4
import logging

logger = logging.getLogger(__name__)
logger.info("准备导入 %d 篇笔记", len(ids))

一个实操提醒: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- id: mcp-notes
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: notes
transport: stdio
command: !!js process.env.PYTHON_BIN
args: ['/srv/mcp/notes/server.py']
cwd: !!js process.cwd()
env:
NOTES_DB: !!js process.env.NOTES_DB

- id: mcp-web
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: web
transport: streamable-http
url: http://localhost:3000/mcp
headers:
Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`'

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"mcpServers": {
"notes": {
"command": "python3",
"args": ["/srv/mcp/notes/server.py"],
"env": { "NOTES_DB": "${NOTES_DB}" }
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }
},
"web": {
"type": "http",
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer ${MCP_TOKEN}" }
}
}
}

环境变量展开在 command / args / env / url / headers 里都支持,还支持 ${VAR:-默认值} 兜底。密钥永远走 ${},不要把 token 提交进仓库。

三个 scope 别搞混:

scope 存哪 谁能看到
local(默认) ~/.claude.json 里该项目的键下 只有你、只有这个项目
project 仓库根目录 .mcp.json 克隆仓库的所有人
user ~/.claude.json 全局 你机器上的所有项目

命令行加 Server 就是一句话:

1
2
3
claude mcp add --scope project --transport http shared-api https://api.example.com/mcp
claude mcp list # 看状态,pending approval 的会标出来
claude mcp get notes # 看单个

有个容易翻车的优先级细节:同名 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 一起挂上,就能拼出一套「本地文件 + 数据库 + 远程服务」的混合工作流。举个真实好用的组合:

  1. 本地文件 Server(stdio):notes —— 读写你的 Markdown 笔记;
  2. 数据库 Server(stdio):db —— 查你自己的业务库;
  3. 远程 Server(streamable-http + 鉴权):web —— 别人给你提供的检索服务。

在 dsh 里三个实例写进同一个 patch 文件;在 Claude Code 里三个条目写进同一个 .mcp.json。然后用 Prompt 起手:/mcp__notes__review_note 挑一篇笔记 → 模型调 mcp__db__query 查相关数据 → 碰上下文不够了调 mcp__web__search 补外网资料 → 最后调 mcp__notes__save 把结论写回去。长任务那一步记得报进度,不然用户以为你卡死了。

最后是排障清单,这一课踩过的坑基本都在里面:

  1. Prompts 菜单看不见:先确认客户端支不支持 Prompts 原语。很多客户端只实现了 Tools,Prompts 不是必备项。用 uv run mcp dev server.py 的 Inspector 切到 Prompts 标签页验证——那边能看到就说明是你客户端的事。
  2. 点 Prompt 报 -32603:必填参数没填。真正原因(Missing required arguments: {...})在 Server 的日志里,别去客户端找。
  3. 补全永远是空的:先看有没有注册 @mcp.completion()——没注册是 Method not found,注册了但返回 None 就是空列表,两种情况界面看起来一样。
  4. 补全结果看着对但过滤失效:SDK 不过滤,startswith 得自己写。
  5. 参数联动的建议不出现:客户端没带上 context_arguments=,或者你的 handler 在 context is None 时返回了 None——后者是对的,前者去客户端找原因。
  6. 进度条一动不动:客户端这次调用没传 progress_callback。这是按次 opt-in,不是连上就有。
  7. 进度报了但客户端报错/跳变:progress 必须严格递增,重复值和后退都违反约定。
  8. 控制台刷 MCPDeprecationWarning:你在用 ctx.log() / ctx.info() 这一套。换成标准 logging 写 stderr,别指望协议层了。
  9. dsh 里工具名对不上:serverName 被改过,或者名字超长/撞名被加了 12 位 hex 后缀。先对齐 serverName 再说。
  10. Claude Code 里 Server 显示 Pending approval:项目级配置需要人工批准一次,claude mcp reset-project-choices 可以清掉旧的批准/拒绝记录重来。
  11. 同一个 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 收尾上线:限流、可观测、灰度回滚,做一份能长期挂着的运维清单。