Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/helpers/ejson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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) {
return obj;
}

if (
obj instanceof Long ||
(typeof obj === "object" && "_bsontype" in obj && (obj as Record<string, unknown>)._bsontype === "Long")
) {
const longObj = obj as unknown as Long;
const isSafe =
longObj.lessThanOrEqual(Long.fromNumber(Number.MAX_SAFE_INTEGER)) &&
longObj.greaterThanOrEqual(Long.fromNumber(Number.MIN_SAFE_INTEGER));
if (isSafe) {
return longObj.toNumber();
}
Comment on lines +20 to +26
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<string, unknown> = {};
for (const key of Object.keys(obj)) {
result[key] = serializeSafeLongs((obj as Record<string, unknown>)[key]);
}
return result;
}
}
Comment on lines +34 to +43

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<typeof EJSON.stringify>[1], space);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to not just do EJSON.stringify(value, { relaxed: false })? Yes that gives you verbose EJSON always which we may or may not want, but then we don't need custom code at all.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using { relaxed: false }\ would force all BSON values (including safe \Long\s, \Int32\s, \Double\s, etc.) to serialize into their verbose strict EJSON representations (e.g. {\count: {\: \42}}\ or {\value: {\: \123}}).

This significantly reduces output readability for downstream LLMs and increases client payload sizes. By using this targeted preprocessing helper, we retain the highly readable relaxed representations for all safe numbers and other BSON types, while strictly preventing precision loss for numbers that exceed JS safe integer limits.

@lerouxb lerouxb Jun 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compass has this concept called "semi-relaxed" which does a variation of what you're doing here and that's probably what we should be using everywhere we output JSON. ie. do relaxed: true except for these edge cases. But it does have the problem that something that reads the output could work fine for years and then suddenly encounter an int greater than 2^^53 for the first time which suddenly changes the output format and then their code blows up.

And it still has the readability problem because those values are suddenly in EJSON. So if LLMs don't deal well with seeing { $numberLong: "value" } then it still applies in that case - it just goes from common to rare. Which as I explained above isn't necessarily better.

I think the team should probably deal with all all content and structuredContent in one go. Make a decision and apply it everywhere consistently. Also make sure that output always contains structuredContent while at it.

@lerouxb lerouxb Jun 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
4 changes: 2 additions & 2 deletions src/tools/mongodb/metadata/dbStats.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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,
},
Expand Down
5 changes: 3 additions & 2 deletions src/tools/mongodb/read/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions src/tools/mongodb/read/aggregateDB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/tools/mongodb/read/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
76 changes: 76 additions & 0 deletions tests/unit/helpers/ejson.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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 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),
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}');
});
});