diff --git a/packages/mcp/package.json b/packages/mcp/package.json index aaadac8a..13cadbb3 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -59,6 +59,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@pascal-app/lingo": "^0.1.0", "zod": "^4.3.5" }, "devDependencies": { diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index 1488e57f..ff9aba36 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -14,6 +14,7 @@ import { import { z } from 'zod' import type { SceneOperations } from '../operations' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas' const ROOF_TYPES = ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'] as const @@ -22,12 +23,14 @@ const RAILING_MODES = ['none', 'left', 'right', 'both'] as const export const createStoryShellInput = { levelId: NodeIdSchema, footprint: z.array(Vec2Schema).min(3), - wallHeight: z.number().positive().default(2.8), - wallThickness: z.number().positive().default(0.16), + wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).default(2.8), + wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default( + 0.16, + ), createSlab: z.boolean().default(true), createCeiling: z.boolean().default(true), - slabElevation: z.number().default(0.1), - ceilingHeight: z.number().positive().optional(), + slabElevation: measurement('length', 'm', { description: 'Slab elevation.' }).default(0.1), + ceilingHeight: measurement('length', 'm', { min: 0, description: 'Ceiling height.' }).optional(), namePrefix: z.string().optional(), wallMaterialPreset: z.string().optional(), slabMaterialPreset: z.string().optional(), @@ -47,16 +50,25 @@ export const createRoofInput = { roofLevelId: NodeIdSchema.optional(), useDedicatedRoofLevel: z.boolean().default(true), roofLevelLabel: z.string().default('Roof'), - roofLevelElevation: z.number().optional(), - roofLevelHeight: z.number().positive().optional(), + roofLevelElevation: measurement('length', 'm', { + description: 'Roof level elevation.', + }).optional(), + roofLevelHeight: measurement('length', 'm', { + min: 0, + description: 'Roof level height.', + }).optional(), center: Vec3Schema.optional(), - width: z.number().positive(), - depth: z.number().positive(), + width: measurement('length', 'm', { min: 0, description: 'Roof width.' }), + depth: measurement('length', 'm', { min: 0, description: 'Roof depth.' }), roofType: z.enum(ROOF_TYPES).default('hip'), - pitch: z.number().min(0).max(85).default(35), - wallHeight: z.number().min(0).default(0.35), - wallThickness: z.number().positive().default(0.16), - overhang: z.number().min(0).default(0.45), + 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, + ), + overhang: measurement('length', 'm', { min: 0, description: 'Eave overhang.' }).default(0.45), materialPreset: z.string().optional(), name: z.string().optional(), } @@ -73,21 +85,31 @@ export const createStairBetweenLevelsInput = { fromLevelId: NodeIdSchema, toLevelId: NodeIdSchema, position: Vec3Schema, - rotation: z.number().default(0), - width: z.number().positive().default(1), - runLength: z.number().positive().default(3), - totalRise: z.number().positive().default(2.8), + 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, + ), stepCount: z.number().int().positive().default(14), railingMode: z.enum(RAILING_MODES).default('both'), destinationSlabId: NodeIdSchema.optional(), sourceCeilingId: NodeIdSchema.optional(), createDestinationSlabOpening: z.boolean().default(true), createSourceCeilingOpening: z.boolean().default(true), - openingWidth: z.number().positive().optional(), - openingLength: z.number().positive().optional(), - openingOffset: z.number().min(0).default(0), + openingWidth: measurement('length', 'm', { + min: 0, + description: 'Floor opening width.', + }).optional(), + openingLength: measurement('length', 'm', { + min: 0, + description: 'Floor opening length.', + }).optional(), + openingOffset: measurement('length', 'm', { min: 0, description: 'Opening offset.' }).default(0), openingCenter: Vec2Schema.optional(), - openingRotation: z.number().optional(), + openingRotation: measurement('angle', 'rad', { description: 'Opening rotation.' }).optional(), materialPreset: z.string().optional(), name: z.string().optional(), } diff --git a/packages/mcp/src/tools/create-level.ts b/packages/mcp/src/tools/create-level.ts index ade635b3..784297c4 100644 --- a/packages/mcp/src/tools/create-level.ts +++ b/packages/mcp/src/tools/create-level.ts @@ -5,12 +5,16 @@ import { z } from 'zod' import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema } from './schemas' export const createLevelInput = { buildingId: NodeIdSchema, elevation: z.number().optional(), - height: z.number().optional(), + height: measurement('length', 'm', { + min: 0, + description: 'Level height (stored in metadata).', + }).optional(), label: z.string().optional(), } diff --git a/packages/mcp/src/tools/create-wall.test.ts b/packages/mcp/src/tools/create-wall.test.ts index 4d8c49ef..1322b5ff 100644 --- a/packages/mcp/src/tools/create-wall.test.ts +++ b/packages/mcp/src/tools/create-wall.test.ts @@ -42,6 +42,34 @@ describe('create_wall', () => { expect((created as { thickness?: number }).thickness).toBe(0.15) }) + test('accepts a natural-language thickness and canonicalizes to meters', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const result = await client.callTool({ + name: 'create_wall', + arguments: { + levelId: level.id, + start: [0, 0], + end: [4, 0], + thickness: '6 in', + height: '2.5m', + }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + const created = bridge.getNode(parsed.wallId) as { thickness?: number; height?: number } + expect(created.thickness).toBeCloseTo(0.1524, 6) + expect(created.height).toBeCloseTo(2.5, 6) + }) + + test('rejects an out-of-unit-family value', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const result = await client.callTool({ + name: 'create_wall', + arguments: { levelId: level.id, start: [0, 0], end: [4, 0], thickness: 'banana' }, + }) + expect(result.isError).toBe(true) + }) + test('publishes a live scene snapshot when bound to a saved scene', async () => { const now = new Date().toISOString() const savedMeta: SceneMeta = { diff --git a/packages/mcp/src/tools/create-wall.ts b/packages/mcp/src/tools/create-wall.ts index 66d6ee5e..ae9b6794 100644 --- a/packages/mcp/src/tools/create-wall.ts +++ b/packages/mcp/src/tools/create-wall.ts @@ -5,14 +5,15 @@ import { z } from 'zod' import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema, Vec2Schema } from './schemas' export const createWallInput = { levelId: NodeIdSchema, start: Vec2Schema, end: Vec2Schema, - thickness: z.number().positive().optional(), - height: z.number().positive().optional(), + thickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(), + height: measurement('length', 'm', { min: 0, 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 86c0cc6b..b03f12f7 100644 --- a/packages/mcp/src/tools/cut-opening.ts +++ b/packages/mcp/src/tools/cut-opening.ts @@ -6,14 +6,15 @@ import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { wallLength, wallLocalXFromT } from './geometry' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema } from './schemas' export const cutOpeningInput = { wallId: NodeIdSchema, type: z.enum(['door', 'window']), position: z.number().min(0).max(1), - width: z.number().positive(), - height: z.number().positive(), + width: measurement('length', 'm', { min: 0, description: 'Opening width.' }), + height: measurement('length', 'm', { min: 0, description: 'Opening height.' }), } export const cutOpeningOutput = { diff --git a/packages/mcp/src/tools/measurement.test.ts b/packages/mcp/src/tools/measurement.test.ts new file mode 100644 index 00000000..ff0f9b0b --- /dev/null +++ b/packages/mcp/src/tools/measurement.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { measurement } from './measurement' + +describe('measurement()', () => { + test('parses natural-language length to meters', () => { + const m = measurement('length', 'm', { min: 0 }) + expect(m.parse('6 in')).toBeCloseTo(0.1524, 6) + expect(m.parse('180cm')).toBeCloseTo(1.8, 6) + expect(m.parse('1m80')).toBeCloseTo(1.8, 6) + expect(m.parse(`5'11"`)).toBeCloseTo(1.8034, 4) + expect(m.parse('2 ft 3 in')).toBeCloseTo(0.6858, 6) + }) + + test('passes numbers through unchanged (backward compatible)', () => { + const m = measurement('length', 'm', { min: 0 }) + expect(m.parse(0.15)).toBe(0.15) + expect(m.parse(3)).toBe(3) + }) + + test('reads a bare numeric string as the field unit', () => { + expect(measurement('length', 'm').parse('6')).toBe(6) + expect(measurement('angle', 'deg').parse('45')).toBe(45) + }) + + test('canonicalizes angles to the field unit', () => { + expect(measurement('angle', 'deg').parse('45°')).toBe(45) + expect(measurement('angle', 'deg').parse('1.57rad')).toBeCloseTo(89.954, 3) + expect(measurement('angle', 'rad').parse('90deg')).toBeCloseTo(Math.PI / 2, 6) + expect(measurement('angle', 'rad').parse('0.25 turn')).toBeCloseTo(Math.PI / 2, 6) + }) + + test('enforces min/max bounds in the field unit', () => { + const m = measurement('length', 'm', { min: 0, max: 3 }) + expect(m.safeParse('-1').success).toBe(false) + expect(m.safeParse('900ft').success).toBe(false) + expect(m.safeParse('2m').success).toBe(true) + const tooBig = m.safeParse('900ft') + expect(tooBig.success).toBe(false) + if (!tooBig.success) expect(tooBig.error.issues[0]?.message).toContain('at most 3 m') + }) + + test('rejects unparseable input with a model-readable message', () => { + const r = measurement('length', 'm').safeParse('banana') + expect(r.success).toBe(false) + if (!r.success) expect(r.error.issues[0]?.message.toLowerCase()).toContain('number') + }) + + test('emits a number|string JSON schema advertising natural language', () => { + const schema = z.toJSONSchema( + measurement('length', 'm', { min: 0, description: 'Wall thickness.' }), + { + io: 'input', + }, + ) + const json = JSON.stringify(schema) + expect(json).toContain('anyOf') + expect(json).toContain('number') + expect(json).toContain('string') + expect((schema as { description?: string }).description).toContain('natural-language') + }) +}) diff --git a/packages/mcp/src/tools/measurement.ts b/packages/mcp/src/tools/measurement.ts new file mode 100644 index 00000000..1767ad06 --- /dev/null +++ b/packages/mcp/src/tools/measurement.ts @@ -0,0 +1,98 @@ +import { type Kind, parseQuantity } from '@pascal-app/lingo' +import { z } from 'zod' + +/** + * A zod field for a measurement tool argument that a model may emit as a bare + * number OR as natural language. `measurement('length', 'm')` accepts `0.15`, + * `"6 in"`, `"180cm"`, `"2 ft 3 in"`; `measurement('angle', 'deg')` accepts + * `45`, `"45°"`, `"1.57rad"`, `"0.25 turn"`. The value is canonicalized (via + * `@pascal-app/lingo`) to a number in `unit` — the exact unit the tool handler + * already expects — so no handler change is needed. `min`/`max` (in `unit`) + * reject out-of-range values with a model-readable message. + * + * 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. + */ + +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 +} + +function unitNoun(unit: string): string { + switch (unit) { + case 'm': + return 'meters' + case 'deg': + return 'degrees' + case 'rad': + return 'radians' + default: + return unit + } +} + +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"' + 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 { + if (min !== undefined && max !== undefined) return ` Range ${min}–${max} ${unit}.` + if (min !== undefined) return ` Minimum ${min} ${unit}.` + if (max !== undefined) return ` Maximum ${max} ${unit}.` + return '' +} + +export function measurement(kind: Kind, unit: string, opts: MeasurementOptions = {}) { + const { min, max } = opts + const description = [ + opts.description, + naturalLanguageNote(kind, unit) + boundsNote(unit, min, max), + ] + .filter(Boolean) + .join(' ') + + return z + .union([z.number(), z.string()]) + .transform((val, ctx) => { + let value: number + if (typeof val === 'number') { + value = val + } else { + const result = parseQuantity(val, { kind, unit, strictness: 'forgiving' }) + if (!result.ok) { + ctx.addIssue({ + code: 'custom', + message: result.issues[0]?.message ?? `Could not read "${val}" as a ${kind}.`, + }) + return z.NEVER + } + value = result.quantity.to(unit).value + } + if (!Number.isFinite(value)) { + ctx.addIssue({ code: 'custom', message: `Value must be a finite ${kind} in ${unit}.` }) + return z.NEVER + } + if (min !== undefined && value < min) { + ctx.addIssue({ code: 'custom', message: `Must be at least ${min} ${unit} (got ${value}).` }) + return z.NEVER + } + if (max !== undefined && value > max) { + ctx.addIssue({ code: 'custom', message: `Must be at most ${max} ${unit} (got ${value}).` }) + return z.NEVER + } + return value + }) + .describe(description) +} 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 ad183be5..c2032ab8 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 @@ -13,6 +13,7 @@ import { import { z } from 'zod' import type { SceneOperations } from '../../operations' import { appendLiveSceneEvent } from '../live-sync' +import { measurement } from '../measurement' /** * Input shape for the `photo_to_scene` orchestrator. `image` matches the @@ -23,8 +24,14 @@ export const photoToSceneInput = { scaleHint: z.string().optional().describe('e.g. "1 cm = 1 m" or "approx 80 m²"'), name: z.string().default('Scene from photo'), save: z.boolean().default(true), - defaultWallThickness: z.number().default(0.2), - defaultWallHeight: z.number().default(2.6), + defaultWallThickness: measurement('length', 'm', { + min: 0, + description: 'Default wall thickness.', + }).default(0.2), + defaultWallHeight: measurement('length', 'm', { + min: 0, + description: 'Default wall height.', + }).default(2.6), } export const photoToSceneOutput = { diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts index 88691241..1b7da901 100644 --- a/packages/mcp/src/tools/place-item.ts +++ b/packages/mcp/src/tools/place-item.ts @@ -7,13 +7,14 @@ import { findCatalogItem } from './asset-catalog' import { ErrorCode, throwMcpError } from './errors' import { projectWorldPointToWallLocalX, wallLength } from './geometry' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema, Vec3Schema } from './schemas' export const placeItemInput = { catalogItemId: z.string().min(1), targetNodeId: NodeIdSchema, position: Vec3Schema, - rotation: z.number().optional(), + rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).optional(), } export const placeItemOutput = { diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 2fca54bd..51aa1dee 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -22,6 +22,7 @@ import { wallLocalXFromT, } from './geometry' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema, Vec2Schema } from './schemas' const ROOM_TYPES = [ @@ -51,8 +52,8 @@ export const createRoomInput = { name: z.string().min(1), polygon: z.array(Vec2Schema).min(3), color: z.string().optional(), - wallHeight: z.number().positive().optional(), - wallThickness: z.number().positive().optional(), + wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(), + wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(), } export const createRoomOutput = { @@ -67,8 +68,8 @@ export const addDoorInput = { wallId: NodeIdSchema, t: z.number().min(0).max(1).optional(), position: z.number().min(0).max(1).optional(), - width: z.number().positive().optional(), - height: z.number().positive().optional(), + width: measurement('length', 'm', { min: 0, description: 'Door width.' }).optional(), + height: measurement('length', 'm', { min: 0, description: 'Door height.' }).optional(), hingesSide: z.enum(['left', 'right']).optional(), swingDirection: z.enum(['inward', 'outward']).optional(), } @@ -87,9 +88,12 @@ export const addWindowInput = { wallId: NodeIdSchema, t: z.number().min(0).max(1).optional(), position: z.number().min(0).max(1).optional(), - width: z.number().positive().optional(), - height: z.number().positive().optional(), - sillHeight: z.number().min(0).optional(), + width: measurement('length', 'm', { min: 0, description: 'Window width.' }).optional(), + height: measurement('length', 'm', { min: 0, description: 'Window height.' }).optional(), + sillHeight: measurement('length', 'm', { + min: 0, + description: 'Sill height above floor.', + }).optional(), } export const addWindowOutput = {