diff --git a/src/mcp/__tests__/command-tools.test.ts b/src/mcp/__tests__/command-tools.test.ts index ae5ec63064..d419068de6 100644 --- a/src/mcp/__tests__/command-tools.test.ts +++ b/src/mcp/__tests__/command-tools.test.ts @@ -5,6 +5,7 @@ import { createCommandToolExecutor, listCommandTools } from '../command-tools.ts import { COMMAND_OUTPUT_SCHEMAS } from '../command-output-schemas.ts'; import { AppError } from '../../kernel/errors.ts'; import { NAVIGATION_COMMAND_PROJECTIONS } from '../../commands/system/navigation-projection.ts'; +import { validateAgainstSchema } from './output-schema-validator.ts'; test('MCP command tool executor hides client creation behind an execution adapter', async () => { const client = {} as AgentDeviceClient; @@ -379,6 +380,136 @@ test('MCP boot structuredContent is consistent with its advertised outputSchema' assert.deepEqual(result.structuredContent, bootResult); }); +// --- #1219 public Apple platform parity in MCP output schemas --- +// These validate representative structured content against the COMPLETE +// advertised schema (enums/consts and nested required fields), not just the +// presence of required keys, and pin the negative cases the required-only check +// silently accepted. + +function bootResultFor(platform: 'ios' | 'macos') { + return { + platform, + target: platform === 'macos' ? 'desktop' : 'mobile', + device: platform === 'macos' ? 'My Mac' : 'iPhone 16', + id: 'UDID-123', + kind: platform === 'macos' ? 'device' : 'simulator', + booted: true, + // Additive Apple-OS discriminant rides alongside the leaf platform. + appleOs: platform, + } as const; +} + +function shutdownResultFor(platform: 'ios' | 'macos') { + const { platform: leaf, target, device, id, kind, appleOs } = bootResultFor(platform); + return { + platform: leaf, + target, + device, + id, + kind, + appleOs, + shutdown: { success: true, exitCode: 0, stdout: '', stderr: '' }, + }; +} + +function prepareResultFor(platform: 'ios' | 'macos') { + return { + action: 'ios-runner', + platform, + deviceId: 'UDID-123', + deviceName: 'iPhone 16', + kind: 'simulator', + durationMs: 1200, + runner: {}, + connectMs: 100, + healthCheckMs: 50, + timing: { + totalMs: 1200, + additiveParts: { connectAfterBuildMs: 100, healthCheckMs: 50 }, + containment: { healthCheckMs: [] }, + note: 'ok', + }, + message: 'prepared', + }; +} + +test('MCP boot/shutdown schemas advertise public Apple leaves, never internal apple', () => { + const platformEnum = COMMAND_OUTPUT_SCHEMAS.boot.properties?.platform?.enum; + assert.deepEqual(platformEnum, ['ios', 'macos', 'android', 'linux', 'web']); + assert.equal(platformEnum?.includes('apple'), false); + // shutdown shares the same resolved-device header. + assert.deepEqual(COMMAND_OUTPUT_SCHEMAS.shutdown.properties?.platform?.enum, platformEnum); +}); + +test('MCP valid ios/macos boot results satisfy the complete boot schema', () => { + for (const platform of ['ios', 'macos'] as const) { + assert.deepEqual( + validateAgainstSchema(bootResultFor(platform), COMMAND_OUTPUT_SCHEMAS.boot), + [], + ); + } +}); + +test('MCP valid ios/macos shutdown results satisfy the complete shutdown schema', () => { + for (const platform of ['ios', 'macos'] as const) { + assert.deepEqual( + validateAgainstSchema(shutdownResultFor(platform), COMMAND_OUTPUT_SCHEMAS.shutdown), + [], + ); + } +}); + +test('MCP boot schema rejects the internal apple platform and unknown enum values', () => { + // Internal identity must not be advertised on the public result field. + assert.notDeepEqual( + validateAgainstSchema( + { ...bootResultFor('ios'), platform: 'apple' }, + COMMAND_OUTPUT_SCHEMAS.boot, + ), + [], + ); + assert.notDeepEqual( + validateAgainstSchema( + { ...bootResultFor('ios'), platform: 'windows' }, + COMMAND_OUTPUT_SCHEMAS.boot, + ), + [], + ); + // The `booted` discriminant is a const true; a false value fails the schema. + assert.notDeepEqual( + validateAgainstSchema({ ...bootResultFor('ios'), booted: false }, COMMAND_OUTPUT_SCHEMAS.boot), + [], + ); + // A missing nested-required shutdown field (stderr) fails validation. + assert.notDeepEqual( + validateAgainstSchema( + { ...shutdownResultFor('ios'), shutdown: { success: true, exitCode: 0, stdout: '' } }, + COMMAND_OUTPUT_SCHEMAS.shutdown, + ), + [], + ); +}); + +test('MCP prepare schema mirrors its PublicPlatform contract', () => { + const platformEnum = COMMAND_OUTPUT_SCHEMAS.prepare.properties?.platform?.enum; + assert.deepEqual(platformEnum, ['ios', 'macos', 'android', 'linux', 'web']); + assert.equal(platformEnum?.includes('apple'), false); + + for (const platform of ['ios', 'macos'] as const) { + assert.deepEqual( + validateAgainstSchema(prepareResultFor(platform), COMMAND_OUTPUT_SCHEMAS.prepare), + [], + ); + } + assert.notDeepEqual( + validateAgainstSchema( + { ...prepareResultFor('ios'), platform: 'apple' }, + COMMAND_OUTPUT_SCHEMAS.prepare, + ), + [], + ); +}); + test('MCP session tool exposes state-dir resolution without a daemon round-trip', async () => { const sessionTool = listCommandTools().find((tool) => tool.name === 'session'); assert.ok(sessionTool); diff --git a/src/mcp/__tests__/output-schema-validator.ts b/src/mcp/__tests__/output-schema-validator.ts new file mode 100644 index 0000000000..8b3fcb5886 --- /dev/null +++ b/src/mcp/__tests__/output-schema-validator.ts @@ -0,0 +1,106 @@ +import type { JsonSchema } from '../../commands/command-contract.ts'; + +/** + * A focused JSON Schema validator for the MCP `outputSchema` dialect actually + * used by COMMAND_OUTPUT_SCHEMAS: `type` (including union type arrays), `enum`, + * `const`, `required`, `properties`, `items`, and `oneOf`. It exists so the + * command-tools tests can validate representative structured content against the + * COMPLETE advertised schema — enums/consts and nested required fields — rather + * than only checking that required keys are present. + * + * It is intentionally NON-STRICT: unknown properties are allowed (mirroring the + * schemas' deliberate absence of `additionalProperties: false`, so additive + * fields such as `cost` validate). It is a test helper, not a schema generator. + */ +export function validateAgainstSchema(value: unknown, schema: JsonSchema): string[] { + return collectErrors(value, schema, '$'); +} + +/** Convenience predicate for the common "does it validate at all" assertion. */ +export function matchesSchema(value: unknown, schema: JsonSchema): boolean { + return collectErrors(value, schema, '$').length === 0; +} + +function collectErrors(value: unknown, schema: JsonSchema, path: string): string[] { + if (schema.oneOf) return oneOfErrors(value, schema.oneOf, path); + + const errors = [...constErrors(value, schema, path), ...enumErrors(value, schema, path)]; + + if (schema.type !== undefined && !matchesType(value, schema.type)) { + errors.push( + `${path}: expected type ${JSON.stringify(schema.type)}, got ${describeType(value)}`, + ); + return errors; + } + + errors.push(...objectErrors(value, schema, path), ...arrayErrors(value, schema, path)); + return errors; +} + +function oneOfErrors(value: unknown, branches: readonly JsonSchema[], path: string): string[] { + const matching = branches.filter((branch) => collectErrors(value, branch, path).length === 0); + if (matching.length === 1) return []; + return [`${path}: expected to match exactly one oneOf branch, matched ${matching.length}`]; +} + +function constErrors(value: unknown, schema: JsonSchema, path: string): string[] { + if (!('const' in schema) || value === schema.const) return []; + return [`${path}: expected const ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`]; +} + +function enumErrors(value: unknown, schema: JsonSchema, path: string): string[] { + if (!schema.enum || schema.enum.includes(value)) return []; + return [`${path}: ${JSON.stringify(value)} is not one of ${JSON.stringify(schema.enum)}`]; +} + +function objectErrors(value: unknown, schema: JsonSchema, path: string): string[] { + if (!isPlainObject(value)) return []; + const errors: string[] = []; + for (const key of schema.required ?? []) { + if (!(key in value)) errors.push(`${path}.${key}: missing required property`); + } + for (const [key, propSchema] of Object.entries(schema.properties ?? {})) { + if (key in value) errors.push(...collectErrors(value[key], propSchema, `${path}.${key}`)); + } + return errors; +} + +function arrayErrors(value: unknown, schema: JsonSchema, path: string): string[] { + if (!Array.isArray(value) || !schema.items) return []; + const items = schema.items; + return value.flatMap((item, index) => collectErrors(item, items, `${path}[${index}]`)); +} + +function matchesType(value: unknown, type: string | readonly string[]): boolean { + const types = Array.isArray(type) ? type : [type]; + return types.some((candidate) => matchesSingleType(value, candidate)); +} + +function matchesSingleType(value: unknown, type: string): boolean { + switch (type) { + case 'object': + return isPlainObject(value); + case 'array': + return Array.isArray(value); + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number'; + case 'boolean': + return typeof value === 'boolean'; + case 'null': + return value === null; + default: + return false; + } +} + +function describeType(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index 9de05dbad6..625d0f6ddf 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -3,7 +3,7 @@ import { projectedSystemCommandOutputSchemas } from '../commands/system/index.ts import type { CommandResultMap } from '../core/command-descriptor/command-result.ts'; import { booleanSchema, looseObjectSchema, stringSchema } from '../commands/command-input.ts'; import { SESSION_SURFACES } from '../contracts/session-surface.ts'; -import { DEVICE_TARGETS, PLATFORMS } from '../kernel/device.ts'; +import { DEVICE_TARGETS, PUBLIC_PLATFORMS } from '../kernel/device.ts'; /** * Registry of per-command MCP `outputSchema`s, keyed by the daemon command @@ -260,7 +260,9 @@ const settleObservationSchema: JsonSchema = objectSchema( // boot / shutdown share the resolved-device header (src/contracts/device.ts). const deviceHeaderProperties: Record = { - platform: enumSchema(PLATFORMS), + // Public leaf vocabulary (ios | macos | android | linux | web): boot/shutdown + // emit publicPlatformString, never the internal `apple` platform. + platform: enumSchema(PUBLIC_PLATFORMS), target: enumSchema(DEVICE_TARGETS), device: stringSchema('Human-readable device name.'), id: stringSchema('Stable device id.'), @@ -353,7 +355,8 @@ export const COMMAND_OUTPUT_SCHEMAS = { prepare: objectSchema( { action: constSchema('ios-runner'), - platform: enumSchema(PLATFORMS), + // PublicPlatform leaf, mirroring PrepareCommandResult (src/contracts/prepare.ts). + platform: enumSchema(PUBLIC_PLATFORMS), deviceId: stringSchema(), deviceName: stringSchema(), kind: enumSchema(DEVICE_KINDS),