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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions