Skip to content

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

Open
gsdali wants to merge 1 commit intomodelcontextprotocol:mainfrom
gsdali:add-value-numbervalue
Open

Value: add numberValue (Double?) that coerces .int and .double (closes #225)#226
gsdali wants to merge 1 commit intomodelcontextprotocol:mainfrom
gsdali:add-value-numbervalue

Conversation

@gsdali
Copy link
Copy Markdown

@gsdali gsdali commented May 9, 2026

Closes #225.

Summary

Adds Value.numberValue: Double? that coerces both .int and .double to Double. Motivation in #225 — short version:

JSON has a single number type with no integer/floating-point distinction. Swift's JSONEncoder writes whole-number Doubles without a decimal point (Value.double(0) → JSON 0) and Value's init(from:) tries Int first (JSON 0Value.int(0)). So any consumer reading numeric tool arguments via arr[N].doubleValue silently fails for [0, 0, 1] (the natural unit-vector shape an LLM would emit) — works fine for [0.5, 0.5, 0.5] test fixtures, breaks in production.

numberValue returns the underlying scalar promoted to Double for .int(_) and .double(_); nil otherwise. Additive — no breaking changes.

Why on the SDK side

I hit this in OCCTMCP on a coordinate-array tool argument; the workaround is a one-line extension that every Swift MCP 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 so consumers don't reinvent it (or, more likely, hit the silent-fail path before realising they need to).

Implementation

public var numberValue: Double? {
    switch self {
    case .int(let value):    return Double(value)
    case .double(let value): return value
    default:                 return nil
    }
}

Doc comment explicitly calls out the JSON whole-number-decodes-as-int gotcha so future readers learn why this exists alongside intValue / doubleValue.

Test plan

  • swift build clean
  • swift test — 557/557 tests pass (was 551; +6 in ValueTests)
  • New ValueTests cover: int → Double, double → Double pass-through, nil for non-numeric cases, and the round-trip case that motivated this — encode Value.double(0) and assert decoded.doubleValue == nil (the gotcha), decoded.numberValue == 0.0 (the fix), plus a [0, 0, 1]-shaped coordinate-array round-trip end-to-end.

Out of scope

  • A Bool-coercing accessor (no 0/1 ↔ true/false precedent in MCP schemas).
  • Treating String numerics like "42" as numbers — schemas should reject those.
  • A throwing variant — nil-returning matches the existing accessor style.

🤖 Generated with Claude Code

JSON has a single `number` type, but Swift's JSONEncoder writes
whole-number Doubles without a decimal point (`Value.double(0)` →
JSON `0`) and Value's decoder tries Int first
(JSON `0` → `Value.int(0)`). Result: `Value.double(0).doubleValue`
returns the value, but the same value after a JSON round-trip returns
nil — a footgun for any tool dispatch reading numeric tool arguments
from LLMs (`[0, 0, 1]` is the natural unit-vector shape).

`numberValue: Double?` returns the underlying scalar promoted to
Double for both .int and .double, nil for everything else. Additive
to existing `intValue` / `doubleValue` accessors; no API change, no
breaking change.

Closes modelcontextprotocol#225.
gsdali added a commit to gsdali/OCCTMCP that referenced this pull request May 10, 2026
Eats our own dog food on modelcontextprotocol/swift-sdk#226 (closes
upstream issue #225). The fork branch
(gsdali/swift-sdk#add-value-numbervalue) ships Value.numberValue —
exactly the helper our Server.swift dispatch wanted.

Package.swift: dep flips from
  modelcontextprotocol/swift-sdk from: "0.11.0"
to
  gsdali/swift-sdk branch: "add-value-numbervalue"
with a comment noting the pin is temporary and reverts to the
upstream tag floor once swift-sdk#226 merges.

Server.swift: drops the local `extension Value { var asDouble }`
back-port (was added in OCCTMCP v1.3) and switches all 19 dispatch
sites — translate / rotate / mirror / pattern / circular /
select_topology / find_correspondences scalar + array reads — from
.asDouble to .numberValue. Identical semantics; we just stop
maintaining a private copy of the helper.

No release tag — main is intentionally on a fork pin until upstream
merges. Cohort downstream consumers building OCCTMCP from main pull
the fork transparently; the moment upstream lands we revert
Package.swift to `from: "0.11.0"` and tag a release.

25/25 tests still pass.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant