Skip to content

feat(server): lenient integer coercion for tool input parameters #292

@randomparity

Description

@randomparity

Problem

When an LLM client (currently observed with Claude Code) calls an MCP tool with an integer parameter, the value sometimes arrives as a JSON string instead of a JSON number. The host's JSON Schema validator rejects the call before it reaches our server with errors like:

params/limit must be integer,null

Observed in the wild on search calls:

{"folder":"DPDK Mailing Lists.Dev","limit":"100","to":"drc@linux.ibm.com"}

The model attempted the call twice in the same stringified shape and gave up. The agent reports this is non-deterministic — sometimes correct, sometimes stringified — so retries don't reliably self-correct.

Root cause

This is not a server bug. Option<usize> in SearchInput makes schemars emit:

{ "type": ["integer", "null"], "format": "uint", "minimum": 0 }

The schema is correct per JSON Schema / MCP spec. The error message format (params/limit must be integer,null) is AJV's signature, meaning a JS-based validator in the MCP host (Claude Code) rejects the call before it reaches our server.

This is a known, widespread ecosystem issue caused by Claude Code's tool-argument serialization:

How other MCP servers handle it

The ecosystem has converged on lenient server-side coercion:

Server Mechanism Reference
FastMCP (Python) Pydantic flexible validation; coerces by default. strict_input_validation=False is the default. gofastmcp.com docs
github/github-mcp-server (Go, official) Switched to mapstructure.WeakDecode; parses numeric strings in RequiredInt/OptionalIntParam. PR #2130
modelcontextprotocol/servers/sequential-thinking (TS) z.coerce.number() for ints; z.preprocess for booleans (avoiding the Boolean(\"false\") === true footgun). PR #3533

Decision

Adopt Option E from the design discussion: introduce a single helper (newtype or deserialize_with + schema_with pair) that:

  1. Declares the JSON Schema as type: [\"integer\", \"string\", \"null\"] with a pattern: \"^[0-9]+$\" constraint on the string form, so the host's pre-flight AJV/Zod validator accepts both shapes.
  2. Implements Deserialize to parse string-encoded integers into the underlying numeric type.
  3. Applies to every integer input field across the tool surface.

Initial scope (integer inputs in crates/rimap-server/src/tools/):

  • retrieval/search.rs: limit, offset
  • retrieval/fetch_message.rs: max_body_bytes
  • Any other Option<u32> / Option<usize> / Option<u64> input field on a tool's *Input struct (audit the full set during implementation).

Out of scope:

  • Booleans. The z.coerce.boolean(\"false\") === true footgun (fix(sequential-thinking): use z.coerce for number and boolean params modelcontextprotocol/servers#3533 review thread) makes the cost/benefit worse, and we have not seen booleans rejected in the wild yet. Defer.
  • String fields. FastMCP issue #1873 documented data loss when string-typed inputs were aggressively coerced (UUIDs starting with digits got truncated). Strings stay strict.
  • Output schemas. Server controls its own output types; coercion only applies to input validation.

Acceptance criteria

  • search tool accepts \"limit\": 100, \"limit\": \"100\", and \"limit\": null.
  • search tool rejects \"limit\": \"abc\" and \"limit\": -1 with a clear error.
  • fetch_message.max_body_bytes behaves identically.
  • All other integer input fields on tool input structs use the same helper.
  • dump-tool-catalog --features test-support confirms each affected field has schema type: [\"integer\", \"string\", \"null\"] with a digits-only pattern on the string form.
  • Boolean and string input fields are unchanged.
  • MCP wire conformance harness still passes (every tool's inputSchema.type remains \"object\").
  • Unit tests cover: int-form, string-form, null, invalid string, overflow.

Footguns to avoid

  1. Don't coerce booleans with naive truthy semantics. Boolean(\"false\") === true in JS; if we ever do booleans, use the preprocess-style mapping that handles \"true\"/\"false\" explicitly.
  2. Don't coerce string-typed fields. Only fields whose canonical type is integer.
  3. Don't infer types from content. FastMCP-style "if it looks like a number, parse it" corrupts UUID/phone-number strings. The schema annotation is authoritative.
  4. Pattern-constrain the string form. \"^[0-9]+$\" (or \"^-?[0-9]+$\" for signed types) so AJV continues to reject non-numeric strings client-side.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestrustPull requests that update rust code

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions