Warp MCP 工具调用整数参数强制转换:用工具自身 JSON Schema 修复 strict MCP 服务器的 `5.0` 解析失败

原创2026-10-03 09:40:25573 阅读
文章标签:桌面应用开发者工具人工智能AI 应用AI Agent代码智能体

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 内部经历了如下往返:

  1. 模型产出 JSON 字符串:LLM 语义正确地生成 {"line": 5, "column": 1, "filePath": "server.go"};
  2. 服务端解析进 google.protobuf.Struct:Struct 的 NumberValue 只保存 float64,整型与浮点型的区别在此被抹平,5 变为 5.0;
  3. Rust 客户端用 serde_json::Number::from_f64 重新序列化:整数值浮点会被写成 5.0 而不是 5;
  4. 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 工具调用测试全部继续通过。

推荐验证流程

  1. 手动验证:将 GoLand MCP 服务器接入 Warp,在合法 line/column 调用 get_symbol_info,确认返回有效结果而非 Failed to parse literal '5.0' 错误;
  2. 单元测试:运行 call_mcp_tool_tests.rs 中的 coercion helper 测试,覆盖上文矩阵的 20 个用例;
  3. 回归:运行既有 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 驱动的派发前类型还原"思路可以直接复用。

登录后查看全文
warp