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
131 changes: 131 additions & 0 deletions src/mcp/__tests__/command-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
106 changes: 106 additions & 0 deletions src/mcp/__tests__/output-schema-validator.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
9 changes: 6 additions & 3 deletions src/mcp/command-output-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -260,7 +260,9 @@ const settleObservationSchema: JsonSchema = objectSchema(

// boot / shutdown share the resolved-device header (src/contracts/device.ts).
const deviceHeaderProperties: Record<string, JsonSchema> = {
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.'),
Expand Down Expand Up @@ -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),
Expand Down
Loading