Skip to content

fix(mcp): recursively coerce integer args nested in tool input schemas#11407

Open
david-engelmann wants to merge 6 commits into
warpdotdev:masterfrom
david-engelmann:david/10596-mcp-integer-coercion
Open

fix(mcp): recursively coerce integer args nested in tool input schemas#11407
david-engelmann wants to merge 6 commits into
warpdotdev:masterfrom
david-engelmann:david/10596-mcp-integer-coercion

Conversation

@david-engelmann
Copy link
Copy Markdown
Contributor

Fixes #10596.

Why

MCP tool args round-trip through google.protobuf.Struct between the agent backend and the Warp client. structpb.NumberValue stores all numbers as f64, erasing the integer/float distinction. By the time args arrive client-side as serde_json::Value, an integer 5 has become 5.0, and serde_json serializes it back as \"5.0\" — which strict MCP servers reject for \"type\": \"integer\" fields.

The previous mitigation (coerce_integer_args, originally from #6945) only walked depth-1 properties and matched the literal string \"integer\", so every nested or composed integer field leaked the float through.

What

Walks schema and args in parallel covering:

  • nested properties (objects)
  • array items (single-schema and positional/tuple form)
  • additionalProperties (for keys outside properties)
  • allOf / oneOf / anyOf (every branch applied — coercion is conservative and a non-matching branch is a no-op, so this sidesteps the ambiguity of "picking the right branch")
  • internal $ref pointers (#/$defs/Foo, #/definitions/Foo) with a cycle guard
  • nullable type-arrays like [\"integer\", \"null\"] and singleton [\"integer\"]

Intentionally skipped (no-op, preserves existing wire behavior): external $ref URLs, not, if/then/else, patternProperties. These are vanishingly rare in real MCP tool schemas, and skipping them never makes a value worse — at worst the float survives, which is today's behavior.

The public signature coerce_integer_args(&mut Map, &Map) is unchanged, so the existing call site at app/src/ai/blocklist/action_model/execute/call_mcp_tool.rs is untouched.

Before / after (the issue's PANW repro)

Schema (excerpt):

```yaml
AuditManagementLogRequest:
type: object
properties:
request_data:
type: object
properties:
search_from: { type: integer }
search_to: { type: integer }
filters:
type: array
items:
oneOf:
- { properties: { value: { type: array, items: { type: string } } } }
- { properties: { value: { type: integer } } }
```

Before — wire payload:

```json
{ "request_data": { "search_from": 0.0, "search_to": 100.0,
"filters": [{ "field": "timestamp", "operator": "gte", "value": 1730419200000.0 }] } }
```

After — wire payload:

```json
{ "request_data": { "search_from": 0, "search_to": 100,
"filters": [{ "field": "timestamp", "operator": "gte", "value": 1730419200000 }] } }
```

Test plan

  • `cargo build -p warp --lib` — clean
  • `cargo test -p warp --lib ai::blocklist::action_model::execute::call_mcp_tool` — 21/21 pass (2 existing + 19 new)
  • `cargo clippy -p warp --lib --tests -- -D warnings` — clean
  • `cargo fmt -p warp -- --check` — clean

New test coverage by shape:

  • nested object: `nested_object_integer_is_coerced`
  • array items (single schema): `array_items_integer_is_coerced`
  • tuple items: `tuple_items_coerced_positionally`
  • `oneOf` branch: `one_of_branch_integer_is_coerced`
  • `anyOf` branch: `any_of_branch_integer_is_coerced`
  • `allOf` branches: `all_of_branches_apply_in_order`
  • nullable [\"integer\", \"null\"]: `nullable_integer_is_coerced` + `null_value_for_nullable_integer_passes_through`
  • singleton [\"integer\"]: `singleton_type_array_integer_is_coerced`
  • internal `$ref`: `internal_ref_resolves`
  • self-referencing `$ref` (cycle terminates): `internal_ref_cycle_terminates`
  • pure A→B→A schema cycle: `pure_ref_cycle_does_not_loop`
  • external `$ref`: `external_ref_is_skipped`
  • `additionalProperties`: `additional_properties_integer_is_coerced` + `additional_properties_does_not_overwrite_known_properties`
  • conservative: `non_whole_float_is_not_truncated`, `out_of_i64_range_is_not_coerced`, `large_timestamp_is_coerced`
  • end-to-end PANW repro: `panw_audit_management_repro_coerces_all_integers`

🤖 Generated with Claude Code

@cla-bot cla-bot Bot added the cla-signed label May 20, 2026
@github-actions github-actions Bot added the external-contributor Indicates that a PR has been opened by someone outside the Warp team. label May 20, 2026
@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 20, 2026

@david-engelmann

I'm starting a first review of this pull request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

oz-for-oss[bot]
oz-for-oss Bot previously requested changes May 20, 2026
Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

This PR replaces top-level MCP integer argument coercion with a recursive schema/value walker and adds tests for nested objects, arrays, composition keywords, refs, nullable type arrays, and conservative numeric cases.

Concerns

  • additionalProperties currently treats keys covered by unsupported patternProperties as extras, which can coerce values that should be governed by a pattern schema instead.
  • This changes user-facing MCP behavior, and the shared helper can also affect displayed MCP call details, but the PR does not include screenshots or a screen recording demonstrating the repro working end to end. Please attach visual evidence per repo guidance.

Verdict

Found: 0 critical, 2 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

let Some(f) = n.as_f64() else { continue };
if f.fract() != 0.0 {
continue;
if let Some(additional) = schema.get("additionalProperties") {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Keys matched by patternProperties are still treated as extras here, so a schema with patternProperties for numeric fields plus additionalProperties: {"type": "integer"} will coerce those pattern-matched values even though patternProperties is intentionally unsupported. Skip additionalProperties when patternProperties is present, or account for pattern matches before applying the additional schema.

david-engelmann added a commit to david-engelmann/warp that referenced this pull request May 20, 2026
…ercion (warpdotdev#10596 review)

Per JSON Schema, a key matching any `patternProperties` regex is governed by
that pattern's schema, not by `additionalProperties`. The recursive walker
didn't (yet) coerce through `patternProperties`, but it was still iterating
those keys when applying `additionalProperties`, which could coerce a value
that should have been governed by the pattern schema instead. Oz flagged
this on PR warpdotdev#11407:

> additionalProperties currently treats keys covered by unsupported
> patternProperties as extras, which can coerce values that should be
> governed by a pattern schema instead.

Compile each `patternProperties` key as a regex and skip matching args
before falling back to `additionalProperties`. Patterns that fail to
compile are ignored, matching the "unsupported shapes are skipped" policy
elsewhere in this walker.

Adds 2 regression tests:
- `pattern_properties_keys_are_excluded_from_additional_properties` — a
  key matching `^_` is governed by its pattern (no coercion), while an
  unrelated key still gets coerced via `additionalProperties`.
- `invalid_pattern_property_regex_is_ignored` — an uncompilable regex
  doesn't panic and doesn't block sibling coercion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@david-engelmann
Copy link
Copy Markdown
Contributor Author

Thanks for the catch — you're right, a key matching a patternProperties regex is governed by its pattern's schema, not additionalProperties, and my walker was leaking through.

Addressed in f46924c0:

  • When iterating additionalProperties, compile each patternProperties key as a regex and skip args matching any of them. The pattern's own schema still isn't followed (we keep treating patternProperties as out-of-scope), but those keys no longer fall back to additionalProperties incorrectly.
  • Patterns that fail to compile are ignored — matching the "unsupported shapes are skipped" policy used everywhere else in the walker.

Added 2 regression tests in call_mcp_tool_tests.rs:

  • pattern_properties_keys_are_excluded_from_additional_properties — pattern-matched key keeps its float (governed by patternProperties: { type: number }); unrelated key is still coerced via additionalProperties: { type: integer }.
  • invalid_pattern_property_regex_is_ignored — an uncompilable regex ("(") doesn't panic and doesn't block sibling coercion.

23/23 in call_mcp_tool pass; clippy + fmt clean.

Re: visual evidence — this PR is a behind-the-scenes serialization fix (whole-number f64 → i64 on the wire), so there's no UI surface to capture. The PANW-style end-to-end repro lives in panw_audit_management_repro_coerces_all_integers — it pins the exact schema + payload shape from the issue body.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 20, 2026

@david-engelmann

I'm re-reviewing this pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

oz-for-oss[bot]
oz-for-oss Bot previously requested changes May 20, 2026
Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

This PR expands MCP tool argument integer coercion from top-level properties to nested objects, arrays, composition keywords, internal refs, and additionalProperties, with broad unit coverage for the new traversal behavior.

Concerns

  • patternProperties matching is not conservative for valid JSON Schema regexes that Rust's regex engine cannot compile; those keys can fall through to additionalProperties and be coerced with the wrong schema.

Verdict

Found: 0 critical, 1 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

.and_then(|v| v.as_object())
.map(|p| {
p.keys()
.filter_map(|pat| regex::Regex::new(pat).ok())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] patternProperties uses JSON Schema/ECMA regex semantics, but Rust regex rejects valid patterns such as look-around. Silently dropping those patterns lets matching keys fall through to additionalProperties and be coerced with the wrong schema; skip additionalProperties when pattern ownership cannot be evaluated with compatible semantics.

david-engelmann added a commit to david-engelmann/warp that referenced this pull request May 20, 2026
…ile (warpdotdev#10596 review)

JSON Schema's pattern grammar is ECMA-262; Rust's `regex` crate is a strict
subset and rejects valid patterns such as those with lookaheads or certain
Unicode constructs. The previous carve-out filtered out uncompilable
patterns with `.ok()`, which meant a valid JSON Schema regex that Rust
couldn't compile silently failed to exclude its keys — those keys then
fell through to `additionalProperties` and got coerced with the wrong
schema. Oz flagged this on PR warpdotdev#11407:

> patternProperties matching is not conservative for valid JSON Schema
> regexes that Rust's regex engine cannot compile; those keys can fall
> through to additionalProperties and be coerced with the wrong schema.

If any `patternProperties` entry fails to compile, break out of the
`additionalProperties` branch entirely (labeled break, scoped to that
block — `properties` recursion at the same level is unaffected). A key
that should have been governed by the pattern won't be coerced with the
wrong schema.

Replaces the old `invalid_pattern_property_regex_is_ignored` test with
two new ones that pin the conservative behavior:
- `uncompilable_pattern_skips_additional_properties_conservatively` —
  the would-be-coerced key keeps its float when the pattern is unparseable.
- `uncompilable_pattern_still_allows_properties_recursion` — `properties`
  coercion at the same level still runs, because it doesn't depend on the
  pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@david-engelmann
Copy link
Copy Markdown
Contributor Author

Good catch — Rust's regex crate is a strict ECMA-262 subset and silently rejecting unparseable patterns let a real pattern-governed key slip through to additionalProperties.

Addressed in 266845ed:

  • If any patternProperties entry fails to compile, break out of the additionalProperties branch entirely (labeled break, scoped to just that block). properties recursion at the same level is unaffected.
  • A key that should have been governed by the unparseable pattern won't be coerced with the wrong schema — it just keeps its float on the wire.

Replaced invalid_pattern_property_regex_is_ignored (which encoded the old, unsafe behavior) with two new tests:

  • uncompilable_pattern_skips_additional_properties_conservatively — would-be-coerced key keeps its float when the pattern is unparseable.
  • uncompilable_pattern_still_allows_properties_recursionproperties coercion still runs alongside the unparseable pattern.

24/24 in call_mcp_tool pass; clippy + fmt clean.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 20, 2026

@david-engelmann

I'm re-reviewing this pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

oz-for-oss[bot]
oz-for-oss Bot previously requested changes May 20, 2026
Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

This PR expands MCP tool argument integer coercion from top-level properties to a recursive JSON Schema walker covering nested objects, arrays, composition keywords, internal refs, additionalProperties, and related tests.

Concerns

  • Recursive composition traversal does not carry a cycle/depth guard across $ref + allOf / oneOf / anyOf, so a third-party MCP server can advertise a recursive composed schema that causes unbounded recursion while preparing a tool call.

Security

  • The unbounded recursion is driven by MCP-provided schemas and can hang or crash the client during argument coercion.

Verdict

Found: 0 critical, 1 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

let schema = resolve_refs(schema, root);

// `allOf` — apply every branch; can stack with sibling keywords.
if let Some(branches) = schema.get("allOf").and_then(|v| v.as_array()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] [SECURITY] This recurses through composed schemas with a fresh $ref visited set for every call, so a schema like A.allOf -> $ref B and B.allOf -> $ref A will recurse forever before consuming any data depth. MCP schemas are supplied by third-party servers; carry a visited ref/schema set, or a max schema-depth guard, across coerce_recursive when following composition branches.

david-engelmann added a commit to david-engelmann/warp that referenced this pull request May 20, 2026
…tion DoS (warpdotdev#10596 review)

`resolve_refs` already breaks pure `$ref` chains, but the walker's
recursive entries for `allOf` / `oneOf` / `anyOf` / `properties` /
`items` / `additionalProperties` had no cross-cutting guard. A
malicious or accidentally-recursive composed schema like

  $defs.Loop = { allOf: [{ "$ref": "#/$defs/Loop" }], ... }

would loop indefinitely (allOf → resolve_refs → allOf → …) while
preparing a tool call. Oz flagged this on PR warpdotdev#11407:

> Recursive composition traversal does not carry a cycle/depth guard
> across `$ref` + `allOf` / `oneOf` / `anyOf`, so a third-party MCP
> server can advertise a recursive composed schema that causes
> unbounded recursion while preparing a tool call.

Thread a `depth: usize` counter through `coerce_recursive`, bail at
`MAX_COERCE_DEPTH = 64`. Real MCP schemas top out well under this; the
limit only kicks in for cycles. Termination is the guarantee — correctness
on an intentionally-malicious schema is not a goal (the server would have
to be cooperating to send valid args anyway).

Adds 3 regression tests:
- `all_of_self_reference_terminates` — `allOf` cycle via `$ref` self-link.
- `one_of_self_reference_terminates` — same shape via `oneOf`.
- `deep_finite_nesting_under_limit_still_coerces` — a 10-level nested
  `properties` chain still coerces correctly (proves the limit doesn't
  block reasonable usage).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@david-engelmann
Copy link
Copy Markdown
Contributor Author

Right — resolve_refs only breaks pure $ref chains; the other recursive edges had no cross-cutting guard.

Addressed in 726c8535:

  • Threaded a depth: usize counter through coerce_recursive. Bails at MAX_COERCE_DEPTH = 64.
  • Real MCP schemas top out well under this; the limit only matters for cycles. Termination is the guarantee — correctness on an intentionally-recursive schema isn't a goal (the server would have to be cooperating to send valid args anyway).

Added 3 regression tests:

  • all_of_self_reference_terminates$defs.Loop = { allOf: [{ "$ref": "#/$defs/Loop" }], properties: {…} }. Without the limit this looped indefinitely through allOf → resolve_refs → allOf → ….
  • one_of_self_reference_terminates — same shape via oneOf.
  • deep_finite_nesting_under_limit_still_coerces — 10 levels of nested properties still coerces (pins that the limit doesn't block legitimate schemas).

27/27 in call_mcp_tool pass; clippy + fmt clean.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 20, 2026

@david-engelmann

I'm re-reviewing this pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

oz-for-oss[bot]
oz-for-oss Bot previously requested changes May 20, 2026
Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

This PR expands MCP tool integer argument coercion to nested objects, arrays, composition keywords, internal refs, and additional properties. No approved spec context was available, so this review focused on the annotated diff and security implications.

Concerns

  • The recursive composition handling still allows untrusted MCP schemas to create exponential traversal despite the depth cap, which can hang the client while preparing a tool call.

Security

  • The recursion fan-out issue is security-relevant resource exhaustion from a schema supplied by an MCP server.

Verdict

Found: 0 critical, 1 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

// `allOf` — apply every branch; can stack with sibling keywords.
if let Some(branches) = schema.get("allOf").and_then(|v| v.as_array()) {
for b in branches {
coerce_recursive(value, b, root, depth + 1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] [SECURITY] The depth cap still permits exponential recursion when an untrusted schema has multiple composition branches that self-reference (for example, two allOf/anyOf refs back to the same schema), so a malicious MCP server can hang the client before the cap helps; track composition refs already on the stack or otherwise short-circuit these cycles instead of relying only on depth.

david-engelmann added a commit to david-engelmann/warp that referenced this pull request May 20, 2026
…n-out (warpdotdev#10596 review)

The previous depth-cap (64) bounded the longest single chain, but didn't
bound TOTAL work. An adversarial schema like

  $defs.Loop = { allOf: [{ "$ref": "#/Loop" }, { "$ref": "#/Loop" }] }

doubles work at each recursion level — total ops grow as `B^D` where B is
the branching factor. With B=2 and D=64 that's ~1.8e19 calls before any
single chain hits MAX_COERCE_DEPTH. Oz flagged this on PR warpdotdev#11407:

> The recursive composition handling still allows untrusted MCP schemas
> to create exponential traversal despite the depth cap, which can hang
> the client while preparing a tool call.

Thread a mutable `budget: &mut usize` through `coerce_recursive`. Each
recursive call decrements the budget; the function bails when it hits 0
(in addition to the existing depth check). Budget set to `MAX_COERCE_OPS
= 10_000`, which leaves ~20× headroom for real MCP schemas (which run a
few hundred calls in practice) while bounding the worst case to a few
milliseconds of work.

Adds 1 regression test:
- `fan_out_via_all_of_self_reference_terminates` — `allOf: [Self, Self]`
  schema cycle. Without the budget this generated 2^D calls before any
  single chain reached the depth cap. With the budget it bails at 10_000.

Also reorders the const declarations so the docstring on `coerce_integer_args`
attaches to the function rather than to `MAX_COERCE_DEPTH` (post-Edit fix).

28/28 in `call_mcp_tool` pass. clippy + fmt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@david-engelmann
Copy link
Copy Markdown
Contributor Author

Right — the depth cap bounds the longest single chain, but allOf: [Self, Self] doubles work at each level, so a B=2 schema can fan out to ~2^64 calls before any single chain hits the depth cap.

Addressed in `2f784f01`:

  • Added `MAX_COERCE_OPS = 10_000` and threaded a `budget: &mut usize` through `coerce_recursive`. Each recursive call decrements the budget; the function bails on either depth ≥ 64 OR budget == 0, whichever fires first.
  • 10_000 ops bounds the worst case to a few milliseconds while leaving ~20× headroom for real MCP schemas (which use a few hundred calls in practice).

Added 1 regression test:

  • `fan_out_via_all_of_self_reference_terminates` — `allOf: [{ $ref: "#/Self" }, { $ref: "#/Self" }]` doubling schema. Without the op budget this generated 2^D calls; with the budget it bails at 10_000.

(Also reordered the const declarations so the `coerce_integer_args` docstring attaches to the function rather than to `MAX_COERCE_DEPTH`.)

28/28 in `call_mcp_tool` pass. clippy + fmt clean.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 20, 2026

This is your last /oz-review for the current 24-hour window. Your next slot opens in ~14h 22m.

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 20, 2026

@david-engelmann

I'm re-reviewing this pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

oz-for-oss[bot]
oz-for-oss Bot previously requested changes May 20, 2026
Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

This PR expands MCP tool argument integer coercion from top-level schema properties to recursive JSON Schema traversal, covering nested objects, arrays, composition keywords, additionalProperties, internal $refs, and regression tests for the reported nested PANW shape.

Concerns

  • The PR changes user-facing MCP tool execution behavior, but the description does not include screenshots or a screen recording showing the nested integer MCP tool call succeeding end to end. For this user-facing change, please include screenshots or a screen recording demonstrating it working end to end.

Verdict

Found: 0 critical, 1 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

david-engelmann added a commit to david-engelmann/warp-taper that referenced this pull request May 21, 2026
Code-level visual evidence for warpdotdev/warp#11407. The fix lives in
`coerce_integer_args` (a private helper that runs before MCP tool calls
hit the wire), so a UI demonstration is indirect: the scenario drives a
terminal inside warp-oss to:

  1. `cat` the PANW-style test schema (object.object.array.oneOf.integer)
     so the recording shows the exact JSON Schema shape the walker has to
     handle.
  2. Run `cargo test panw_audit_management_repro_coerces_all_integers --
     --nocapture` so the recording shows the assertions pass — the
     wire-format guarantee that `0.0 / 100.0 / 1730419200000.0` →
     `0 / 100 / 1730419200000` at three nested positions.

Uses the new `Scenario::caption()` API to overlay five narration cards
explaining the schema, the would-be-wire payload, and the walker's
recursion path. The captioned mp4 + gif drop out of the pipeline's
03b-captions stage automatically.

Run with:
  RECIPE=11407-mcp-integer-coercion ./scripts/record-agent-evidence.sh
or:
  warp-taper run-builtin 11407-mcp-integer-coercion \
      --warp-source /tmp/warp-10596 --duration-ms 32000
@david-engelmann
Copy link
Copy Markdown
Contributor Author

david-engelmann commented May 21, 2026

Visual evidence for the nested integer coercion, recorded end-to-end against a warp-oss binary built from this PR's branch (/tmp/warp-10596 @ 2f784f01).

#11407 nested integer coercion — end-to-end recording

(MP4: https://github.com/david-engelmann/warp-taper/raw/01d9cab83251127f83082b9596cf6aa144e83177/docs/evidence/11407-mcp-integer-coercion/master-captioned.mp4)

What the recording shows

Time Caption On-screen content
0-4s "warpdotdev/warp PR #11407 - nested integer coercion" warp-oss terminal at /tmp/warp-10596 on branch david/10596-mcp-integer-coercion
4-10s "Schema declares search_from / search_to / filters[].value as integer" cat output of the PANW test schema — object → object → array → oneOf → integer (the exact shape from the issue body)
10-16s "Without the fix the agent would send 0.0 / 100.0 / 1730419200000.0" schema continues
16-22s "Walker recurses through object - array - oneOf - integer leaves" cargo test ... -- --nocapture issued
22-32s "Test passes - all three nested integers reach the wire as integers" test result: ok. 1 passed; 0 failed — the three nested integer fields all coerced from f64 → i64

The test the recording exercises

From call_mcp_tool_tests.rs:591:

fn panw_audit_management_repro_coerces_all_integers() {
    let mut args = obj(json!({
        "request_data": {
            "search_from": 0.0,
            "search_to": 100.0,
            "filters": [
                { "field": "timestamp", "operator": "gte", "value": 1730419200000.0 }
            ]
        }
    }));
    let schema = obj(json!({
        "type": "object",
        "properties": {
            "request_data": {
                "type": "object",
                "properties": {
                    "search_from": { "type": "integer" },
                    "search_to":   { "type": "integer" },
                    "filters": {
                        "type": "array",
                        "items": {
                            "oneOf": [
                                { "properties": { "value": { "type": "array", "items": { "type": "string" } } } },
                                { "properties": { "value": { "type": "integer" } } }
                            ]
                        }
                    }
                }
            }
        }
    }));

    coerce_integer_args(&mut args, &schema);

    assert_serialized_as(&args, "request_data.search_from", "0");
    assert_serialized_as(&args, "request_data.search_to", "100");
    assert_serialized_as(&args, "request_data.filters.0.value", "1730419200000");
}

The three assertions pin the wire format: each integer field that the agent supplies as 5.0 / 100.0 / 1730419200000.0 reaches git ls-files / the MCP server as the string "5" / "100" / "1730419200000" after coerce_integer_args runs — i.e. the receiving MCP server sees JSON integers, not whole-number floats.

Reproducing

The scenario + recipe + the new warp-taper caption CLI subcommand all live in warp-taper @ 01d9cab:

  • Scenario (5 timed captions, metadata)
  • Recipe (warp-driver steps)
  • Run with RECIPE=11407-mcp-integer-coercion DURATION_S=35 EMIT_GIF=1 ./scripts/record-agent-evidence.sh against a warp-oss built from this PR's branch.

Full call-mcp-tool suite (all 28 tests covering the walker's recursion shapes — composition, $ref, additionalProperties, depth guard, op budget):

test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 3613 filtered out; finished in 0.02s

@david-engelmann
Copy link
Copy Markdown
Contributor Author

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 21, 2026

You've used all 5 /oz-review slots for the current 24-hour window. Your next slot opens in ~11h 4m.

@david-engelmann david-engelmann force-pushed the david/10596-mcp-integer-coercion branch from 2f784f0 to c876721 Compare May 22, 2026 17:19
david-engelmann added a commit to david-engelmann/warp that referenced this pull request May 22, 2026
…ercion (warpdotdev#10596 review)

Per JSON Schema, a key matching any `patternProperties` regex is governed by
that pattern's schema, not by `additionalProperties`. The recursive walker
didn't (yet) coerce through `patternProperties`, but it was still iterating
those keys when applying `additionalProperties`, which could coerce a value
that should have been governed by the pattern schema instead. Oz flagged
this on PR warpdotdev#11407:

> additionalProperties currently treats keys covered by unsupported
> patternProperties as extras, which can coerce values that should be
> governed by a pattern schema instead.

Compile each `patternProperties` key as a regex and skip matching args
before falling back to `additionalProperties`. Patterns that fail to
compile are ignored, matching the "unsupported shapes are skipped" policy
elsewhere in this walker.

Adds 2 regression tests:
- `pattern_properties_keys_are_excluded_from_additional_properties` — a
  key matching `^_` is governed by its pattern (no coercion), while an
  unrelated key still gets coerced via `additionalProperties`.
- `invalid_pattern_property_regex_is_ignored` — an uncompilable regex
  doesn't panic and doesn't block sibling coercion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
david-engelmann added a commit to david-engelmann/warp that referenced this pull request May 22, 2026
…ile (warpdotdev#10596 review)

JSON Schema's pattern grammar is ECMA-262; Rust's `regex` crate is a strict
subset and rejects valid patterns such as those with lookaheads or certain
Unicode constructs. The previous carve-out filtered out uncompilable
patterns with `.ok()`, which meant a valid JSON Schema regex that Rust
couldn't compile silently failed to exclude its keys — those keys then
fell through to `additionalProperties` and got coerced with the wrong
schema. Oz flagged this on PR warpdotdev#11407:

> patternProperties matching is not conservative for valid JSON Schema
> regexes that Rust's regex engine cannot compile; those keys can fall
> through to additionalProperties and be coerced with the wrong schema.

If any `patternProperties` entry fails to compile, break out of the
`additionalProperties` branch entirely (labeled break, scoped to that
block — `properties` recursion at the same level is unaffected). A key
that should have been governed by the pattern won't be coerced with the
wrong schema.

Replaces the old `invalid_pattern_property_regex_is_ignored` test with
two new ones that pin the conservative behavior:
- `uncompilable_pattern_skips_additional_properties_conservatively` —
  the would-be-coerced key keeps its float when the pattern is unparseable.
- `uncompilable_pattern_still_allows_properties_recursion` — `properties`
  coercion at the same level still runs, because it doesn't depend on the
  pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
david-engelmann added a commit to david-engelmann/warp that referenced this pull request May 22, 2026
…tion DoS (warpdotdev#10596 review)

`resolve_refs` already breaks pure `$ref` chains, but the walker's
recursive entries for `allOf` / `oneOf` / `anyOf` / `properties` /
`items` / `additionalProperties` had no cross-cutting guard. A
malicious or accidentally-recursive composed schema like

  $defs.Loop = { allOf: [{ "$ref": "#/$defs/Loop" }], ... }

would loop indefinitely (allOf → resolve_refs → allOf → …) while
preparing a tool call. Oz flagged this on PR warpdotdev#11407:

> Recursive composition traversal does not carry a cycle/depth guard
> across `$ref` + `allOf` / `oneOf` / `anyOf`, so a third-party MCP
> server can advertise a recursive composed schema that causes
> unbounded recursion while preparing a tool call.

Thread a `depth: usize` counter through `coerce_recursive`, bail at
`MAX_COERCE_DEPTH = 64`. Real MCP schemas top out well under this; the
limit only kicks in for cycles. Termination is the guarantee — correctness
on an intentionally-malicious schema is not a goal (the server would have
to be cooperating to send valid args anyway).

Adds 3 regression tests:
- `all_of_self_reference_terminates` — `allOf` cycle via `$ref` self-link.
- `one_of_self_reference_terminates` — same shape via `oneOf`.
- `deep_finite_nesting_under_limit_still_coerces` — a 10-level nested
  `properties` chain still coerces correctly (proves the limit doesn't
  block reasonable usage).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
david-engelmann added a commit to david-engelmann/warp that referenced this pull request May 22, 2026
…n-out (warpdotdev#10596 review)

The previous depth-cap (64) bounded the longest single chain, but didn't
bound TOTAL work. An adversarial schema like

  $defs.Loop = { allOf: [{ "$ref": "#/Loop" }, { "$ref": "#/Loop" }] }

doubles work at each recursion level — total ops grow as `B^D` where B is
the branching factor. With B=2 and D=64 that's ~1.8e19 calls before any
single chain hits MAX_COERCE_DEPTH. Oz flagged this on PR warpdotdev#11407:

> The recursive composition handling still allows untrusted MCP schemas
> to create exponential traversal despite the depth cap, which can hang
> the client while preparing a tool call.

Thread a mutable `budget: &mut usize` through `coerce_recursive`. Each
recursive call decrements the budget; the function bails when it hits 0
(in addition to the existing depth check). Budget set to `MAX_COERCE_OPS
= 10_000`, which leaves ~20× headroom for real MCP schemas (which run a
few hundred calls in practice) while bounding the worst case to a few
milliseconds of work.

Adds 1 regression test:
- `fan_out_via_all_of_self_reference_terminates` — `allOf: [Self, Self]`
  schema cycle. Without the budget this generated 2^D calls before any
  single chain reached the depth cap. With the budget it bails at 10_000.

Also reorders the const declarations so the docstring on `coerce_integer_args`
attaches to the function rather than to `MAX_COERCE_DEPTH` (post-Edit fix).

28/28 in `call_mcp_tool` pass. clippy + fmt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@david-engelmann
Copy link
Copy Markdown
Contributor Author

Rebased onto upstream/master (885c5406) to clear the merge conflict in call_mcp_tool.rs (import collision from #11426's wasm gate). All 28 integer-coercion tests still pass post-rebase. Branch tip: c876721d.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 22, 2026

@david-engelmann

I'm re-reviewing this pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

oz-for-oss[bot]
oz-for-oss Bot previously requested changes May 22, 2026
Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

This PR expands MCP integer-argument coercion to recurse through nested objects, arrays, composition keywords, internal refs, and additional properties, with tests covering the issue repro and recursion guards. No approved spec context was available to validate against.

Concerns

  • Applying every oneOf / anyOf branch can mutate values according to a branch that the instance does not actually satisfy, because branch-level constraints such as const, enum, and required are ignored by the coercion walker.
  • Untrusted MCP schemas can now trigger regex compilation from patternProperties while preparing tool calls, which leaves a resource-exhaustion path outside the new recursion and operation budgets.

Security

  • Untrusted patternProperties patterns are compiled without bounds; avoid compiling server-provided regexes on this path, or bound pattern count/size and use explicit regex builder limits.

Verdict

Found: 0 critical, 2 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

// match the value is a no-op, so trying all branches sidesteps the
// ambiguity of picking the "right" branch when multiple top-level types
// would match.
for key in ["oneOf", "anyOf"] {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Applying every oneOf/anyOf branch ignores branch-level constraints like const, enum, and required, so an integer schema in a non-selected branch can coerce a field whose selected branch declares number; validate/pick matching branches before mutating, or limit this path to unambiguous schemas.

.and_then(|v| v.as_object())
.map(|p| {
p.keys()
.map(|pat| regex::Regex::new(pat))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] [SECURITY] MCP schemas are server-controlled, and this compiles arbitrary patternProperties regexes while preparing a tool call; a malicious server can send many or very large patterns to burn CPU/memory outside the recursion budget, so skip additionalProperties when patternProperties is present or bound pattern count/size and compile with explicit limits.

david-engelmann and others added 6 commits May 26, 2026 22:13
warpdotdev#10596)

MCP tool args round-trip through `google.protobuf.Struct` on the wire, whose
`NumberValue` stores everything as `f64`, erasing the integer/float distinction.
The existing `coerce_integer_args` mitigation only walked depth-1 `properties`
and matched the literal string `"integer"`, so nested integer fields (inside
objects, array items, `oneOf`/`anyOf`/`allOf` branches, behind `$ref`, or
declared as `["integer", "null"]`) were still serialized as `"5.0"` and rejected
by strict MCP servers.

Walks schema and args in parallel covering: nested objects, array `items`
(single-schema and tuple), `additionalProperties`, `allOf` / `oneOf` / `anyOf`,
internal `$ref` (with cycle guard), and nullable type-arrays. External `$ref`
and uncommon keywords (`not`, `if`/`then`/`else`, `patternProperties`) are
intentionally skipped — a skip preserves the existing wire behavior.

Adds 19 new unit tests covering each shape plus the exact PANW-style repro
from the issue.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ercion (warpdotdev#10596 review)

Per JSON Schema, a key matching any `patternProperties` regex is governed by
that pattern's schema, not by `additionalProperties`. The recursive walker
didn't (yet) coerce through `patternProperties`, but it was still iterating
those keys when applying `additionalProperties`, which could coerce a value
that should have been governed by the pattern schema instead. Oz flagged
this on PR warpdotdev#11407:

> additionalProperties currently treats keys covered by unsupported
> patternProperties as extras, which can coerce values that should be
> governed by a pattern schema instead.

Compile each `patternProperties` key as a regex and skip matching args
before falling back to `additionalProperties`. Patterns that fail to
compile are ignored, matching the "unsupported shapes are skipped" policy
elsewhere in this walker.

Adds 2 regression tests:
- `pattern_properties_keys_are_excluded_from_additional_properties` — a
  key matching `^_` is governed by its pattern (no coercion), while an
  unrelated key still gets coerced via `additionalProperties`.
- `invalid_pattern_property_regex_is_ignored` — an uncompilable regex
  doesn't panic and doesn't block sibling coercion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ile (warpdotdev#10596 review)

JSON Schema's pattern grammar is ECMA-262; Rust's `regex` crate is a strict
subset and rejects valid patterns such as those with lookaheads or certain
Unicode constructs. The previous carve-out filtered out uncompilable
patterns with `.ok()`, which meant a valid JSON Schema regex that Rust
couldn't compile silently failed to exclude its keys — those keys then
fell through to `additionalProperties` and got coerced with the wrong
schema. Oz flagged this on PR warpdotdev#11407:

> patternProperties matching is not conservative for valid JSON Schema
> regexes that Rust's regex engine cannot compile; those keys can fall
> through to additionalProperties and be coerced with the wrong schema.

If any `patternProperties` entry fails to compile, break out of the
`additionalProperties` branch entirely (labeled break, scoped to that
block — `properties` recursion at the same level is unaffected). A key
that should have been governed by the pattern won't be coerced with the
wrong schema.

Replaces the old `invalid_pattern_property_regex_is_ignored` test with
two new ones that pin the conservative behavior:
- `uncompilable_pattern_skips_additional_properties_conservatively` —
  the would-be-coerced key keeps its float when the pattern is unparseable.
- `uncompilable_pattern_still_allows_properties_recursion` — `properties`
  coercion at the same level still runs, because it doesn't depend on the
  pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tion DoS (warpdotdev#10596 review)

`resolve_refs` already breaks pure `$ref` chains, but the walker's
recursive entries for `allOf` / `oneOf` / `anyOf` / `properties` /
`items` / `additionalProperties` had no cross-cutting guard. A
malicious or accidentally-recursive composed schema like

  $defs.Loop = { allOf: [{ "$ref": "#/$defs/Loop" }], ... }

would loop indefinitely (allOf → resolve_refs → allOf → …) while
preparing a tool call. Oz flagged this on PR warpdotdev#11407:

> Recursive composition traversal does not carry a cycle/depth guard
> across `$ref` + `allOf` / `oneOf` / `anyOf`, so a third-party MCP
> server can advertise a recursive composed schema that causes
> unbounded recursion while preparing a tool call.

Thread a `depth: usize` counter through `coerce_recursive`, bail at
`MAX_COERCE_DEPTH = 64`. Real MCP schemas top out well under this; the
limit only kicks in for cycles. Termination is the guarantee — correctness
on an intentionally-malicious schema is not a goal (the server would have
to be cooperating to send valid args anyway).

Adds 3 regression tests:
- `all_of_self_reference_terminates` — `allOf` cycle via `$ref` self-link.
- `one_of_self_reference_terminates` — same shape via `oneOf`.
- `deep_finite_nesting_under_limit_still_coerces` — a 10-level nested
  `properties` chain still coerces correctly (proves the limit doesn't
  block reasonable usage).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…n-out (warpdotdev#10596 review)

The previous depth-cap (64) bounded the longest single chain, but didn't
bound TOTAL work. An adversarial schema like

  $defs.Loop = { allOf: [{ "$ref": "#/Loop" }, { "$ref": "#/Loop" }] }

doubles work at each recursion level — total ops grow as `B^D` where B is
the branching factor. With B=2 and D=64 that's ~1.8e19 calls before any
single chain hits MAX_COERCE_DEPTH. Oz flagged this on PR warpdotdev#11407:

> The recursive composition handling still allows untrusted MCP schemas
> to create exponential traversal despite the depth cap, which can hang
> the client while preparing a tool call.

Thread a mutable `budget: &mut usize` through `coerce_recursive`. Each
recursive call decrements the budget; the function bails when it hits 0
(in addition to the existing depth check). Budget set to `MAX_COERCE_OPS
= 10_000`, which leaves ~20× headroom for real MCP schemas (which run a
few hundred calls in practice) while bounding the worst case to a few
milliseconds of work.

Adds 1 regression test:
- `fan_out_via_all_of_self_reference_terminates` — `allOf: [Self, Self]`
  schema cycle. Without the budget this generated 2^D calls before any
  single chain reached the depth cap. With the budget it bails at 10_000.

Also reorders the const declarations so the docstring on `coerce_integer_args`
attaches to the function rather than to `MAX_COERCE_DEPTH` (post-Edit fix).

28/28 in `call_mcp_tool` pass. clippy + fmt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ern regexes

Addresses Oz's two review concerns on warpdotdev#10596:

1. oneOf/anyOf over-coercion. The walker applied every composition branch,
   so a value could be coerced per a branch it doesn't satisfy — e.g. a
   tagged union where one arm types a field `integer` and another `number`
   would coerce a legitimate float to an int. Added `branch_excluded`, which
   skips a branch when the value provably violates its `const`, `enum`,
   `type`, `required`, or a `const`/`enum` discriminator on a present
   property. It's a partial check that only ever *excludes* provable
   non-matches, so untagged unions behave exactly as before, and a
   whole-number float still counts as a valid `integer` candidate.

2. patternProperties ReDoS surface. Server-advertised patterns were compiled
   without bounds. Rust's regex is linear-time at match, so the risk is
   pathological compile cost; `compile_pattern_properties` now caps pattern
   count (64), per-pattern length (1024), and compiled program size (64 KiB
   via RegexBuilder size_limit/dfa_size_limit). Any rejection falls back to
   the existing conservative skip of additionalProperties coercion.

8 new tests: tagged-union (const/enum/required/type) branch selection both
ways, plus pattern-count / pattern-length / well-formed-pattern bounds. All
36 coercion tests pass; clippy + fmt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@david-engelmann david-engelmann force-pushed the david/10596-mcp-integer-coercion branch from c876721 to f08cec7 Compare May 27, 2026 02:20
@david-engelmann
Copy link
Copy Markdown
Contributor Author

Addressed both concerns in f08cec79 (rebased onto latest master).

1. oneOf/anyOf coercing through non-matching branches. Added branch_excluded, which skips a composition branch when the value provably violates its const, enum, type, required, or a const/enum discriminator on a property present in the value. So a tagged union like

{ "oneOf": [
  { "properties": { "kind": {"const":"a"}, "val": {"type":"integer"} } },
  { "properties": { "kind": {"const":"b"}, "val": {"type":"number"} } }
]}

with { "kind": "b", "val": 5.0 } now leaves val as 5.0 — branch "a" is excluded by the kind discriminator. It's a deliberately partial check: it only ever excludes provable non-matches, so untagged unions behave exactly as before, and a whole-number float still counts as a valid integer candidate (the case this walker exists for).

2. patternProperties regex compilation on untrusted input. Rust's regex is linear-time at match (finite automaton, no catastrophic backtracking), so the real surface is pathological compile cost. compile_pattern_properties now bounds pattern count (64), per-pattern length (1024), and compiled program size (64 KiB via RegexBuilder::size_limit + dfa_size_limit). Any rejection falls back to the existing conservative skip of additionalProperties coercion.

8 new tests covering tagged-union branch selection (const / enum / required / type, both matching and non-matching) and the three pattern-compile bounds. All 36 coercion tests pass; clippy + fmt clean.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 27, 2026

@david-engelmann

I'm re-reviewing this pull request in response to a review request.

You can view the conversation on Warp.

I reviewed this pull request and requested human review from: @vkodithala.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

@oz-for-oss oz-for-oss Bot dismissed stale reviews from themself May 27, 2026 02:25

Oz no longer requests changes for this pull request after the latest automated review.

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

This PR expands MCP tool argument integer coercion from shallow top-level properties to recursive JSON Schema traversal, covering nested objects, arrays, composition keywords, internal refs, and additionalProperties with bounded recursion/resource guards and focused unit coverage.

Concerns

  • No blocking correctness, spec-alignment, or security concerns were found in the annotated diff.
  • No approved spec context was provided for this PR, so there are no spec commitments to compare beyond the issue-focused behavior described in the PR.
  • No screenshot or screen recording is requested because this is non-visual MCP payload serialization behavior; the before/after wire payload and unit coverage are the meaningful evidence for this change.

Verdict

Found: 0 critical, 0 important, 0 suggestions

Approve

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

@oz-for-oss oz-for-oss Bot requested a review from vkodithala May 27, 2026 02:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed external-contributor Indicates that a PR has been opened by someone outside the Warp team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Warp sends whole-number floats (5.0) to MCP tools instead of integers (5), violating tool input schemas

1 participant