diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a4b55cac8..ebed6ebbd 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -231,6 +231,19 @@ export function resolveJsonSchemaArg( ); } + // The schema becomes the parameter schema of the synthetic + // structured_output tool, and tool-call arguments are object-shaped. + // A schema like `{"type":"string"}` would compile fine but be + // unsatisfiable as a tool-call argument — fail at parse time so the + // user sees the contract violation immediately instead of at runtime. + const schemaType = (parsed as { type?: unknown }).type; + if (schemaType !== undefined && schemaType !== 'object') { + throw new FatalConfigError( + `--json-schema top-level type must be "object" (got "${String(schemaType)}"); ` + + 'wrap your value under an object property if you need a non-object payload.', + ); + } + return parsed as Record; } @@ -540,7 +553,7 @@ export async function parseArguments(): Promise { .option('json-schema', { type: 'string', description: - 'JSON Schema that the model\'s final output must conform to ' + + "JSON Schema that the model's final output must conform to " + '(headless mode only). Accepts a JSON literal or "@path/to/schema.json". ' + 'Registers a synthetic `structured_output` tool; the session ends on ' + 'the first valid call.', diff --git a/packages/cli/src/config/jsonSchemaArg.test.ts b/packages/cli/src/config/jsonSchemaArg.test.ts index 87b700d55..f507cb34e 100644 --- a/packages/cli/src/config/jsonSchemaArg.test.ts +++ b/packages/cli/src/config/jsonSchemaArg.test.ts @@ -42,9 +42,7 @@ describe('resolveJsonSchemaArg', () => { }); it('throws on invalid JSON', () => { - expect(() => resolveJsonSchemaArg('{not json}')).toThrow( - /not valid JSON/, - ); + expect(() => resolveJsonSchemaArg('{not json}')).toThrow(/not valid JSON/); }); it('throws when the parsed value is not an object', () => { @@ -78,4 +76,15 @@ describe('resolveJsonSchemaArg', () => { ); expect(schema).toBeDefined(); }); + + it('rejects schemas whose top-level type is not "object"', () => { + // The schema becomes structured_output's parameter schema; tool args + // are object-shaped, so non-object roots can never be satisfied. + expect(() => resolveJsonSchemaArg('{"type":"string"}')).toThrow( + /top-level type must be "object"/, + ); + expect(() => resolveJsonSchemaArg('{"type":"array"}')).toThrow( + /top-level type must be "object"/, + ); + }); }); diff --git a/packages/core/src/utils/schemaValidator.test.ts b/packages/core/src/utils/schemaValidator.test.ts index 98b05b1ca..9d32ff38a 100644 --- a/packages/core/src/utils/schemaValidator.test.ts +++ b/packages/core/src/utils/schemaValidator.test.ts @@ -478,5 +478,28 @@ describe('SchemaValidator', () => { expect(SchemaValidator.compileStrict(undefined)).toMatch(/JSON object/); expect(SchemaValidator.compileStrict('a string')).toMatch(/JSON object/); }); + + it('rejects arrays even though typeof === "object"', () => { + // Arrays satisfy `typeof === 'object'` but are not valid JSON Schema + // root values; the prior guard accepted them and let the misleading + // error surface from Ajv much later. + expect(SchemaValidator.compileStrict([])).toMatch(/JSON object/); + expect(SchemaValidator.compileStrict([{ type: 'string' }])).toMatch( + /JSON object/, + ); + }); + + it('flags unknown keywords (typos) under strict mode', () => { + // The shared SchemaValidator.validate is intentionally lenient + // (`strictSchema: false`) so MCP-style custom keywords don't break + // runtime validation. compileStrict is the explicit user-supplied + // surface and should NOT swallow typos like `propertees`. + const err = SchemaValidator.compileStrict({ + type: 'object', + propertees: { foo: { type: 'string' } }, + }); + expect(err).not.toBeNull(); + expect(err).toMatch(/propert/i); + }); }); }); diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index 55395d66b..f451e238c 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -72,12 +72,24 @@ export class SchemaValidator { * to surface invalid schemas instead of letting them no-op at runtime. */ static compileStrict(schema: unknown): string | null { - if (!schema || typeof schema !== 'object') { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { return 'schema must be a JSON object'; } - const validator = getValidator(schema as AnySchema); + // Use a dedicated strict-mode Ajv so typos like `propertees` raise + // instead of being silently ignored. The shared ajvDefault/ajv2020 + // instances run with `strictSchema: false` so unknown MCP keywords + // don't break runtime validation — that leniency is wrong for + // explicit user-supplied schemas where `compileStrict` is exactly + // the surface meant to surface mistakes. + const isDraft2020 = + '$schema' in schema && + (schema as { $schema?: string }).$schema === DRAFT_2020_12_SCHEMA; + const strictAjv: Ajv = isDraft2020 + ? new Ajv2020Class({ strict: true }) + : new AjvClass({ strict: true }); + addFormatsFunc(strictAjv); try { - validator.compile(schema as AnySchema); + strictAjv.compile(schema as AnySchema); return null; } catch (error) { return error instanceof Error ? error.message : String(error);