Skip to content

Value: add numberValue (Double?) that coerces .int and .double #225

@gsdali

Description

@gsdali

Context

Value exposes intValue: Int? and doubleValue: Double? accessors that each only match their exact case:

public var doubleValue: Double? {
    guard case let .double(value) = self else { return nil }
    return value
}

Round-tripping a Double through JSON loses the type tag whenever the value is a whole number. Value.double(0) encodes as JSON 0; the decoder tries Int first and produces Value.int(0). So a server tool that reads a coordinate array via:

guard let arr = arguments["normal"]?.arrayValue, arr.count == 3,
      let x = arr[0].doubleValue, let y = arr[1].doubleValue, let z = arr[2].doubleValue else {
    return error("normal requires [x, y, z]")
}

will silently fail when an LLM sends [0, 0, 1][1, 0.5, 0] would parse, [1, 0, 0] wouldn't. Confused-deputy bugs that work in tests with [0.5, 0.5, 1.5] fixtures and break in production.

This is general — happens for every numeric scalar in the schema (radius: 5, angleDeg: 90) the moment an LLM picks an integer literal.

What this issue asks for

A coercing accessor that accepts either case. The typical consumer wants Double for coordinates / measurements and Int for counts, with the codec handling the decoder's promotion choice transparently.

Proposed

extension Value {
    /// Coercing numeric accessor. Returns `Double` for `.int(_)` and
    /// `.double(_)`; nil for everything else. Matches the typical
    /// consumer expectation that a JSON number is a number, regardless
    /// of whether the Codable decoder picked the int or double branch.
    public var numberValue: Double? {
        switch self {
        case .int(let i):    return Double(i)
        case .double(let d): return d
        default:             return nil
        }
    }
}

Optionally a sibling for the reverse direction (integerValue: Int? that accepts .double only when the value is exactly representable as Int), though that's lower-stakes — count: 4.0 is unusual.

Naming alternatives

  • numberValue — matches the JSON spec name for the type. Distinct from intValue / doubleValue so it's clear this one accepts either.
  • asDouble — terser, but loses the "this is the JSON-spec wide type" framing.
  • coercedDouble — explicit but verbose.

I'd pick numberValue for parallelism with arrayValue / objectValue / stringValue (also wide-types).

Why on the SDK side

Every Swift MCP server today is going to hit this the moment a numeric tool argument meets an LLM. We hit it in OCCTMCP on a coordinate array; the workaround is a one-line extension that every server is going to copy:

extension Value {
    var asDouble: Double? {
        if let d = doubleValue { return d }
        if let i = intValue { return Double(i) }
        return nil
    }
}

Better to ship it once on the SDK side. The cost is two short methods + tests; no API change, no breaking change.

Acceptance

  • Value.numberValue returns the underlying scalar promoted to Double for both .int and .double; nil otherwise.
  • Tests: round-trip Value.double(0) through JSONEncoder()JSONDecoder()numberValue returns 0.0. Same for typical LLM shapes like [1, 0, 0] / [0.5, 0.5, 0.5].
  • Documentation comment notes the JSON whole-number-decodes-as-int gotcha so consumers learn why this exists.
  • intValue / doubleValue unchanged — additive only.

Out of scope

  • A Bool-coercing accessor (no precedent for 0/1 ↔ true/false in MCP schemas).
  • Treating String numerics like "42" as numbers (LLMs that send strings should be told off via schema).
  • A throwing variant — nil-returning matches the existing accessors' style.

Volunteering

Happy to ship a PR with the implementation + tests if there's interest in this. Don't want to bikeshed naming in a PR that doesn't have a yes — flagging as an issue first.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions