一个只在“可选参数”上出现的报错
设想企业知识库里有一个 MCP 工具 search_docs,用来按关键词检索内部文档。它的输入参数有三个:query 是必填的检索词,top_k 控制返回条数,department 限定部门范围。后两个参数在 inputSchema 里没有放进 required 数组,top_k 还写了 "default": 10。
上线后,Agent 在“帮我找一下报销流程”这类请求里只传了 query。服务端处理函数读到 args.top_k 得到 undefined,随后在 Math.min(args.top_k, 50) 或数据库分页计算中抛错;另一条路径里,服务端把 undefined 直接写进了查询条件,检索结果变成空集,但工具返回的是成功状态。
这类故障容易被误判成模型“不听话”。实际上模型的行为符合它看到的 schema:字段没有列入 required,模型就可以不传。问题出在服务端把“schema 允许省略”当成了“运行时一定有值”。
required 与 default 各自管什么
required 只约束键是否存在
JSON Schema 的 required 是一个针对对象实例的断言关键字。它规定实例对象必须包含列出的成员名,缺任何一个,该实例就不通过校验。它不关心值是什么类型、是否为空字符串、是否为 null,这些由 type、minLength 等其它关键字负责。
因此 required: ["query"] 的含义很窄:参数对象里必须有 query 这个键。top_k 不在数组里,就表示缺键合法。
default 是注解,不是填充指令
JSON Schema 规范把 default 归入“基础元数据注解”词汇。资料中的验证规范目录把它列在 title、description、deprecated、readOnly、examples 同一组里,这组关键字的作用是给工具、界面或文档提供提示信息,而不是对实例做断言。
规范对注解与断言做了明确区分:断言决定实例是否合法,注解携带非断言信息。校验器在验证一个实例时,不会因为 default 存在就往实例里补一个值。换句话说,"default": 10 表达的是“如果没有提供,建议按 10 理解”,它把这条建议写给读 schema 的人和程序,但不改变被校验的 JSON 本身。
这一点在 MCP 场景里尤其关键。MCP 的工具定义包含名称、描述、输入 schema 和可选的输出 schema,schema 会直接参与模型对工具的理解和参数生成。模型看到 default 可能据此省略字段,也可能照抄默认值,两种行为都符合规范;服务端不能假设任何一种一定发生。
缺键与显式 null 是两种状态
需要区分三种参数对象:
- 键不存在:
{"query": "报销"},读取args.top_k得到undefined(JavaScript)或等价的语言级“无值”。 - 键存在但值为
null:{"query": "报销", "top_k": null}。这通过了required校验,但type: "integer"会拒绝它。 - 键存在且有值:
{"query": "报销", "top_k": 5}。
服务端常见的错误是把后两种当成同一种处理,或者用 args.top_k || 10 兜底,结果 0 也被替换成 10。
参数对象在链路上如何丢掉字段
下面的流程展示一次典型的工具调用,从模型生成参数到服务端解引用,标出了缺键产生和暴露的位置。
flowchart TD
A[客户端列出工具] --> B[模型读取 inputSchema]
B --> C{字段在 required 中?}
C -- 是 --> D[模型生成该字段]
C -- 否 --> E[模型可省略该字段]
D --> F[组装 arguments 对象]
E --> F
F --> G[客户端发送 tools/call]
G --> H[服务端接收 arguments]
H --> I{服务端是否补默认值?}
I -- 否 --> J[读取 args.top_k 得到 undefined]
I -- 是 --> K[按默认值填充]
J --> L[解引用报错或写入空值]
K --> M[正常执行]
关键转折点在 E 和 I 两处。E 处模型省略字段是合法行为,I 处服务端没有做任何填充,缺键就一路传到业务代码。中间没有任何协议层会自动补齐,因为 JSON Schema 的 default 不承担这个职责,MCP 规范也没有规定服务端必须按 schema 默认值填充参数。
服务端按必填假设解引用会怎样
直接解引用导致运行时异常
最直接的失败是类型错误。以 JavaScript 为例,args.top_k.toFixed() 在 top_k 缺键时抛出 TypeError: Cannot read properties of undefined。这类错误如果没被捕获,会变成 JSON-RPC 层面的内部错误,客户端看到的是一个笼统的失败,Agent 往往据此重试同样的调用,形成循环。
空值写入造成静默错误
更隐蔽的是把 undefined 带进下游。构造数据库查询时,department: args.department 在缺键时可能被序列化成 null 或直接省略条件,检索范围从“某部门”变成“全部部门”。工具返回成功,内容却是错的。对 Agent 来说,这比报错更难发现,因为它拿到的是看起来正常的文本结果。
校验时机决定错误归属
如果服务端在进入业务逻辑前先用 schema 校验 arguments,缺键的可选字段不会触发失败,因为 schema 本来就允许省略。校验只能拦住 required 字段缺失和类型错误,拦不住“可选字段缺键但业务代码假设有值”。这说明校验点必须和默认值处理放在一起,不能只依赖 schema 校验。
两种修法:客户端预填与服务端容错
客户端预填默认值
客户端在发送 tools/call 前,按 inputSchema 里的 default 给缺失字段补值。好处是服务端拿到的参数对象结构稳定,业务代码可以按“字段一定存在”来写。代价是客户端必须正确解析 schema,包括嵌套对象、数组元素和 $ref 引用;一旦解析不全,补值就会漏。
还有一个语义风险:客户端补的值会被服务端当成模型显式传入的值。如果默认值依赖服务端配置(比如租户级的条数上限),客户端并不知道真实默认值,补出来的值可能覆盖服务端策略。
服务端容错解析
服务端在读取参数时统一走一个解析层:先判断键是否存在,不存在再用本地默认值,存在则做类型和范围检查。这种方式把默认值的权威留在服务端,客户端和模型都可以省略字段。
代价是每个工具都要写解析逻辑,容易遗漏。工程上通常把解析封装成按 schema 驱动的通用函数,但要注意 default 只是注解,通用函数读取它是“采纳建议”,不是“执行规范”,所以函数行为需要在文档里写清楚。
对比与选择
| 维度 | 客户端预填 | 服务端容错解析 |
|---|---|---|
| 默认值权威 | 分散在客户端,可能与服务端策略冲突 | 集中在服务端,随配置变化 |
| 服务端代码 | 可假设字段存在,逻辑简单 | 需要统一解析层,初期成本高 |
| 对第三方客户端 | 无法控制,行为不一致 | 对所有客户端一致 |
| 缺键可见性 | 被预填掩盖,日志里看不到模型省略了什么 | 可在解析层记录缺键,便于观测 |
| 适用场景 | 自有客户端、默认值稳定 | 多客户端接入、默认值随租户或环境变化 |
多数生产环境会两者并用:客户端尽量补,服务端仍然容错。只做客户端预填的风险在于,任何绕过该客户端的调用方都会把缺键直接打到服务端。
校验点应该放在哪里
入口处做结构校验
服务端收到 arguments 后,第一步按 inputSchema 做结构校验,确认 required 字段齐全、类型正确。这一步用标准校验器即可,失败时返回明确的参数错误,而不是让异常在业务层炸开。
解析层做默认值合并
第二步是解析层:对每个可选字段判断键是否存在,缺键时填入服务端默认值,存在时保留原值并做范围检查。这里要避免用真值判断兜底,0、空字符串、false 都是合法值。
伪代码示意:
function resolveArgs(schema, raw):
args = clone(raw)
for field in schema.properties:
if field not in raw:
if field has serverDefault:
args[field] = serverDefault[field]
else if field in schema.required:
return error("missing required field: " + field)
return args
这段逻辑的关键在于区分“缺键”和“有值”,并明确服务端默认值来自哪里。如果直接读 schema 的 default,要接受它只是建议这一事实,不能把它当成协议保证。
业务层不再假设可选字段存在
解析层之后,业务代码可以按字段一定存在来写,但前提是解析层覆盖了所有工具。新增工具时如果忘了注册默认值,缺键仍会漏到业务层。工程上通常让工具注册和默认值声明放在同一处,减少遗漏。
需要观察的信号与常见失败模式
可观测指标
- 参数校验失败率,按工具名和缺失字段分组,能直接看出哪些可选字段经常被省略。
- 解析层补默认值的次数,按字段统计。补值频率高说明模型倾向于省略,schema 描述可能需要补充说明。
- 工具返回成功但结果为空的比例。这类信号可能对应空值写入,需要结合查询条件日志排查。
典型失败模式
模型省略字段本身不是故障,故障来自服务端对缺键的处理。常见的有:用 || 兜底把 0 替换掉;把缺键的可选字段直接拼进 SQL 条件;在嵌套对象里只检查了顶层键,子字段缺键仍然报错;客户端预填时把 null 当成缺键,覆盖了模型显式传入的空值语义。
还有一种边界情况:字段在 required 里,但模型仍然没传。这时结构校验会拦住,返回参数错误。Agent 收到这类错误后是否重试、是否改写参数,取决于客户端如何呈现错误信息。错误信息里写清缺了哪个字段,比笼统的“参数无效”更有助于模型修正。
适用边界与仍未解决的问题
default 作为注解这一语义是稳定的,但“谁来落实默认值”在 MCP 生态里没有统一规定。不同客户端对 schema 的解析深度不同,不同服务端对缺键的容忍度也不同。跨实现调用时,最稳妥的做法是服务端不假设任何可选字段存在,客户端不假设服务端会补值。
尚未解决的问题包括:嵌套对象和数组元素的默认值如何统一处理;默认值随租户或环境变化时,schema 里的 default 是否还应该写;以及当模型显式传入 null 表示“不限”时,如何与缺键区分。这些问题没有协议级答案,只能在具体实现里约定并写进工具描述。