AI 技术
#MCP#prompts/get#参数校验#JSON-RPC

MCP 提示模板参数校验:为什么客户端按 prompts/get 传入的参数会被服务端拒绝

远程 MCP Server 暴露带必填参数的提示模板时,客户端常因参数名大小写、缺失或类型不符收到 -32602 错误。本文沿 prompts/list 的 arguments 声明到 prompts/get 校验链路,解释服务端如何判定参数合法,对比客户端预校验与服务端宽松解析的取舍,并给出可观测指标与部署边界。

一个反复出现的 422 式失败

假设团队在远程 MCP Server 上暴露了一个代码审查提示模板 code_review。客户端从 prompts/list 拿到声明:参数 code 必填。用户在斜杠命令里选中模板,客户端把模型整理出的参数填进 prompts/get 请求。服务端返回 -32602 Invalid params,提示“缺少必需参数 code”。客户端日志里明明有 Code、code_snippet 或 code 字段。

这类失败不是网络问题,也不是权限问题。它发生在参数从声明到请求、从请求到服务端处理函数之间的校验环节。MCP 规范对提示模板的参数只给出很薄的约束:arguments 是可选列表,每项有 name、description 和 required。规范要求服务端在处理前验证参数,但没有规定参数类型系统,也没有规定服务端必须宽松解析。客户端和服务端各自按自己的理解实现,边界就对不上了。

声明与请求之间的信息落差

prompts/list 返回的 arguments 声明是客户端渲染输入表单的唯一依据。规范示例中,code_review 的声明是:

{
  "name": "code_review",
  "arguments": [
    { "name": "code", "description": "The code to review", "required": true }
  ]
}

客户端拿到这份声明后,通常会做三件事:为每个参数生成一个输入控件;把 required: true 映射为“不填不能提交”;把参数名作为提交时的键。问题出在第三件事上。如果客户端允许模型自动填充,模型可能输出 Code、CODE 或 codeSnippet。如果客户端允许用户手动编辑键名,用户也可能改错。客户端如果只按“有没有值”判断,而不按“键名是否与声明完全一致”判断,就会把错误键名发出去。

prompts/get 请求的 arguments 是一个 JSON 对象,键是参数名,值是字符串。规范示例只展示字符串值:

{
  "method": "prompts/get",
  "params": {
    "name": "code_review",
    "arguments": { "code": "def hello():\n print('world')" }
  }
}

规范没有定义参数值的类型约束。arguments 在 schema 中只是对象,没有为每个参数声明 type。这意味着服务端无法从声明中知道 code 应该是字符串、数字还是布尔值。服务端只能按自己的实现约定来校验。

服务端校验的三个判定点

服务端收到 prompts/get 后,典型实现会依次做三项判定。第一,按 params.name 查找提示定义。找不到就返回 -32602,规范把“无效提示名称”归入这一类。第二,遍历声明中 required: true 的参数,检查请求的 arguments 对象里是否存在同名键。缺失就返回 -32602,规范明确把“缺少必需参数”列为 -32602。第三,把参数值代入模板渲染。这一步最容易出现类型不匹配。

参数名匹配通常是精确字符串比较。code 和 Code 在 JSON 对象里是两个不同的键。服务端如果先做 Object.keys(arguments) 再与声明名逐一比对,大小写不符会直接判为缺失。有些服务端实现会先做 trim 或大小写归一化,但规范没有要求这样做,所以不能假设所有远程服务端都会宽容。

必填性判定依赖声明中的 required 字段。规范说 required 是可选布尔值。如果服务端把缺失的 required 当作 false,那么声明里没写 required 的参数不会被强制检查。如果客户端把缺失的 required 当作 true,就会在本地拦住请求。两边对同一个缺省值的理解不同,也会造成“客户端以为能发、服务端以为不必填”或反过来的错位。

类型不匹配更隐蔽。假设模板内部把参数拼进一段文本,服务端可能直接做字符串拼接。这时传入数字 123 会被隐式转成 "123",不一定报错。但如果服务端在渲染前做了 schema 校验,或者模板逻辑对参数调用了字符串方法,数字就会触发异常。规范没有为提示参数定义 JSON Schema,所以类型校验完全取决于服务端实现。客户端如果从模型输出里拿到 "true" 字符串而不是布尔值 true,也可能在服务端布尔判断时得到意外结果。

一次失败的完整数据流

下面的流程图展示贯穿本文的场景:客户端从 prompts/list 拿到 code_review 声明,模型生成参数,客户端提交 prompts/get,服务端校验失败。

flowchart TD
  A[prompts/list 请求] --> B[服务端返回 arguments 声明]
  B --> C[客户端渲染输入控件]
  C --> D[模型或用户填充参数]
  D --> E{客户端预校验}
  E -- 键名与声明一致且必填齐全 --> F[发送 prompts/get]
  E -- 键名不一致或缺失 --> G[本地拦截并提示]
  F --> H{服务端校验}
  H -- 名称存在且必填齐全 --> I[渲染模板并返回 messages]
  H -- 名称不存在 --> J[-32602 无效提示名称]
  H -- 必填参数缺失 --> K[-32602 缺少必需参数]
  H -- 类型不匹配 --> L[-32603 或渲染异常]

图中关键转折在 E 和 H 两个判定点。E 是客户端预校验,H 是服务端校验。如果 E 被跳过或实现过松,错误就会流到 H。H 的失败分支中,名称不存在和必填缺失都映射到 -32602,类型问题可能落到 -32603。客户端看到 -32602 时,不能只提示“参数错误”,而要区分是名称问题还是必填问题,否则用户不知道该改哪里。

客户端预校验与服务端宽松解析的取舍

两种策略解决的是同一个问题:让请求在到达模板渲染前变得合法。客户端预校验把检查提前,服务端宽松解析把检查放宽。两者不是互斥的,但默认选择不同会带来不同的失败表现。

维度客户端严格预校验服务端宽松解析
校验时机发送 prompts/get 之前收到请求之后
参数名大小写按声明精确匹配,不一致即拦截可归一化后匹配,也可能不归一化
缺失必填项本地阻止提交,提示用户补填服务端返回 -32602
类型处理按控件类型约束输入可能隐式转字符串,也可能报错
错误反馈速度快,无需网络往返慢,需一次 RPC
对模型输出的容忍度低,模型键名错误会被拦住取决于实现,可能静默修正
实现复杂度客户端需要维护声明到表单的映射服务端需要维护归一化与类型转换规则
跨客户端一致性每个客户端各自实现,行为可能不同由服务端统一,但远程服务端不可控

客户端严格预校验的收益是反馈快、错误信息可以精确到字段。代价是客户端必须完整消费 arguments 声明,包括 required 的缺省语义。如果客户端把模型生成的参数直接透传,不做键名比对,预校验就形同虚设。

服务端宽松解析的收益是兼容更多客户端。代价是可能掩盖客户端的错误。比如服务端把 Code 归一化成 code,请求成功了,但客户端下次仍然生成错误键名,问题没有暴露。更危险的是类型宽松:服务端把 "false" 字符串当作真值,模板行为就和用户意图相反。

工程上通常建议客户端做严格预校验,服务端做防御性校验但不做静默修正。客户端负责把声明翻译成用户能看懂的输入约束;服务端负责在参数不合法时返回明确的 -32602,并在错误 data 中带上缺失或未知的参数名。这样错误既不会静默通过,也不会让用户面对一个没有上下文的“参数错误”。

可观测指标与定位路径

参数校验失败如果只靠用户截图,定位会很慢。客户端和服务端都需要记录可区分的信号。

客户端侧应记录:prompts/list 返回的声明快照,包括每个参数的 name 和 required;模型生成的原始参数对象;预校验拦截原因,区分“键名不在声明中”“必填项缺失”“类型与控件不符”;以及最终发出的 prompts/get 请求体。请求体里不要记录敏感参数值,但键名和值类型要保留。

服务端侧应记录:收到的 params.name、arguments 的键集合、声明中必填参数的集合、校验失败的分支。失败分支至少区分三类:提示名称不存在、必填参数缺失、参数值类型或渲染异常。返回 -32602 时,错误 message 应简短,data 可以带 missingArguments 和 unknownArguments 两个数组。规范允许 data 由发送方定义,这给结构化错误留了空间。

一个实用的定位顺序是:先看服务端错误分支。如果是“必填参数缺失”,对比服务端记录的键集合和客户端记录的请求体键名,通常能直接看到大小写差异或拼写差异。如果是“提示名称不存在”,检查客户端是否在 prompts/list 分页后只缓存了部分列表。如果是渲染异常,检查参数值类型,尤其是模型输出的数字和布尔值是否被包成了字符串。

哪些前提不成立时方案会退化

第一种退化是声明与实现不一致。服务端在 prompts/list 中声明 code 必填,但 prompts/get 的处理函数实际读取的是 code_snippet。客户端按声明填了 code,服务端却找不到自己期望的键。这类问题无法靠客户端预校验发现,只能靠服务端在开发阶段做声明与处理函数的对照测试。

第二种退化是客户端不消费声明。有些客户端把提示模板当作纯文本展示,让用户自由输入参数,不解析 arguments。这时 required 和参数名都没有进入 UI,模型生成的键名直接进入请求。服务端一旦严格校验,失败率就会升高。

第三种退化是远程服务端版本漂移。客户端缓存的 prompts/list 结果可能来自旧版本服务端,参数声明已经变化,但客户端没有处理 notifications/prompts/list_changed。规范说声明了 listChanged 能力的服务端应该发送变更通知,客户端应该尊重能力协商。如果客户端忽略通知,就会用旧声明校验新请求。

第四种退化是类型假设。规范没有为提示参数定义类型,客户端如果假设所有参数都是字符串,把模型输出的对象或数组序列化成字符串,服务端可能无法按预期解析。反过来,服务端如果假设参数一定是字符串,收到嵌套对象时可能直接抛异常。双方都需要在边界上明确:arguments 的值按字符串处理,复杂结构由模板内部约定编码方式。

部署边界与尚未解决的问题

当前规范给提示参数留下的约束很薄:只有名称和必填性,没有类型、没有枚举、没有默认值。补全 API 可以为参数提供候选值,但它不改变 prompts/get 的校验规则。这意味着参数校验的强度主要靠实现约定,而不是协议强制。

在远程 MCP Server 场景下,服务端无法控制客户端实现,客户端也无法控制服务端版本。可行的做法是把校验责任分层:客户端按声明做键名和必填性的严格预校验,服务端做防御性校验并返回结构化错误,双方都记录可对比的键集合。类型问题暂时只能靠约定,把参数值统一为字符串,复杂结构在模板内部解析。

仍未解决的问题是:当模型生成参数时,客户端是否应该自动修正键名。自动修正能降低失败率,但会掩盖模型输出与声明不一致的信号。如果不修正,用户会看到更多 -32602,但每次失败都能推动客户端或提示词改进。这个取舍没有协议层面的答案,取决于产品更看重首次成功率还是可诊断性。

资料来源

  1. Model Context Protocol Specification - Prompts
  2. Model Context Protocol Specification - Schema Reference
  3. 提示 – MCP 中文站(Model Context Protocol 中文)
  4. 提示词 – Model Context Protocol (MCP)