fix(mcp): restore >0 validation and reject ambiguous numbers in measurement()

Adversarial-review follow-ups to the lingo measurement() field:
- Add a `positive` option (strict > 0) and use it for the non-zero dimension
  params. Swapping z.number().positive() → measurement(..,{min:0}) had started
  admitting 0 (the core node schemas have no positivity backstop), so a zero-size
  wall/opening/roof could be created. Inclusive-0 fields (overhang, sill height,
  knee-wall height, opening offset, roof pitch) keep min:0.
- Escalate AMBIGUOUS_NUMBER to error so "1,234" fails instead of silently reading
  as 1234 — a 1000x hazard for European decimals. Matches lingo's own /ai fields.
- roofLevelElevation reverted to z.number() (it's a level ordinal, not meters);
  radians fields now advertise radian-appropriate examples.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-07-08 13:09:59 +02:00
co-authored by Claude Opus 4.8
parent f66dcd438b
commit 3273aac551
7 changed files with 108 additions and 48 deletions
+30 -22
View File
@@ -23,14 +23,20 @@ const RAILING_MODES = ['none', 'left', 'right', 'both'] as const
export const createStoryShellInput = { export const createStoryShellInput = {
levelId: NodeIdSchema, levelId: NodeIdSchema,
footprint: z.array(Vec2Schema).min(3), footprint: z.array(Vec2Schema).min(3),
wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).default(2.8), wallHeight: measurement('length', 'm', { positive: true, description: 'Wall height.' }).default(
wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default( 2.8,
0.16,
), ),
wallThickness: measurement('length', 'm', {
positive: true,
description: 'Wall thickness.',
}).default(0.16),
createSlab: z.boolean().default(true), createSlab: z.boolean().default(true),
createCeiling: z.boolean().default(true), createCeiling: z.boolean().default(true),
slabElevation: measurement('length', 'm', { description: 'Slab elevation.' }).default(0.1), slabElevation: measurement('length', 'm', { description: 'Slab elevation.' }).default(0.1),
ceilingHeight: measurement('length', 'm', { min: 0, description: 'Ceiling height.' }).optional(), ceilingHeight: measurement('length', 'm', {
positive: true,
description: 'Ceiling height.',
}).optional(),
namePrefix: z.string().optional(), namePrefix: z.string().optional(),
wallMaterialPreset: z.string().optional(), wallMaterialPreset: z.string().optional(),
slabMaterialPreset: z.string().optional(), slabMaterialPreset: z.string().optional(),
@@ -50,24 +56,24 @@ export const createRoofInput = {
roofLevelId: NodeIdSchema.optional(), roofLevelId: NodeIdSchema.optional(),
useDedicatedRoofLevel: z.boolean().default(true), useDedicatedRoofLevel: z.boolean().default(true),
roofLevelLabel: z.string().default('Roof'), roofLevelLabel: z.string().default('Roof'),
roofLevelElevation: measurement('length', 'm', { // A level ordinal (story index), not a length — kept numeric.
description: 'Roof level elevation.', roofLevelElevation: z.number().optional(),
}).optional(),
roofLevelHeight: measurement('length', 'm', { roofLevelHeight: measurement('length', 'm', {
min: 0, positive: true,
description: 'Roof level height.', description: 'Roof level height.',
}).optional(), }).optional(),
center: Vec3Schema.optional(), center: Vec3Schema.optional(),
width: measurement('length', 'm', { min: 0, description: 'Roof width.' }), width: measurement('length', 'm', { positive: true, description: 'Roof width.' }),
depth: measurement('length', 'm', { min: 0, description: 'Roof depth.' }), depth: measurement('length', 'm', { positive: true, description: 'Roof depth.' }),
roofType: z.enum(ROOF_TYPES).default('hip'), roofType: z.enum(ROOF_TYPES).default('hip'),
pitch: measurement('angle', 'deg', { min: 0, max: 85, description: 'Roof pitch.' }).default(35), pitch: measurement('angle', 'deg', { min: 0, max: 85, description: 'Roof pitch.' }).default(35),
wallHeight: measurement('length', 'm', { min: 0, description: 'Knee-wall height.' }).default( wallHeight: measurement('length', 'm', { min: 0, description: 'Knee-wall height.' }).default(
0.35, 0.35,
), ),
wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default( wallThickness: measurement('length', 'm', {
0.16, positive: true,
), description: 'Wall thickness.',
}).default(0.16),
overhang: measurement('length', 'm', { min: 0, description: 'Eave overhang.' }).default(0.45), overhang: measurement('length', 'm', { min: 0, description: 'Eave overhang.' }).default(0.45),
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
name: z.string().optional(), name: z.string().optional(),
@@ -86,13 +92,15 @@ export const createStairBetweenLevelsInput = {
toLevelId: NodeIdSchema, toLevelId: NodeIdSchema,
position: Vec3Schema, position: Vec3Schema,
rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).default(0), rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).default(0),
width: measurement('length', 'm', { min: 0, description: 'Stair width.' }).default(1), width: measurement('length', 'm', { positive: true, description: 'Stair width.' }).default(1),
runLength: measurement('length', 'm', { min: 0, description: 'Horizontal run length.' }).default( runLength: measurement('length', 'm', {
3, positive: true,
), description: 'Horizontal run length.',
totalRise: measurement('length', 'm', { min: 0, description: 'Total vertical rise.' }).default( }).default(3),
2.8, totalRise: measurement('length', 'm', {
), positive: true,
description: 'Total vertical rise.',
}).default(2.8),
stepCount: z.number().int().positive().default(14), stepCount: z.number().int().positive().default(14),
railingMode: z.enum(RAILING_MODES).default('both'), railingMode: z.enum(RAILING_MODES).default('both'),
destinationSlabId: NodeIdSchema.optional(), destinationSlabId: NodeIdSchema.optional(),
@@ -100,11 +108,11 @@ export const createStairBetweenLevelsInput = {
createDestinationSlabOpening: z.boolean().default(true), createDestinationSlabOpening: z.boolean().default(true),
createSourceCeilingOpening: z.boolean().default(true), createSourceCeilingOpening: z.boolean().default(true),
openingWidth: measurement('length', 'm', { openingWidth: measurement('length', 'm', {
min: 0, positive: true,
description: 'Floor opening width.', description: 'Floor opening width.',
}).optional(), }).optional(),
openingLength: measurement('length', 'm', { openingLength: measurement('length', 'm', {
min: 0, positive: true,
description: 'Floor opening length.', description: 'Floor opening length.',
}).optional(), }).optional(),
openingOffset: measurement('length', 'm', { min: 0, description: 'Opening offset.' }).default(0), openingOffset: measurement('length', 'm', { min: 0, description: 'Opening offset.' }).default(0),
+5 -2
View File
@@ -12,8 +12,11 @@ export const createWallInput = {
levelId: NodeIdSchema, levelId: NodeIdSchema,
start: Vec2Schema, start: Vec2Schema,
end: Vec2Schema, end: Vec2Schema,
thickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(), thickness: measurement('length', 'm', {
height: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(), positive: true,
description: 'Wall thickness.',
}).optional(),
height: measurement('length', 'm', { positive: true, description: 'Wall height.' }).optional(),
} }
export const createWallOutput = { export const createWallOutput = {
+2 -2
View File
@@ -13,8 +13,8 @@ export const cutOpeningInput = {
wallId: NodeIdSchema, wallId: NodeIdSchema,
type: z.enum(['door', 'window']), type: z.enum(['door', 'window']),
position: z.number().min(0).max(1), position: z.number().min(0).max(1),
width: measurement('length', 'm', { min: 0, description: 'Opening width.' }), width: measurement('length', 'm', { positive: true, description: 'Opening width.' }),
height: measurement('length', 'm', { min: 0, description: 'Opening height.' }), height: measurement('length', 'm', { positive: true, description: 'Opening height.' }),
} }
export const cutOpeningOutput = { export const cutOpeningOutput = {
@@ -46,6 +46,19 @@ describe('measurement()', () => {
if (!r.success) expect(r.error.issues[0]?.message.toLowerCase()).toContain('number') if (!r.success) expect(r.error.issues[0]?.message.toLowerCase()).toContain('number')
}) })
test('positive rejects zero and negatives (restores .positive() behavior)', () => {
const m = measurement('length', 'm', { positive: true })
expect(m.safeParse(0).success).toBe(false)
expect(m.safeParse('0m').success).toBe(false)
expect(m.safeParse(-1).success).toBe(false)
expect(m.parse(0.1)).toBe(0.1)
})
test('rejects ambiguous separators instead of a silent 1000x reading', () => {
const r = measurement('length', 'm').safeParse('1,234')
expect(r.success).toBe(false)
})
test('emits a number|string JSON schema advertising natural language', () => { test('emits a number|string JSON schema advertising natural language', () => {
const schema = z.toJSONSchema( const schema = z.toJSONSchema(
measurement('length', 'm', { min: 0, description: 'Wall thickness.' }), measurement('length', 'm', { min: 0, description: 'Wall thickness.' }),
+44 -14
View File
@@ -13,15 +13,21 @@ import { z } from 'zod'
* The emitted JSON Schema is `number | string`, so the model is free to answer * The emitted JSON Schema is `number | string`, so the model is free to answer
* in whatever unit it is thinking in; AI SDK v6 applies the transform and * in whatever unit it is thinking in; AI SDK v6 applies the transform and
* forwards the canonical number to the tool executor. * forwards the canonical number to the tool executor.
*
* Tool-boundary safety: genuinely ambiguous separators (`"1,234"`, which could
* mean 1234 or 1.234) are rejected rather than silently absorbed, so a European
* decimal never becomes a 1000× value.
*/ */
export interface MeasurementOptions { export interface MeasurementOptions {
/** Lower bound, in `unit`. */
min?: number
/** Upper bound, in `unit`. */
max?: number
/** Semantic description (e.g. "Wall thickness"). The natural-language note is appended. */ /** Semantic description (e.g. "Wall thickness"). The natural-language note is appended. */
description?: string description?: string
/** Upper bound, in `unit`. */
max?: number
/** Inclusive lower bound, in `unit`. */
min?: number
/** Require the value to be strictly greater than 0 (for non-zero dimensions). */
positive?: boolean
} }
function unitNoun(unit: string): string { function unitNoun(unit: string): string {
@@ -38,16 +44,29 @@ function unitNoun(unit: string): string {
} }
function naturalLanguageNote(kind: Kind, unit: string): string { function naturalLanguageNote(kind: Kind, unit: string): string {
const examples = let examples: string
kind === 'angle' if (kind === 'angle') {
? unit === 'rad' // Lead with an example in the field's own unit so a bare number is read
? '45, "45°", "1.57rad", "0.25 turn"' // the way the model intends (a bare "45" in a radians field is 45 rad).
: '45, "45°", "1.57rad", "0.25 turn"' examples =
: '0.9, "6 ft", "180cm", "2 ft 3 in"' unit === 'rad' ? '1.5708, "90°", "1.57rad", "0.25 turn"' : '45, "45°", "1.57rad", "0.25 turn"'
} else {
examples = '0.9, "6 ft", "180cm", "2 ft 3 in"'
}
return `Accepts a number (${unitNoun(unit)}) or a natural-language string; other units are converted. e.g. ${examples}.` return `Accepts a number (${unitNoun(unit)}) or a natural-language string; other units are converted. e.g. ${examples}.`
} }
function boundsNote(unit: string, min?: number, max?: number): string { function boundsNote(
unit: string,
min: number | undefined,
max: number | undefined,
positive: boolean,
): string {
if (positive) {
return max === undefined
? ` Must be greater than 0 ${unit}.`
: ` Greater than 0, up to ${max} ${unit}.`
}
if (min !== undefined && max !== undefined) return ` Range ${min}${max} ${unit}.` if (min !== undefined && max !== undefined) return ` Range ${min}${max} ${unit}.`
if (min !== undefined) return ` Minimum ${min} ${unit}.` if (min !== undefined) return ` Minimum ${min} ${unit}.`
if (max !== undefined) return ` Maximum ${max} ${unit}.` if (max !== undefined) return ` Maximum ${max} ${unit}.`
@@ -55,10 +74,10 @@ function boundsNote(unit: string, min?: number, max?: number): string {
} }
export function measurement(kind: Kind, unit: string, opts: MeasurementOptions = {}) { export function measurement(kind: Kind, unit: string, opts: MeasurementOptions = {}) {
const { min, max } = opts const { min, max, positive = false } = opts
const description = [ const description = [
opts.description, opts.description,
naturalLanguageNote(kind, unit) + boundsNote(unit, min, max), naturalLanguageNote(kind, unit) + boundsNote(unit, min, max, positive),
] ]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
@@ -70,7 +89,14 @@ export function measurement(kind: Kind, unit: string, opts: MeasurementOptions =
if (typeof val === 'number') { if (typeof val === 'number') {
value = val value = val
} else { } else {
const result = parseQuantity(val, { kind, unit, strictness: 'forgiving' }) const result = parseQuantity(val, {
kind,
unit,
strictness: 'forgiving',
// A genuinely ambiguous separator ("1,234") must not silently become
// a 1000× value at a tool boundary — fail so the model self-corrects.
escalate: { AMBIGUOUS_NUMBER: 'error' },
})
if (!result.ok) { if (!result.ok) {
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: 'custom',
@@ -84,6 +110,10 @@ export function measurement(kind: Kind, unit: string, opts: MeasurementOptions =
ctx.addIssue({ code: 'custom', message: `Value must be a finite ${kind} in ${unit}.` }) ctx.addIssue({ code: 'custom', message: `Value must be a finite ${kind} in ${unit}.` })
return z.NEVER return z.NEVER
} }
if (positive && value <= 0) {
ctx.addIssue({ code: 'custom', message: `Must be greater than 0 ${unit} (got ${value}).` })
return z.NEVER
}
if (min !== undefined && value < min) { if (min !== undefined && value < min) {
ctx.addIssue({ code: 'custom', message: `Must be at least ${min} ${unit} (got ${value}).` }) ctx.addIssue({ code: 'custom', message: `Must be at least ${min} ${unit} (got ${value}).` })
return z.NEVER return z.NEVER
@@ -25,11 +25,11 @@ export const photoToSceneInput = {
name: z.string().default('Scene from photo'), name: z.string().default('Scene from photo'),
save: z.boolean().default(true), save: z.boolean().default(true),
defaultWallThickness: measurement('length', 'm', { defaultWallThickness: measurement('length', 'm', {
min: 0, positive: true,
description: 'Default wall thickness.', description: 'Default wall thickness.',
}).default(0.2), }).default(0.2),
defaultWallHeight: measurement('length', 'm', { defaultWallHeight: measurement('length', 'm', {
min: 0, positive: true,
description: 'Default wall height.', description: 'Default wall height.',
}).default(2.6), }).default(2.6),
} }
+12 -6
View File
@@ -52,8 +52,14 @@ export const createRoomInput = {
name: z.string().min(1), name: z.string().min(1),
polygon: z.array(Vec2Schema).min(3), polygon: z.array(Vec2Schema).min(3),
color: z.string().optional(), color: z.string().optional(),
wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(), wallHeight: measurement('length', 'm', {
wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(), positive: true,
description: 'Wall height.',
}).optional(),
wallThickness: measurement('length', 'm', {
positive: true,
description: 'Wall thickness.',
}).optional(),
} }
export const createRoomOutput = { export const createRoomOutput = {
@@ -68,8 +74,8 @@ export const addDoorInput = {
wallId: NodeIdSchema, wallId: NodeIdSchema,
t: z.number().min(0).max(1).optional(), t: z.number().min(0).max(1).optional(),
position: z.number().min(0).max(1).optional(), position: z.number().min(0).max(1).optional(),
width: measurement('length', 'm', { min: 0, description: 'Door width.' }).optional(), width: measurement('length', 'm', { positive: true, description: 'Door width.' }).optional(),
height: measurement('length', 'm', { min: 0, description: 'Door height.' }).optional(), height: measurement('length', 'm', { positive: true, description: 'Door height.' }).optional(),
hingesSide: z.enum(['left', 'right']).optional(), hingesSide: z.enum(['left', 'right']).optional(),
swingDirection: z.enum(['inward', 'outward']).optional(), swingDirection: z.enum(['inward', 'outward']).optional(),
} }
@@ -88,8 +94,8 @@ export const addWindowInput = {
wallId: NodeIdSchema, wallId: NodeIdSchema,
t: z.number().min(0).max(1).optional(), t: z.number().min(0).max(1).optional(),
position: z.number().min(0).max(1).optional(), position: z.number().min(0).max(1).optional(),
width: measurement('length', 'm', { min: 0, description: 'Window width.' }).optional(), width: measurement('length', 'm', { positive: true, description: 'Window width.' }).optional(),
height: measurement('length', 'm', { min: 0, description: 'Window height.' }).optional(), height: measurement('length', 'm', { positive: true, description: 'Window height.' }).optional(),
sillHeight: measurement('length', 'm', { sillHeight: measurement('length', 'm', {
min: 0, min: 0,
description: 'Sill height above floor.', description: 'Sill height above floor.',