From 62ac56f107f02728f63764d38176a7cf20e47785 Mon Sep 17 00:00:00 2001 From: gsdali <51393997+gsdali@users.noreply.github.com> Date: Sun, 10 May 2026 09:13:22 +1000 Subject: [PATCH] Value: add numberValue (Double?) that coerces .int and .double MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #225. --- Sources/MCP/Base/Value.swift | 25 +++++++++++ Tests/MCPTests/ValueTests.swift | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 Tests/MCPTests/ValueTests.swift diff --git a/Sources/MCP/Base/Value.swift b/Sources/MCP/Base/Value.swift index 732835b8..c2c290ad 100644 --- a/Sources/MCP/Base/Value.swift +++ b/Sources/MCP/Base/Value.swift @@ -51,6 +51,31 @@ public enum Value: Hashable, Sendable { return value } + /// Returns the underlying number as a `Double`, regardless of + /// whether the value is an `int` or a `double`. + /// + /// JSON has a single `number` type with no distinction between + /// integers and floating-point — but Swift's `JSONEncoder` writes + /// whole-number `Double`s without a decimal point, and the decoder + /// in `Value.init(from:)` tries `Int` first. So `Value.double(0)` + /// round-trips through JSON to `Value.int(0)`, and `doubleValue` + /// returns `nil`. + /// + /// This accessor coerces both `.int(_)` and `.double(_)` to + /// `Double`, which is what most consumers want when reading + /// numeric tool arguments (coordinates, dimensions, angles): the + /// value is a number, regardless of whether the JSON shape was + /// `0` or `0.0`. + /// + /// Returns `nil` for any non-numeric case. + public var numberValue: Double? { + switch self { + case .int(let value): return Double(value) + case .double(let value): return value + default: return nil + } + } + /// Returns the `String` value if the value is a `string`, /// otherwise returns `nil`. public var stringValue: String? { diff --git a/Tests/MCPTests/ValueTests.swift b/Tests/MCPTests/ValueTests.swift new file mode 100644 index 00000000..f384e80e --- /dev/null +++ b/Tests/MCPTests/ValueTests.swift @@ -0,0 +1,73 @@ +import Testing + +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder + +@testable import MCP + +@Suite("Value Tests") +struct ValueTests { + + // MARK: - numberValue + + @Test("numberValue returns the underlying Int promoted to Double") + func numberValueFromInt() { + #expect(Value.int(0).numberValue == 0.0) + #expect(Value.int(42).numberValue == 42.0) + #expect(Value.int(-7).numberValue == -7.0) + } + + @Test("numberValue returns the underlying Double unchanged") + func numberValueFromDouble() { + #expect(Value.double(0.0).numberValue == 0.0) + #expect(Value.double(0.5).numberValue == 0.5) + #expect(Value.double(-1.25).numberValue == -1.25) + } + + @Test("numberValue returns nil for non-numeric cases") + func numberValueNilForNonNumeric() { + #expect(Value.null.numberValue == nil) + #expect(Value.bool(true).numberValue == nil) + #expect(Value.string("42").numberValue == nil) + #expect(Value.array([.int(1)]).numberValue == nil) + #expect(Value.object(["x": .int(1)]).numberValue == nil) + } + + // MARK: - JSON round-trip + + @Test("numberValue survives a JSON round-trip of a whole-number Double") + func numberValueSurvivesJSONRoundTripWholeNumber() throws { + // The motivating case: Value.double(0) encodes as JSON `0`, the + // decoder picks Int first, so doubleValue is nil but numberValue + // still returns the number. + let encoded = try JSONEncoder().encode(Value.double(0)) + let decoded = try JSONDecoder().decode(Value.self, from: encoded) + #expect(decoded.doubleValue == nil, "whole-number Double round-trips through Int") + #expect(decoded.intValue == 0) + #expect(decoded.numberValue == 0.0) + } + + @Test("numberValue handles a typical coordinate array round-trip") + func numberValueHandlesCoordinateArrayRoundTrip() throws { + // [0, 0, 1] is the natural JSON shape for a unit Z direction — + // the exact case that hits the Int/Double decoding gotcha. + let original: Value = .array([.double(0), .double(0), .double(1)]) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Value.self, from: encoded) + guard let arr = decoded.arrayValue, arr.count == 3 else { + Issue.record("expected 3-element array, got \(decoded)") + return + } + #expect(arr[0].numberValue == 0.0) + #expect(arr[1].numberValue == 0.0) + #expect(arr[2].numberValue == 1.0) + } + + @Test("numberValue returns the fractional value when JSON carries a decimal") + func numberValueHandlesFractional() throws { + let encoded = try JSONEncoder().encode(Value.double(0.5)) + let decoded = try JSONDecoder().decode(Value.self, from: encoded) + #expect(decoded.doubleValue == 0.5) + #expect(decoded.numberValue == 0.5) + } +}