一个枚举参数引发的空白输入框
假设团队在远程 MCP Server 上暴露了一个提示模板 incident_report,它有一个参数 severity,服务端只接受 P0、P1、P2、P3 四个值。客户端把模板列出来之后,用户点开它,参数输入框却是空的。用户随手输入“紧急”,模型照着这个字符串拼出提示词,prompts/get 返回的内容看似正常,但下游按 severity 分流的逻辑全部落到默认分支。
常见的直觉是:服务端已经知道合法值,客户端把参数名发过去问一下就行。问题在于 MCP 并没有把“参数候选值”放进 prompts/list 的返回里。提示模板的 arguments 只描述名称、说明和是否必填,枚举值需要另一条通道获取,也就是 completion/complete。这条通道是否可用,取决于服务端有没有声明能力、客户端有没有发起请求、以及结果有没有被渲染到输入控件上。三个环节任一断开,界面就退化成自由文本。
补全请求在协议里的位置
MCP 的补全(Completion)是一类服务端实用工具,为提示模板参数和资源 URI 模板参数提供自动补全建议,目标是支撑类似 IDE 代码补全的交互体验。协议本身不规定界面形态,下拉菜单、弹窗或行内提示都可以,但它规定了请求与响应的数据结构。
客户端发起补全时发送 completion/complete,核心参数是 ref 和 argument。ref 指明“正在补全哪个对象”,有两种引用类型:ref/prompt 按名称引用提示模板,ref/resource 引用资源 URI 模板。argument 包含 name(参数名)和 value(用户当前已输入的片段)。多个参数时,请求还可以带 context.arguments,把已经确定的其它参数值一并传过去。
服务端返回 completion 对象,其中 values 是按相关性排序的建议数组,单次响应最多 100 项;total 是可选的可用匹配总数;hasMore 表示是否还有更多结果。对 severity 这个例子,用户输入 P 时服务端可以返回 P0、P1、P2、P3,total 为 4,hasMore 为 false。
需要强调的是,prompts/list 与 completion/complete 是两条独立的消息。前者给出模板清单和参数骨架,后者按需给出候选值。客户端只调用前者时,拿到的信息足以渲染一个空输入框,却不足以渲染一个下拉框。
能力声明是第一道闸门
协议规定,支持补全的服务端必须在初始化阶段声明 completions 能力:
{
"capabilities": {
"completions": {}
}
}
这个声明出现在 initialize 的响应里,客户端据此判断是否可以对某个服务端发起补全请求。如果服务端没有声明,客户端按规范不应调用 completion/complete;即使强行调用,服务端也可能返回 JSON-RPC 的 -32601(Method not found,能力不支持)。
这里有一个容易被忽略的细节:prompts 能力和 completions 能力是分开声明的。一个服务端完全可以声明 prompts 并正常提供 prompts/list、prompts/get,却没有声明 completions。此时提示模板照常可用,只是参数候选值不可获取。从客户端界面看,就是模板能列出来、能执行,但参数只能手填。
工程上通常把这种状态称为“功能降级而非故障”:协议层没有报错,用户体验却明显变差。排查时先看 initialize 的结果,比盯着 completion/complete 的日志更有效。
客户端侧的两个断点:不发起与不渲染
即使服务端声明了 completions,客户端仍可能拿不到候选值,原因通常落在两处。
第一处是不发起请求。补全不是自动发生的,客户端需要在用户编辑参数时主动构造 completion/complete。有些客户端只在参数被标记为某种枚举类型时才发起请求,而 MCP 的提示参数描述里并没有强类型的枚举字段,只有名称、说明和必填标记。客户端如果按“没有枚举元数据就不请求”的策略实现,就会跳过所有提示参数的补全。
第二处是不渲染结果。请求发出、响应返回,但客户端把 values 丢弃,或者只在用户按下某个快捷键时才展示。协议明确说交互模型由实现方决定,因此“返回了候选值”和“用户看到了候选值”之间没有协议保证。
还有一个时序问题:多参数模板里,后面的参数候选值可能依赖前面的参数。协议建议客户端在 context.arguments 中带上已确定的参数值。例如 framework 参数的候选值取决于 language 已选为 python。如果客户端每次都只发 argument 而不带 context,服务端只能按无上下文处理,返回的候选集可能过宽或为空。
请求从输入到渲染的完整路径
把上面的环节串起来,一次成功的补全经历如下流程。
flowchart TD
A[用户打开提示模板 incident_report] --> B[客户端 prompts/list 获取参数骨架]
B --> C{initialize 中服务端声明 completions?}
C -- 否 --> D[参数退化为自由文本]
C -- 是 --> E[用户输入 severity 前缀 P]
E --> F[客户端防抖后发送 completion/complete]
F --> G[ref/prompt 指向 incident_report]
G --> H[argument 为 severity=P]
H --> I[服务端按前缀过滤枚举值]
I --> J[返回 values total hasMore]
J --> K{客户端渲染候选列表?}
K -- 否 --> D
K -- 是 --> L[用户从下拉框选择 P1]
L --> M[prompts/get 携带 severity=P1]
流程中有两个判断节点,分别对应能力闸门和渲染闸门。任何一处走“否”分支,最终都会落到自由文本输入。图中 prompts/get 携带的是用户选定的值,而不是补全建议本身;补全只影响输入过程,不改变 prompts/get 的请求结构。
服务端在 I 节点做的事取决于实现。最简单的是前缀匹配:对 P 返回所有以 P 开头的枚举值。协议建议服务端返回按相关性排序的建议,并在合适时实现模糊匹配。如果枚举值来自数据库或远程配置,服务端还需要考虑查询延迟,这也是协议建议客户端对快速补全请求做防抖的原因。
静态枚举与运行时补全的取舍
面对“参数候选值从哪来”,工程上有两种做法:把枚举写进模板描述让模型自己读,或者实现 completion/complete 动态返回。两者差别不只是实现成本。
| 维度 | 静态枚举(写进描述) | 运行时补全(completion/complete) |
|---|---|---|
| 候选值来源 | 模板 description 或参数 description 中的文本 | 服务端按 ref 与 argument 实时计算 |
| 客户端渲染 | 依赖模型解析文本,界面通常仍是文本框 | 客户端可直接渲染下拉或弹窗 |
| 值变化后的同步 | 需重新发布模板,客户端重新拉取 | 下次请求即返回新值 |
| 非法值拦截 | 靠 prompts/get 或下游校验 | 输入阶段即可收敛到合法集合 |
| 实现成本 | 低,只改模板文本 | 需声明能力、实现过滤、限流与输入校验 |
| 失败表现 | 模型猜错值,错误延后暴露 | 能力缺失或未渲染时同样退化为手填 |
| 适用场景 | 值极少且长期稳定 | 值较多、随环境变化或依赖其它参数 |
静态枚举的隐性成本在于错误位置。用户输入“紧急”时,prompts/get 可能照样返回一段文本,错误要等到下游按 severity 分流才暴露,排查链路被拉长。运行时补全把收敛点前移到输入阶段,但代价是服务端多了一条需要限流和校验的入口。
协议对服务端的要求包括验证所有补全输入、实施速率限制、控制敏感建议的访问,防止基于补全的信息泄露。如果枚举值本身包含客户名称或内部代号,补全接口就变成了一个信息探测面,这一点在静态枚举方案里不存在。
校验点、可观测指标与失效边界
排查“客户端拿不到候选值”时,按消息顺序逐层确认比猜测界面实现更可靠。
(1)能力层
检查 initialize 响应中是否存在 capabilities.completions。同时确认客户端是否记录了该能力,有些客户端会把服务端能力缓存到会话对象里,重连后未刷新。
(2)请求层
在客户端侧记录 completion/complete 的发出次数、ref.type、argument.name 和 argument.value 长度。若该计数长期为零,问题在客户端发起逻辑;若计数正常但响应为 -32601,问题在服务端能力声明。
(3)结果层
统计响应的 values 长度分布、hasMore 为 true 的比例,以及空结果比例。空结果比例偏高通常指向两个原因:context.arguments 缺失导致依赖型参数无法计算,或服务端过滤逻辑对大小写、前后空白处理不一致。
(4)渲染层
对比“收到非空 values 的响应数”和“用户实际从候选列表中选择的次数”。后者显著低于前者,说明客户端收到了数据但没有展示,属于界面实现问题而非协议问题。
(5)下游层
统计 prompts/get 中参数值不在合法集合内的比例。这个指标反映补全失效造成的实际业务影响,也是判断是否需要回退到静态枚举描述的依据。
这套方案的失效边界比较明确。服务端未声明 completions 时,任何客户端都无法通过协议获取候选值。客户端未实现发起或渲染时,服务端实现得再完整也无济于事。多参数模板中,如果客户端不传 context.arguments,依赖型参数的候选质量会下降。枚举值规模超过单次 100 项上限时,客户端需要处理 hasMore 并决定是否继续请求,而协议并未规定续取的具体交互,这部分仍需实现方自行约定。
一个尚未在协议层解决的问题是补全结果的缓存一致性。协议建议客户端适当缓存,但没有定义失效信号。当服务端的枚举集合发生变化时,客户端缓存的旧候选值可能持续存在,直到会话重建或缓存过期。对于值变化频繁的模板,缓存收益和错误风险需要按业务权衡。
回到开头的场景:输入框为空,是因为 prompts/list 本来就不携带枚举值,而 completion/complete 这条通道在能力声明、客户端发起或结果渲染的某一环断开了。先确认 initialize 里的 completions,再看请求计数,最后看渲染选择率,三步就能定位断点在哪一层。