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.
Context
ValueexposesintValue: Int?anddoubleValue: Double?accessors that each only match their exact case:Round-tripping a
Doublethrough JSON loses the type tag whenever the value is a whole number.Value.double(0)encodes as JSON0; the decoder triesIntfirst and producesValue.int(0). So a server tool that reads a coordinate array via: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
Doublefor coordinates / measurements andIntfor counts, with the codec handling the decoder's promotion choice transparently.Proposed
Optionally a sibling for the reverse direction (
integerValue: Int?that accepts.doubleonly when the value is exactly representable asInt), though that's lower-stakes —count: 4.0is unusual.Naming alternatives
numberValue— matches the JSON spec name for the type. Distinct fromintValue/doubleValueso 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
numberValuefor parallelism witharrayValue/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:
Better to ship it once on the SDK side. The cost is two short methods + tests; no API change, no breaking change.
Acceptance
Value.numberValuereturns the underlying scalar promoted toDoublefor both.intand.double; nil otherwise.Value.double(0)throughJSONEncoder()→JSONDecoder()→numberValuereturns0.0. Same for typical LLM shapes like[1, 0, 0]/[0.5, 0.5, 0.5].intValue/doubleValueunchanged — additive only.Out of scope
Bool-coercing accessor (no precedent for0/1↔ true/false in MCP schemas).Stringnumerics like"42"as numbers (LLMs that send strings should be told off via schema).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.