From c540a6228b086a141ccb4f0fb4f97a4fece694e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 04:26:46 +0000 Subject: [PATCH 1/3] Keep exact digits of large integer IDs in tool text Co-authored-by: Gabe From a0d78efbfd81a73abfaa46ebb77183ebe3056c18 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 04:32:51 +0000 Subject: [PATCH 2/3] Keep exact digits of large integer IDs in tool text JSON.parse rounds an integer it cannot store. Return those digits as text, including from a saved JSON file. Version 0.12.20. Co-authored-by: Gabe --- .claude-plugin/plugin.json | 2 +- docs/usage.mdx | 1 + hooks/protocol.ts | 93 +++++++++++++++++++++++++++++++++++++- tests/code-mode.test.ts | 78 ++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 25969ca..0808469 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code-mode", - "version": "0.12.19", + "version": "0.12.20", "description": "Code mode for MCP: the model writes one JavaScript program that calls MCP tools in a sandbox, instead of one tool call per step.", "types": "./types/index.d.ts", "author": { diff --git a/docs/usage.mdx b/docs/usage.mdx index c518cc7..7c14e07 100644 --- a/docs/usage.mdx +++ b/docs/usage.mdx @@ -78,6 +78,7 @@ What a call gives: - If the tool gives structured content, the call gives that object. - If the tool gives text that is JSON, the call gives the parsed value. +- An integer that a number does not hold exactly arrives as text. Its digits stay exact. - Else the call gives the text. - If the call fails or a permission rule denies it, the call throws an `Error`. The program can catch it. diff --git a/hooks/protocol.ts b/hooks/protocol.ts index 8d71407..c7756c2 100644 --- a/hooks/protocol.ts +++ b/hooks/protocol.ts @@ -104,6 +104,95 @@ export const mcpReply = (r: McpResultLike): Reply => { return { ok: true, value: toValue({ structuredContent: r.structuredContent }, text) } } +// JSON.parse rounds an integer it cannot store, and it reports no error. A +// reviver can read the source text (Node 21 and later), but a reviver slows +// every parse. Quote only an integer whose digits differ from that number, +// then use the plain parse. A string (a key is one), a decimal and an exponent +// stay as they are. An integer of 15 digits or fewer always fits, so text +// with no 16-digit run skips the scan. +const LONG_DIGITS = /\d{16}/ +const JSON_INTEGER = /^-?(?:0|[1-9]\d*)$/ + +const QUOTE = '"'.charCodeAt(0) +const SLASH = '\\'.charCodeAt(0) +const MINUS = '-'.charCodeAt(0) +const PLUS = '+'.charCodeAt(0) +const DOT = '.'.charCodeAt(0) +const EXP = 'e'.charCodeAt(0) +const EXP_CAP = 'E'.charCodeAt(0) + +const isDigit = (code: number): boolean => code >= 48 && code <= 57 + +const skipDigits = (text: string, i: number): number => { + while (i < text.length && isDigit(text.charCodeAt(i))) i++ + return i +} + +/** True when this integer token is not the number JSON.parse would keep. */ +const integerLosesDigits = (token: string): boolean => { + if (!JSON_INTEGER.test(token)) return false + const asNumber = Number(token) + if (!Number.isFinite(asNumber)) return true + return BigInt(asNumber) !== BigInt(token) +} + +/** The same JSON text, with each rounded integer written as a string of its digits. */ +const quoteUnsafeIntegers = (text: string): string => { + if (!LONG_DIGITS.test(text)) return text + const parts: string[] = [] + let from = 0 + let i = 0 + let quoted = false + const keep = (to: number) => { + if (to > from) parts.push(text.slice(from, to)) + from = to + } + while (i < text.length) { + const code = text.charCodeAt(i) + // A string, escapes included. Digits in it, and a key, stay quoted. + if (code === QUOTE) { + i++ + while (i < text.length) { + const next = text.charCodeAt(i) + if (next === SLASH) { + i += 2 + continue + } + i++ + if (next === QUOTE) break + } + continue + } + if (code !== MINUS && !isDigit(code)) { + i++ + continue + } + const start = i + if (code === MINUS) i++ + if (!isDigit(text.charCodeAt(i))) continue + i = skipDigits(text, i) + if (text.charCodeAt(i) === DOT) i = skipDigits(text, i + 1) + const exp = text.charCodeAt(i) + if (exp === EXP || exp === EXP_CAP) { + i++ + const sign = text.charCodeAt(i) + if (sign === PLUS || sign === MINUS) i++ + i = skipDigits(text, i) + } + const token = text.slice(start, i) + if (!integerLosesDigits(token)) continue + keep(start) + parts.push(`"${token}"`) + from = i + quoted = true + } + if (!quoted) return text + keep(text.length) + return parts.join('') +} + +const parseToolJson = (text: string): unknown => JSON.parse(quoteUnsafeIntegers(text)) + /** What the script receives for a tool result: structured data when there is any. */ export const toValue = (result: unknown, text: string | undefined): unknown => { if (result !== null && typeof result === 'object' && 'structuredContent' in result) { @@ -113,7 +202,7 @@ export const toValue = (result: unknown, text: string | undefined): unknown => { const raw = text ?? (typeof result === 'string' ? result : undefined) if (raw === undefined) return result ?? null try { - return JSON.parse(raw) + return parseToolJson(raw) } catch { return raw } @@ -175,7 +264,7 @@ export const savedReply = (format: SavedResult['format'], fileText: string): Rep if (format === 'text') return { ok: true, value: toValue(undefined, fileText) } let parsed: unknown try { - parsed = JSON.parse(fileText) + parsed = parseToolJson(fileText) } catch { // JSON that does not parse was cut; text that is not JSON is the result. if (format === 'unknown') return { ok: true, value: fileText } diff --git a/tests/code-mode.test.ts b/tests/code-mode.test.ts index 0bcbf59..621aefe 100644 --- a/tests/code-mode.test.ts +++ b/tests/code-mode.test.ts @@ -116,6 +116,78 @@ describe('protocol', () => { expect(toValue(undefined, 'plain')).toBe('plain') }) + test('toValue keeps the digits of an integer a number cannot hold', () => { + // 2^53 + 1, and a 19-digit id: bare, in an object, and in an array. + expect(toValue(undefined, '9007199254740993')).toBe('9007199254740993') + expect(toValue(undefined, '1234567890123456789')).toBe('1234567890123456789') + const huge = `1${'0'.repeat(400)}` + expect(toValue(undefined, huge)).toBe(huge) + expect(toValue(undefined, `-${huge}`)).toBe(`-${huge}`) + expect(toValue(undefined, '{"id":1234567890123456789}')).toEqual({ id: '1234567890123456789' }) + expect(toValue(undefined, '{"id":9007199254740993}')).toEqual({ id: '9007199254740993' }) + expect(toValue(undefined, '[9007199254740993]')).toEqual(['9007199254740993']) + expect(toValue(undefined, '[1234567890123456789]')).toEqual(['1234567890123456789']) + expect(toValue(undefined, '[-1234567890123456789]')).toEqual(['-1234567890123456789']) + expect(toValue(undefined, '[1.5,1e21,9007199254740993]')).toEqual([1.5, 1e21, '9007199254740993']) + expect(toValue(undefined, ' { "id" : 1234567890123456789 } ')).toEqual({ id: '1234567890123456789' }) + expect(toValue(undefined, '{"exact":9007199254740992,"id":9007199254740993}')).toEqual({ + exact: 9007199254740992, + id: '9007199254740993', + }) + expect(toValue(undefined, '{"s":"a\\"b\\\\c","id":1234567890123456789}')).toEqual({ + s: 'a"b\\c', + id: '1234567890123456789', + }) + expect(toValue(undefined, '{"rows":[{"id":9007199254740993}]}')).toEqual({ + rows: [{ id: '9007199254740993' }], + }) + // The host sends the value to the sandbox with JSON.stringify. The digits survive that trip. + const sent = JSON.parse(JSON.stringify(toValue(undefined, '{"id":1234567890123456789}'))) + expect(sent).toEqual({ id: '1234567890123456789' }) + }) + + test('toValue parses safe JSON the same way as JSON.parse', () => { + const same = [ + '0', + '1', + '-1', + '42', + '-42', + '9007199254740991', + '-9007199254740991', + '9007199254740992', + '-9007199254740992', + '1.5', + '-1.5', + '0.1', + '1.0', + '1e21', + '1e+21', + '-1.5e2', + '1E-2', + '1e309', + '9007199254740993e0', + '9007199254740993.0', + '"12345678901234567890"', + '"id 1234567890123456789"', + '{"9007199254740993":1}', + '{"s":"a\\"b\\\\c","n":1}', + '{"a":1,"b":[2,3.5,true,null]}', + 'true', + 'false', + 'null', + ' [1, 2] ', + ] + const mismatches = same.filter(text => { + const got = toValue(undefined, text) + const want = JSON.parse(text) + if (want !== null && typeof want === 'object') return JSON.stringify(got) !== JSON.stringify(want) + return !Object.is(got, want) + }) + expect(mismatches).toEqual([]) + expect(Object.is(toValue(undefined, '-0'), -0)).toBe(true) + }) + test('splitToolName splits at the first double underscore', () => { expect(splitToolName('mcp__claude_ai_Jira__get_issue')).toEqual({ server: 'claude_ai_Jira', name: 'get_issue' }) expect(splitToolName('mcp__fcef2cd1-7ad6__atlassianUserInfo')).toEqual({ @@ -206,6 +278,12 @@ describe('protocol', () => { expect(savedReply('unknown', JSON.stringify(messages))).toEqual(value(messages)) // JSON that does not parse was cut. expect(savedReply('json', '{"cut": ')).toEqual({ ok: false, error: 'the saved result is not whole' }) + // A saved JSON file is the same text as a live result, so a large id keeps its digits. + expect(savedReply('json', '{"id":1234567890123456789}')).toEqual({ + ok: true, + value: { id: '1234567890123456789' }, + }) + expect(savedReply('json', '[9007199254740993]')).toEqual({ ok: true, value: ['9007199254740993'] }) }) test('hintNudge names the servers that work now and the searches that missed', () => { From 43ed86ff103103f95f4970a7f8d5d287b3ba756d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 05:07:06 +0000 Subject: [PATCH 3/3] Keep invalid JSON invalid when a long integer is unquoted JSON.parse runs first. The digit scan runs only after that parse succeeds, so an unquoted key stays the original text. Co-authored-by: Gabe --- hooks/protocol.ts | 18 ++++++++++++------ tests/code-mode.test.ts | 11 +++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/hooks/protocol.ts b/hooks/protocol.ts index c7756c2..34c5997 100644 --- a/hooks/protocol.ts +++ b/hooks/protocol.ts @@ -104,12 +104,12 @@ export const mcpReply = (r: McpResultLike): Reply => { return { ok: true, value: toValue({ structuredContent: r.structuredContent }, text) } } -// JSON.parse rounds an integer it cannot store, and it reports no error. A -// reviver can read the source text (Node 21 and later), but a reviver slows -// every parse. Quote only an integer whose digits differ from that number, -// then use the plain parse. A string (a key is one), a decimal and an exponent +// JSON.parse rounds an integer it cannot store, and it reports no error. It +// also decides validity: a scan must not turn invalid text into JSON. After a +// successful parse, quote only an integer whose digits differ from that +// number, then parse again. A string (a key is one), a decimal and an exponent // stay as they are. An integer of 15 digits or fewer always fits, so text -// with no 16-digit run skips the scan. +// with no 16-digit run keeps the first parse. const LONG_DIGITS = /\d{16}/ const JSON_INTEGER = /^-?(?:0|[1-9]\d*)$/ @@ -191,7 +191,13 @@ const quoteUnsafeIntegers = (text: string): string => { return parts.join('') } -const parseToolJson = (text: string): unknown => JSON.parse(quoteUnsafeIntegers(text)) +const parseToolJson = (text: string): unknown => { + const parsed = JSON.parse(text) + if (!LONG_DIGITS.test(text)) return parsed + const quoted = quoteUnsafeIntegers(text) + if (quoted === text) return parsed + return JSON.parse(quoted) +} /** What the script receives for a tool result: structured data when there is any. */ export const toValue = (result: unknown, text: string | undefined): unknown => { diff --git a/tests/code-mode.test.ts b/tests/code-mode.test.ts index 621aefe..81e2668 100644 --- a/tests/code-mode.test.ts +++ b/tests/code-mode.test.ts @@ -188,6 +188,17 @@ describe('protocol', () => { expect(Object.is(toValue(undefined, '-0'), -0)).toBe(true) }) + test('make it fail: invalid JSON with a long integer stays the original text', () => { + // An unquoted key, and a trailing comma. A scan must not make either valid. + const raw = ['{9007199254740993:1}', '[1234567890123456789,]'] + for (const text of raw) { + expect(toValue(undefined, text)).toBe(text) + expect(savedReply('text', text)).toEqual({ ok: true, value: text }) + expect(savedReply('unknown', text)).toEqual({ ok: true, value: text }) + expect(savedReply('json', text)).toEqual({ ok: false, error: 'the saved result is not whole' }) + } + }) + test('splitToolName splits at the first double underscore', () => { expect(splitToolName('mcp__claude_ai_Jira__get_issue')).toEqual({ server: 'claude_ai_Jira', name: 'get_issue' }) expect(splitToolName('mcp__fcef2cd1-7ad6__atlassianUserInfo')).toEqual({