From 1f618fbef405243afdbb34a7e58986b03dc965fc Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Tue, 2 Jun 2026 12:04:47 +0530 Subject: [PATCH 1/2] fix(read): preserve Int64 precision for unsafe Longs in tool outputs Introduce a custom EJSON stringifier helper that pre-processes objects to convert BSON Long values exceeding safe JavaScript integer limits into strict numberLong string representations, while keeping safe Long values as standard relaxed numbers. Use this custom stringifier across find, aggregate, aggregate-db, and db-stats tool outputs to prevent precision round-off errors in downstream LLM clients. --- src/helpers/ejson.ts | 52 +++++++++++++++++++++ src/tools/mongodb/metadata/dbStats.ts | 4 +- src/tools/mongodb/read/aggregate.ts | 5 ++- src/tools/mongodb/read/aggregateDB.ts | 5 ++- src/tools/mongodb/read/find.ts | 4 +- tests/unit/helpers/ejson.test.ts | 65 +++++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 src/helpers/ejson.ts create mode 100644 tests/unit/helpers/ejson.test.ts diff --git a/src/helpers/ejson.ts b/src/helpers/ejson.ts new file mode 100644 index 000000000..d21b963fd --- /dev/null +++ b/src/helpers/ejson.ts @@ -0,0 +1,52 @@ +import { EJSON, Long } from "bson"; + +/** + * Pre-processes an object by converting BSON Long values into EJSON format + * if they exceed the JavaScript safe integer limits. Safe Long values are + * converted to standard JavaScript numbers to maintain readability. + */ +export function serializeSafeLongs(obj: unknown): unknown { + if (obj === null || obj === undefined) { + return obj; + } + + if ( + obj instanceof Long || + (typeof obj === "object" && "_bsontype" in obj && (obj as Record)._bsontype === "Long") + ) { + const longObj = obj as unknown as Long; + const num = longObj.toNumber(); + if (num >= Number.MIN_SAFE_INTEGER && num <= Number.MAX_SAFE_INTEGER) { + return num; + } + return { $numberLong: longObj.toString() }; + } + + if (Array.isArray(obj)) { + return obj.map(serializeSafeLongs); + } + + if (typeof obj === "object") { + const proto: unknown = Object.getPrototypeOf(obj); + if (proto === null || proto === Object.prototype) { + const result: Record = {}; + for (const key of Object.keys(obj)) { + result[key] = serializeSafeLongs((obj as Record)[key]); + } + return result; + } + } + + return obj; +} + +/** + * Custom EJSON stringifier that preserves 64-bit integer precision for unsafe Longs. + */ +export function stringifyEJSON( + value: unknown, + replacer?: ((this: unknown, key: string, value: unknown) => unknown) | (string | number)[] | null, + space?: string | number +): string { + return EJSON.stringify(serializeSafeLongs(value), replacer as Parameters[1], space); +} diff --git a/src/tools/mongodb/metadata/dbStats.ts b/src/tools/mongodb/metadata/dbStats.ts index fdd055b3b..c55b41682 100644 --- a/src/tools/mongodb/metadata/dbStats.ts +++ b/src/tools/mongodb/metadata/dbStats.ts @@ -1,7 +1,7 @@ import { DBOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import type { ToolArgs, OperationType, ToolExecutionContext, ToolResult } from "../../tool.js"; import { formatUntrustedData } from "../../tool.js"; -import { EJSON } from "bson"; +import { stringifyEJSON } from "../../../helpers/ejson.js"; import { z } from "zod"; const DbStatsOutputSchema = { @@ -34,7 +34,7 @@ export class DbStatsTool extends MongoDBToolBase { ); return { - content: formatUntrustedData(`Statistics for database ${database}`, EJSON.stringify(result)), + content: formatUntrustedData(`Statistics for database ${database}`, stringifyEJSON(result)), structuredContent: { stats: result, }, diff --git a/src/tools/mongodb/read/aggregate.ts b/src/tools/mongodb/read/aggregate.ts index c13451aa4..fb46ef814 100644 --- a/src/tools/mongodb/read/aggregate.ts +++ b/src/tools/mongodb/read/aggregate.ts @@ -6,7 +6,8 @@ import { CollOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import type { ToolArgs, OperationType, ToolExecutionContext } from "../../tool.js"; import { formatUntrustedData } from "../../tool.js"; import { checkIndexUsage } from "../../../helpers/indexCheck.js"; -import { type Document, EJSON } from "bson"; +import { type Document } from "bson"; +import { stringifyEJSON } from "../../../helpers/ejson.js"; import { ErrorCodes, MongoDBError } from "../../../common/errors.js"; import { collectCursorUntilMaxBytesLimit } from "../../../helpers/collectCursorUntilMaxBytes.js"; import { operationWithFallback } from "../../../helpers/operationWithFallback.js"; @@ -188,7 +189,7 @@ Note to LLM: If the entire aggregation result is required, use the "export" tool return { content: formatUntrustedData( successMessage, - ...(documents.length > 0 ? [EJSON.stringify(documents)] : []) + ...(documents.length > 0 ? [stringifyEJSON(documents)] : []) ), }; } finally { diff --git a/src/tools/mongodb/read/aggregateDB.ts b/src/tools/mongodb/read/aggregateDB.ts index 6757cfa3b..ceecbc96c 100644 --- a/src/tools/mongodb/read/aggregateDB.ts +++ b/src/tools/mongodb/read/aggregateDB.ts @@ -5,7 +5,8 @@ import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-d import { DBOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import type { ToolArgs, OperationType, ToolExecutionContext } from "../../tool.js"; import { formatUntrustedData } from "../../tool.js"; -import { type Document, EJSON } from "bson"; +import { type Document } from "bson"; +import { stringifyEJSON } from "../../../helpers/ejson.js"; import { ErrorCodes, MongoDBError } from "../../../common/errors.js"; import { collectCursorUntilMaxBytesLimit } from "../../../helpers/collectCursorUntilMaxBytes.js"; import { operationWithFallback } from "../../../helpers/operationWithFallback.js"; @@ -100,7 +101,7 @@ The maximum number of bytes to return in the response. This value is capped by t return { content: formatUntrustedData( successMessage, - ...(documents.length > 0 ? [EJSON.stringify(documents)] : []) + ...(documents.length > 0 ? [stringifyEJSON(documents)] : []) ), }; } finally { diff --git a/src/tools/mongodb/read/find.ts b/src/tools/mongodb/read/find.ts index 87794870f..38580355d 100644 --- a/src/tools/mongodb/read/find.ts +++ b/src/tools/mongodb/read/find.ts @@ -5,7 +5,7 @@ import type { ToolArgs, OperationType, ToolExecutionContext } from "../../tool.j import { formatUntrustedData } from "../../tool.js"; import type { FindCursor } from "mongodb"; import { checkIndexUsage } from "../../../helpers/indexCheck.js"; -import { EJSON } from "bson"; +import { stringifyEJSON } from "../../../helpers/ejson.js"; import { collectCursorUntilMaxBytesLimit } from "../../../helpers/collectCursorUntilMaxBytes.js"; import { operationWithFallback } from "../../../helpers/operationWithFallback.js"; import { ONE_MB, QUERY_COUNT_MAX_TIME_MS_CAP, CURSOR_LIMITS_TO_LLM_TEXT } from "../../../helpers/constants.js"; @@ -115,7 +115,7 @@ Note to LLM: If the entire query result is required, use the "export" tool inste documents: cursorResults.documents, appliedLimits: [limitOnFindCursor.cappedBy, cursorResults.cappedBy].filter((limit) => !!limit), }), - ...(cursorResults.documents.length > 0 ? [EJSON.stringify(cursorResults.documents)] : []) + ...(cursorResults.documents.length > 0 ? [stringifyEJSON(cursorResults.documents)] : []) ), }; } finally { diff --git a/tests/unit/helpers/ejson.test.ts b/tests/unit/helpers/ejson.test.ts new file mode 100644 index 000000000..93b01c9db --- /dev/null +++ b/tests/unit/helpers/ejson.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { Long } from "bson"; +import { serializeSafeLongs, stringifyEJSON } from "../../../src/helpers/ejson.js"; + +describe("serializeSafeLongs", () => { + it("preserves null and undefined", () => { + expect(serializeSafeLongs(null)).toBeNull(); + expect(serializeSafeLongs(undefined)).toBeUndefined(); + }); + + it("leaves standard numbers and primitive types untouched", () => { + expect(serializeSafeLongs(42)).toBe(42); + expect(serializeSafeLongs("hello")).toBe("hello"); + expect(serializeSafeLongs(true)).toBe(true); + }); + + it("converts safe Long values to standard JavaScript numbers", () => { + const safeLong = Long.fromNumber(12345); + expect(serializeSafeLongs(safeLong)).toBe(12345); + }); + + it("serializes unsafe Long values exceeding Number.MAX_SAFE_INTEGER to strict numberLong object", () => { + const unsafeLong = Long.fromString("7583362298413593073"); + expect(serializeSafeLongs(unsafeLong)).toEqual({ $numberLong: "7583362298413593073" }); + }); + + it("serializes unsafe Long values below Number.MIN_SAFE_INTEGER to strict numberLong object", () => { + const unsafeLong = Long.fromString("-7583362298413593073"); + expect(serializeSafeLongs(unsafeLong)).toEqual({ $numberLong: "-7583362298413593073" }); + }); + + it("handles nested objects and arrays", () => { + const input = { + a: Long.fromNumber(100), + b: Long.fromString("9007199254740992"), // unsafe (MAX_SAFE_INTEGER + 1) + c: [ + Long.fromNumber(200), + Long.fromString("-9007199254740992"), // unsafe (MIN_SAFE_INTEGER - 1) + ], + nested: { + d: Long.fromString("123456789012345678"), + }, + }; + const expected = { + a: 100, + b: { $numberLong: "9007199254740992" }, + c: [200, { $numberLong: "-9007199254740992" }], + nested: { + d: { $numberLong: "123456789012345678" }, + }, + }; + expect(serializeSafeLongs(input)).toEqual(expected); + }); +}); + +describe("stringifyEJSON", () => { + it("stringifies unsafe Long as string preserving exact precision in JSON", () => { + const data = { + val: Long.fromString("7583362298413593073"), + safe: Long.fromNumber(42), + }; + const result = stringifyEJSON(data); + expect(result).toBe('{"val":{"$numberLong":"7583362298413593073"},"safe":42}'); + }); +}); From 31b183d7dd02380b1aaa286ab1bd3677ece04777 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Tue, 2 Jun 2026 12:07:16 +0530 Subject: [PATCH 2/2] refactor(ejson): perform non-lossy direct Long comparison for safe limit check Compare the Long object directly rather than calling toNumber() first, preventing precision loss for boundary values (e.g. MAX_SAFE_INTEGER + 2) that round back to safe limits as floats. Add clarifying JSDoc documentation about the prototype recursion constraint. --- src/helpers/ejson.ts | 11 ++++++++--- tests/unit/helpers/ejson.test.ts | 11 +++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/helpers/ejson.ts b/src/helpers/ejson.ts index d21b963fd..3af65b97c 100644 --- a/src/helpers/ejson.ts +++ b/src/helpers/ejson.ts @@ -4,6 +4,9 @@ import { EJSON, Long } from "bson"; * Pre-processes an object by converting BSON Long values into EJSON format * if they exceed the JavaScript safe integer limits. Safe Long values are * converted to standard JavaScript numbers to maintain readability. + * + * Note: Recursion is restricted to plain objects (proto is Object.prototype or null) + * and arrays to avoid traversing and corrupting other BSON wrapper types (e.g. ObjectId, Binary). */ export function serializeSafeLongs(obj: unknown): unknown { if (obj === null || obj === undefined) { @@ -15,9 +18,11 @@ export function serializeSafeLongs(obj: unknown): unknown { (typeof obj === "object" && "_bsontype" in obj && (obj as Record)._bsontype === "Long") ) { const longObj = obj as unknown as Long; - const num = longObj.toNumber(); - if (num >= Number.MIN_SAFE_INTEGER && num <= Number.MAX_SAFE_INTEGER) { - return num; + const isSafe = + longObj.lessThanOrEqual(Long.fromNumber(Number.MAX_SAFE_INTEGER)) && + longObj.greaterThanOrEqual(Long.fromNumber(Number.MIN_SAFE_INTEGER)); + if (isSafe) { + return longObj.toNumber(); } return { $numberLong: longObj.toString() }; } diff --git a/tests/unit/helpers/ejson.test.ts b/tests/unit/helpers/ejson.test.ts index 93b01c9db..f636e0150 100644 --- a/tests/unit/helpers/ejson.test.ts +++ b/tests/unit/helpers/ejson.test.ts @@ -29,6 +29,17 @@ describe("serializeSafeLongs", () => { expect(serializeSafeLongs(unsafeLong)).toEqual({ $numberLong: "-7583362298413593073" }); }); + it("handles boundary values around safe integer limits without float lossiness", () => { + // MAX_SAFE_INTEGER is 9007199254740991 + // 9007199254740993 is unsafe, but its float representation rounds to 9007199254740992, + // which could trick a simple float-based safety check. + const unsafeBoundary = Long.fromString("9007199254740993"); + expect(serializeSafeLongs(unsafeBoundary)).toEqual({ $numberLong: "9007199254740993" }); + + const safeBoundary = Long.fromString("9007199254740991"); + expect(serializeSafeLongs(safeBoundary)).toBe(9007199254740991); + }); + it("handles nested objects and arrays", () => { const input = { a: Long.fromNumber(100),