Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions docs/usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
99 changes: 97 additions & 2 deletions hooks/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,101 @@ 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. 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 keeps the first parse.
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 => {
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 => {
if (result !== null && typeof result === 'object' && 'structuredContent' in result) {
Expand All @@ -113,7 +208,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
}
Expand Down Expand Up @@ -175,7 +270,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 }
Expand Down
89 changes: 89 additions & 0 deletions tests/code-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,89 @@ 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('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({
Expand Down Expand Up @@ -206,6 +289,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', () => {
Expand Down
Loading