Warp MCP 工具调用整数参数强制转换:用工具自身 JSON Schema 修复 strict MCP 服务器的 `5.0` 解析失败
Warp MCP 工具调用整数参数强制转换:用工具自身 JSON Schema 修复 strict MCP 服务器的 5.0 解析失败
Warp 作为 agentic 开发环境,会把 LLM 生成的工具参数经 google.protobuf.Struct 中转后调用 MCP 服务器。由于 Struct.NumberValue 把所有数字都存成 float64,整数字段(如 {"line": 5})在线上会被序列化成 {"line": 5.0},导致 GoLand 等 strict MCP 服务器直接报 Failed to parse literal '5.0' as an int value 而拒绝调用。本文完整讲解 Warp 的客户端侧修复方案——在派发前依据工具自身的 JSON Schema "type" 关键字,把整数字段的整数值强制还原为整数(5 而非 5.0),并给出源码级实现剖析、各场景行为矩阵与 20 个单元测试的验证证据。读完本文,你将掌握 Warp 内部 MCP 工具参数从模型输出到服务器派发的完整类型链路,以及一套可复用的"基于 Schema 的数字类型修复"实现思路。
问题背景:整数字段为何会在线上变成 5.0
一条完整的数据链路
当用户触发一个带整数参数的 MCP 工具(例如代码导航工具 get_symbol_info 的 line / column)时,参数在 Warp 内部经历了如下往返:
- 模型产出 JSON 字符串:LLM 语义正确地生成
{"line": 5, "column": 1, "filePath": "server.go"}; - 服务端解析进
google.protobuf.Struct:Struct的NumberValue只保存float64,整型与浮点型的区别在此被抹平,5变为5.0; - Rust 客户端用
serde_json::Number::from_f64重新序列化:整数值浮点会被写成5.0而不是5; - strict MCP 服务器反序列化:Kotlin/Java(如 GoLand)、严格 Python、Rust 的
i64反序列化器会拒绝把5.0解析为整数字段,抛出:
Failed to parse literal '5.0' as an int value
由此,即使模型生成的参数在语义上完全正确,这些工具在 Warp 中也会变得完全不可用。且失败对用户不可见——工具调用确实被派发出去,只是 MCP 服务器返回了错误。
源码证据:类型信息在哪一步丢失
第 2→3 步的类型丢失在仓库中有直接实现证据。convert.rs 中的 prost_to_serde_json 负责把 prost_types::Value 转成 serde_json::Value,其中 NumberValue(n) 分支正是:
NumberValue(n) => Number(
serde_json::Number::from_f64(n)
.ok_or_else(|| format!("float {n} is not valid JSON number"))?,
),
serde_json::Number::from_f64(5.0) 得到的是 5.0 而非 5,这就是"整型参数被序列化成浮点字面量"的直接来源。该转换同时服务于 MCP 工具调用参数的生成路径(convert.rs 中 CallMcpTool 通过 prost_to_serde_json(prost_types::Value { kind: Some(Kind::StructValue(args)) }) 构造 AIAgentActionType::CallMCPTool 的 input)。
修复方案:客户端侧、Schema 驱动的整数还原
核心思路与目标
修复的核心思路是:在客户端派发前,用工具自身的 input_schema(JSON Schema 的 "type" 关键字)判断哪些字段需要还原,把"值为整数的浮点"(如 5.0)强制写回整数(5)再发送。方案目标(来自 specs/APP-4105/PRODUCT.md):
- 带整型参数的工具调用能被 strict MCP 服务器接受(要求
5而非5.0); - 带浮点参数的工具调用(如
temperature: 0.7)继续正常工作,浮点一律不碰; - 混合参数或纯字符串参数的工具调用不受影响;
- 零服务端改动、零 proto 改动——修复完全发生在客户端。
明确不做的事(Non-goals)
文档同时划清了边界,避免过度设计:
- 不修复
structpb.NumberValue的底层类型信息丢失。有损的服务端编码仍然存在,本修复只是在派发时依据工具 Schema 恢复整数字面量形态; - 不保留 2⁵³ 以上大整数的完整 64 位精度。这些值在客户端看到之前就已经被
float64往返取整丢失了位数,本修复只能恢复字面量形态、不能找回丢失的比特。实际上这只会影响超大 ID 和时间戳,典型 MCP 工具用的都是小整数; - 不改动服务器行为,也不改动 LLM prompt。
值得注意的一点:文档最初的非目标写明"只处理顶层属性、不遍历嵌套对象/数组/JSON Schema 组合器(anyOf/oneOf/$ref)",但从当前仓库的最终实现看(见下节),coerce_integer_args 实际实现为递归遍历,已覆盖嵌套对象、数组、元组式 items、additionalProperties 以及 oneOf/anyOf/allOf 组合器,仅 $ref 仍被跳过——这是实现相对原计划的一次升级(测试中明确提到为 issue #10596 的毫秒时间戳场景做了支持)。
核心实现剖析:coerce_integer_args 源码级解读
完整的实现位于 call_mcp_tool.rs,由四个函数协作完成。
调用时机:派发前一刻
在 CallMCPToolExecutor::execute 中,先通过 TemplatableMCPServerManager 拿到工具 Schema,命中则执行强制转换,随后才用转换后的参数构造 CallToolRequestParams 发起调用:
// Coerce whole-number f64 args to i64 for fields declared as `"type": "integer"`
// in the tool's input schema. ...
if let Some(schema) = templatable_mcp_manager.tool_input_schema(*server_id, name.as_str()) {
coerce_integer_args(&mut arguments, &schema);
}
Schema 查询接口定义在 crates/mcp/src/lib.rs 的 tool_input_schema:按工具名在已连接服务器的 tools 列表里查找并返回 input_schema(Arc<JsonObject>)。如果查找失败(服务器中途断开、工具名不匹配、无匹配服务器),工具调用照常以未转换的原始参数继续派发,失败模式不会比修复前更糟。
入口 coerce_integer_args
pub(crate) fn coerce_integer_args(
args: &mut serde_json::Map<String, serde_json::Value>,
input_schema: &serde_json::Map<String, serde_json::Value>,
) {
let mut wrapped = serde_json::Value::Object(std::mem::take(args));
let schema_value = serde_json::Value::Object(input_schema.clone());
coerce_value_against_schema(&mut wrapped, &schema_value);
if let serde_json::Value::Object(restored) = wrapped {
*args = restored;
}
}
入口把参数 Map 包装成 Value 交给递归遍历器,这样顶层与嵌套层走同一套逻辑——顶层 oneOf/anyOf/allOf 和 additionalProperties 的待遇与深层完全一致。
类型判定 schema_declares_integer
fn schema_declares_integer(schema: &serde_json::Value) -> bool {
match schema.get("type") {
Some(serde_json::Value::String(s)) => s == "integer",
Some(serde_json::Value::Array(types)) => {
types.iter().any(|t| t.as_str() == Some("integer"))
}
_ => false,
}
}
除了常见的 "type": "integer" 字符串写法,还识别可空形式的类型数组 "type": ["integer", "null"],这正是很多 MCP 工具 Schema 的真实写法。
数值还原 coerce_number_to_int
fn coerce_number_to_int(n: &mut serde_json::Number) {
let Some(f) = n.as_f64() else { return };
if n.is_i64() || n.is_u64() {
return; // 本来就是整数,无需处理
}
if f.fract() != 0.0 {
return; // 非整数小数,保持原样
}
if let Ok(i) = i64::try_from(f as i128) {
*n = serde_json::Number::from(i);
}
}
三个关键判定:
- 已是 i64/u64 直接跳过:避免对早已正确的整数做无谓转换;
fract() != 0.0不转换:若 LLM 对整数字段生成了5.5,保持原样交给服务器拒绝——静默截断比清晰报错更糟,这是文档明确认可的行为;i64::try_from(f as i128)才还原:保证还原后的值仍处于i64可表示范围,超范围则放弃。
递归遍历 coerce_value_against_schema
这是实现真正纵深的地方。它按 Schema 结构递归走遍参数的每一层:
fn coerce_value_against_schema(value: &mut serde_json::Value, schema: &serde_json::Value) {
if schema_declares_integer(schema)
&& let serde_json::Value::Number(n) = value
{
coerce_number_to_int(n);
}
// 遍历所有组合器(oneOf / anyOf / allOf),同一层可能出现多个
for combinator in ["oneOf", "anyOf", "allOf"] {
if let Some(branches) = schema.get(combinator).and_then(|b| b.as_array()) {
for branch in branches {
coerce_value_against_schema(value, branch);
}
}
}
match value {
serde_json::Value::Object(map) => {
let properties = schema.get("properties").and_then(|p| p.as_object());
let additional = schema.get("additionalProperties");
for (k, v) in map.iter_mut() {
if let Some(prop_schema) = properties.and_then(|p| p.get(k)) {
coerce_value_against_schema(v, prop_schema);
} else if let Some(extra_schema) = additional
&& extra_schema.is_object()
{
coerce_value_against_schema(v, extra_schema);
}
}
}
serde_json::Value::Array(items) => {
if let Some(item_schema) = schema.get("items") {
match item_schema {
serde_json::Value::Object(_) => {
for elem in items.iter_mut() {
coerce_value_against_schema(elem, item_schema);
}
}
serde_json::Value::Array(schemas) => {
for (elem, elem_schema) in items.iter_mut().zip(schemas.iter()) {
coerce_value_against_schema(elem, elem_schema);
}
}
_ => {}
}
}
}
_ => {}
}
}
覆盖能力总结:
| Schema 形态 | 处理方式 |
|---|---|
properties 中的具名字段 |
按字段名匹配子 Schema 递归 |
嵌套 object |
递归进入内层 properties |
array + items(对象式) |
每个元素套同一 Schema |
array + items(元组式,Schema 数组) |
按位置配对逐一递归 |
oneOf / anyOf / allOf |
遍历每一个分支(同一层可同时存在多个组合器,全部访问) |
additionalProperties(对象式) |
对未匹配具名字段的键应用该 Schema |
$ref |
明确跳过(解析需要根 Schema) |
其中"遍历每一个分支"是关键细节:由于强制转换是单调的(对不匹配的值是无害的 no-op),访问与值不匹配的分支是安全的,因此可以放心地遍历所有分支而不是在第一个匹配处短路——测试 one_of_with_multiple_branches_all_visited 专门验证了这一点。
各场景行为矩阵
整数参数(正常路径)
工具 Schema:
{ "properties": { "line": { "type": "integer" }, "column": { "type": "integer" }, "filePath": { "type": "string" } } }
模型生成 {"line": 5, "column": 1, "filePath": "server.go"},MCP 服务器实际收到:
{"line": 5, "column": 1, "filePath": "server.go"}
——整数,而不是 5.0 / 1.0。
浮点参数(永不触碰)
工具 Schema 为 { "properties": { "temperature": { "type": "number" } } },模型输入 {"temperature": 0.7},服务器收到 {"temperature": 0.7} 不变。浮点字段一律不碰。
如果模型恰好为 number 字段生成了整数值(如 {"temperature": 1.0}),则保持原样按浮点序列化——这正是 Schema 声明为 number 的正确行为。
Schema 查找失败(优雅回退)
工具的 input_schema 定位失败时(服务器中途断开、工具名不匹配、无匹配服务器),工具调用以未转换的原始参数继续派发,失败模式与修复前完全一致,绝不因转换路径额外报错。
整数字段中的非整数小数(不强制、不截断)
LLM 对整数字段生成 {"line": 5.5} 时,值保持原样不被转换,交由 MCP 服务器拒绝——这是正确行为:模型生成了坏输入,静默截断(变成 5)比清晰的错误更糟。
blocklist 中的 MCP 工具调用详情(渲染与派发同频)
Warp 的 AI block 中,每个 MCP 工具调用旁会渲染调用详情,展示即将实际派发的参数。同一套强制转换在派发时和渲染时都会执行,因此整数字段在 UI 中显示为 5 而非 5.0,保证用户看到的字面量与线上实际发送的内容一致。
该路径的实现证据在 block.rs:处理 CallMCPTool 动作的渲染分支先对参数 Map 克隆后调用 coerce_integer_args,再格式化成 MCP Tool: {name} 命令文本;inline_action/requested_command.rs 中的 VIEWING_MCP_TOOL_DETAIL_MESSAGE 常量("Viewing MCP tool call detail")则对应详情文本的查看提示。两条路径(渲染与派发)是各自独立的:某条路径的 Schema 查找失败,只会让该路径独立回退到原始未转换值,两者互不耦合。
单元测试验证:20 个用例的完整测试矩阵
配套测试位于 call_mcp_tool_tests.rs,直接覆盖文档 Validation 一节要求的全部场景,并超出文档补充了大量嵌套边界。代表性用例:
| 测试函数 | 验证内容 |
|---|---|
whole_float_is_coerced_when_schema_declares_integer |
整数字段 + 整数值浮点 → 序列化为 "5"、as_i64() == Some(5) |
no_coercion_when_not_typed_as_integer |
number 类型 / 无 properties / 无 type 键 → 一律保持 "1.0" |
nested_object_integer_is_coerced |
嵌套对象内层整数字段同样被还原 |
array_items_integer_is_coerced |
数组元素 [1.0, 2.0, 3.5] → 1、2 还原,3.5 保留 |
nullable_integer_type_array_is_coerced |
"type": ["integer", "null"] 可空写法同样生效,null 不受影响 |
one_of_branch_with_integer_is_coerced |
复现 issue #10596:filters 数组 oneOf 分支中的毫秒时间戳 1730419200000.0 → 1730419200000 |
any_of_at_property_level_is_coerced / all_of_with_integer_branch_is_coerced |
属性级组合器各分支覆盖 |
additional_properties_schema_is_applied |
additionalProperties 对象式 Schema 对未具名键生效 |
fractional_value_is_not_coerced_to_int |
2.5 不截断 |
root_level_one_of_branch_with_integer_is_coerced |
根级组合器分支全部遍历 |
negative_whole_float_is_coerced |
-42.0 → -42 |
tuple_style_items_coerces_positional_schemas |
元组式 items 按位置配对 |
multiple_combinators_at_same_level_are_all_traversed |
同一层 oneOf+anyOf+allOf 并存时全部访问 |
already_integer_value_is_unchanged |
本已是整数的值不被改写 |
所有断言都同时检查序列化字符串形态("5" 而非 "5.0")与类型 round-trip(as_i64() / as_f64()),从两个维度锁定"字面量正确"这一目标。
验收标准与验证步骤
成功标准
对照 specs/APP-4105/PRODUCT.md 的 Success Criteria:
- 调用 GoLand MCP 工具(如
get_symbol_info传line: 5)成功返回结果,不再出现Failed to parse literal '5.0'解析错误; number类型参数(如temperature: 0.7)继续正常工作;- Schema 中无整数参数的工具有效调用不受影响;
- 派发时找不到 Schema 的工具仍能派发(使用未转换参数),不会因强制转换路径报错;
- blocklist 中渲染的 MCP 工具调用详情对整数字段显示整数(如
5),与服务器实际收到的一致; - 既有 MCP 工具调用测试全部继续通过。
推荐验证流程
- 手动验证:将 GoLand MCP 服务器接入 Warp,在合法 line/column 调用
get_symbol_info,确认返回有效结果而非Failed to parse literal '5.0'错误; - 单元测试:运行 call_mcp_tool_tests.rs 中的 coercion helper 测试,覆盖上文矩阵的 20 个用例;
- 回归:运行既有 MCP 相关测试,确认无失败。
已知边界总结
- 底层类型信息丢失仍在:
structpb.NumberValue的 f64 有损编码无法修复,本方案只是在派发时依据 Schema 恢复整数字面量; - 2⁵³ 以上的大整数精度已不可恢复:只影响超大 ID 与时间戳,典型 MCP 工具不受影响;
$ref不被解析:需要根 Schema 才能解析,当前无已知 MCP 工具依赖该特性;- 文档中无未决问题(Open Questions: None at this time),方案的边界与行为已被实现和测试完全确定下来。
结语
这个看似几行代码的修复,解决的是 agent 工具调用链上一个隐蔽而致命的类型失真问题:protobuf.Struct 的 float64 存储抹平了 JSON 的整型/浮点语义,strict 类型语言的反序列化器则寸步不让。Warp 的做法提供了很好的工程范式——不追求改造有损的底层编码,而是在派发边界用工具自身的 Schema 做一次类型还原,同时把"渲染展示"与"实际派发"锁在同一条转换路径上,保证 UI 诚实、行为可预期。实现从最初"仅顶层属性"的设想演进为递归遍历(嵌套对象、数组、元组 items、additionalProperties、oneOf/anyOf/allOf 全覆盖,仅跳过 $ref),配套 20 个针对性单元测试从字面量与类型两个维度锁定了行为。如果你正在自研 MCP 客户端或处理类似"类型被 float 化"的序列化问题,这套"Schema 驱动的派发前类型还原"思路可以直接复用。