diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index ff9aba36..a8e43e92 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -23,14 +23,20 @@ const RAILING_MODES = ['none', 'left', 'right', 'both'] as const export const createStoryShellInput = { levelId: NodeIdSchema, footprint: z.array(Vec2Schema).min(3), - wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).default(2.8), - wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default( - 0.16, + wallHeight: measurement('length', 'm', { positive: true, description: 'Wall height.' }).default( + 2.8, ), + wallThickness: measurement('length', 'm', { + positive: true, + description: 'Wall thickness.', + }).default(0.16), createSlab: z.boolean().default(true), createCeiling: z.boolean().default(true), 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(), wallMaterialPreset: z.string().optional(), slabMaterialPreset: z.string().optional(), @@ -50,24 +56,24 @@ export const createRoofInput = { roofLevelId: NodeIdSchema.optional(), useDedicatedRoofLevel: z.boolean().default(true), roofLevelLabel: z.string().default('Roof'), - roofLevelElevation: measurement('length', 'm', { - description: 'Roof level elevation.', - }).optional(), + // A level ordinal (story index), not a length — kept numeric. + roofLevelElevation: z.number().optional(), roofLevelHeight: measurement('length', 'm', { - min: 0, + positive: true, description: 'Roof level height.', }).optional(), center: Vec3Schema.optional(), - width: measurement('length', 'm', { min: 0, description: 'Roof width.' }), - depth: measurement('length', 'm', { min: 0, description: 'Roof depth.' }), + width: measurement('length', 'm', { positive: true, description: 'Roof width.' }), + depth: measurement('length', 'm', { positive: true, description: 'Roof depth.' }), roofType: z.enum(ROOF_TYPES).default('hip'), pitch: measurement('angle', 'deg', { min: 0, max: 85, description: 'Roof pitch.' }).default(35), wallHeight: measurement('length', 'm', { min: 0, description: 'Knee-wall height.' }).default( 0.35, ), - wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default( - 0.16, - ), + wallThickness: measurement('length', 'm', { + positive: true, + description: 'Wall thickness.', + }).default(0.16), overhang: measurement('length', 'm', { min: 0, description: 'Eave overhang.' }).default(0.45), materialPreset: z.string().optional(), name: z.string().optional(), @@ -86,13 +92,15 @@ export const createStairBetweenLevelsInput = { toLevelId: NodeIdSchema, position: Vec3Schema, rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).default(0), - width: measurement('length', 'm', { min: 0, description: 'Stair width.' }).default(1), - runLength: measurement('length', 'm', { min: 0, description: 'Horizontal run length.' }).default( - 3, - ), - totalRise: measurement('length', 'm', { min: 0, description: 'Total vertical rise.' }).default( - 2.8, - ), + width: measurement('length', 'm', { positive: true, description: 'Stair width.' }).default(1), + runLength: measurement('length', 'm', { + positive: true, + description: 'Horizontal run length.', + }).default(3), + totalRise: measurement('length', 'm', { + positive: true, + description: 'Total vertical rise.', + }).default(2.8), stepCount: z.number().int().positive().default(14), railingMode: z.enum(RAILING_MODES).default('both'), destinationSlabId: NodeIdSchema.optional(), @@ -100,11 +108,11 @@ export const createStairBetweenLevelsInput = { createDestinationSlabOpening: z.boolean().default(true), createSourceCeilingOpening: z.boolean().default(true), openingWidth: measurement('length', 'm', { - min: 0, + positive: true, description: 'Floor opening width.', }).optional(), openingLength: measurement('length', 'm', { - min: 0, + positive: true, description: 'Floor opening length.', }).optional(), openingOffset: measurement('length', 'm', { min: 0, description: 'Opening offset.' }).default(0), diff --git a/packages/mcp/src/tools/create-wall.ts b/packages/mcp/src/tools/create-wall.ts index ae9b6794..70679d15 100644 --- a/packages/mcp/src/tools/create-wall.ts +++ b/packages/mcp/src/tools/create-wall.ts @@ -12,8 +12,11 @@ export const createWallInput = { levelId: NodeIdSchema, start: Vec2Schema, end: Vec2Schema, - thickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(), - height: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(), + thickness: measurement('length', 'm', { + positive: true, + description: 'Wall thickness.', + }).optional(), + height: measurement('length', 'm', { positive: true, description: 'Wall height.' }).optional(), } export const createWallOutput = { diff --git a/packages/mcp/src/tools/cut-opening.ts b/packages/mcp/src/tools/cut-opening.ts index b03f12f7..e5d8e2c4 100644 --- a/packages/mcp/src/tools/cut-opening.ts +++ b/packages/mcp/src/tools/cut-opening.ts @@ -13,8 +13,8 @@ export const cutOpeningInput = { wallId: NodeIdSchema, type: z.enum(['door', 'window']), position: z.number().min(0).max(1), - width: measurement('length', 'm', { min: 0, description: 'Opening width.' }), - height: measurement('length', 'm', { min: 0, description: 'Opening height.' }), + width: measurement('length', 'm', { positive: true, description: 'Opening width.' }), + height: measurement('length', 'm', { positive: true, description: 'Opening height.' }), } export const cutOpeningOutput = { diff --git a/packages/mcp/src/tools/measurement.test.ts b/packages/mcp/src/tools/measurement.test.ts index ff0f9b0b..deb90c0c 100644 --- a/packages/mcp/src/tools/measurement.test.ts +++ b/packages/mcp/src/tools/measurement.test.ts @@ -46,6 +46,19 @@ describe('measurement()', () => { 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', () => { const schema = z.toJSONSchema( measurement('length', 'm', { min: 0, description: 'Wall thickness.' }), diff --git a/packages/mcp/src/tools/measurement.ts b/packages/mcp/src/tools/measurement.ts index 1767ad06..6df60b1e 100644 --- a/packages/mcp/src/tools/measurement.ts +++ b/packages/mcp/src/tools/measurement.ts @@ -13,15 +13,21 @@ import { z } from 'zod' * 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 * 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 { - /** Lower bound, in `unit`. */ - min?: number - /** Upper bound, in `unit`. */ - max?: number /** Semantic description (e.g. "Wall thickness"). The natural-language note is appended. */ 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 { @@ -38,16 +44,29 @@ function unitNoun(unit: string): string { } function naturalLanguageNote(kind: Kind, unit: string): string { - const examples = - kind === 'angle' - ? unit === 'rad' - ? '45, "45°", "1.57rad", "0.25 turn"' - : '45, "45°", "1.57rad", "0.25 turn"' - : '0.9, "6 ft", "180cm", "2 ft 3 in"' + let examples: string + if (kind === 'angle') { + // Lead with an example in the field's own unit so a bare number is read + // the way the model intends (a bare "45" in a radians field is 45 rad). + examples = + 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}.` } -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) return ` Minimum ${min} ${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 = {}) { - const { min, max } = opts + const { min, max, positive = false } = opts const description = [ opts.description, - naturalLanguageNote(kind, unit) + boundsNote(unit, min, max), + naturalLanguageNote(kind, unit) + boundsNote(unit, min, max, positive), ] .filter(Boolean) .join(' ') @@ -70,7 +89,14 @@ export function measurement(kind: Kind, unit: string, opts: MeasurementOptions = if (typeof val === 'number') { value = val } 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) { ctx.addIssue({ 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}.` }) 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) { ctx.addIssue({ code: 'custom', message: `Must be at least ${min} ${unit} (got ${value}).` }) return z.NEVER diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts index c2032ab8..96d2d2b1 100644 --- a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts @@ -25,11 +25,11 @@ export const photoToSceneInput = { name: z.string().default('Scene from photo'), save: z.boolean().default(true), defaultWallThickness: measurement('length', 'm', { - min: 0, + positive: true, description: 'Default wall thickness.', }).default(0.2), defaultWallHeight: measurement('length', 'm', { - min: 0, + positive: true, description: 'Default wall height.', }).default(2.6), } diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 51aa1dee..2a36fdc1 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -52,8 +52,14 @@ export const createRoomInput = { name: z.string().min(1), polygon: z.array(Vec2Schema).min(3), color: z.string().optional(), - wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(), - wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(), + wallHeight: measurement('length', 'm', { + positive: true, + description: 'Wall height.', + }).optional(), + wallThickness: measurement('length', 'm', { + positive: true, + description: 'Wall thickness.', + }).optional(), } export const createRoomOutput = { @@ -68,8 +74,8 @@ export const addDoorInput = { wallId: NodeIdSchema, t: 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(), - height: measurement('length', 'm', { min: 0, description: 'Door height.' }).optional(), + width: measurement('length', 'm', { positive: true, description: 'Door width.' }).optional(), + height: measurement('length', 'm', { positive: true, description: 'Door height.' }).optional(), hingesSide: z.enum(['left', 'right']).optional(), swingDirection: z.enum(['inward', 'outward']).optional(), } @@ -88,8 +94,8 @@ export const addWindowInput = { wallId: NodeIdSchema, t: 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(), - height: measurement('length', 'm', { min: 0, description: 'Window height.' }).optional(), + width: measurement('length', 'm', { positive: true, description: 'Window width.' }).optional(), + height: measurement('length', 'm', { positive: true, description: 'Window height.' }).optional(), sillHeight: measurement('length', 'm', { min: 0, description: 'Sill height above floor.',