From a76183f9c6fd5a27e276e01c4e77f7fbf16ce73d Mon Sep 17 00:00:00 2001 From: pomelo Date: Mon, 15 Jun 2026 23:15:32 +0800 Subject: [PATCH] fix(core): coerce numeric string params in SchemaValidator for MCP tools (#4967) * fix(core): coerce numeric string params in SchemaValidator for MCP tools LLMs frequently emit numeric parameters as strings (e.g. `{"depth": "3"}` instead of `{"depth": 3}`), which strict MCP servers like Playwright reject with schema validation errors ("params/depth must be number"). The SchemaValidator already coerces boolean strings and stringified JSON but was missing numeric coercion. Add `fixNumericValues()` that converts string values to integers/numbers when the schema expects integer/number but does not accept string, consistent with the existing `fixBooleanValues` pattern. Co-Authored-By: Qwen-Coder (Qwen Code) * fix(core): prevent silent truncation in numeric string coercion - Skip coercion for decimal strings when schema only accepts integer - Prefer parseFloat when number type is accepted (fixes anyOf[integer, number]) - Add tests for integer-only decimal rejection, anyOf coercion, and array elements Co-Authored-By: Qwen-Coder * fix(core): coerce whole-number decimal strings for integer schemas Replace the syntactic `hasDecimal` guard with a semantic `num % 1 !== 0` check so whole-number decimal strings ("3.0", "-10.0") coerce to integers while genuine non-integers ("5.5") are still left for the LLM to self-correct. Python-based models commonly serialize float(3) as "3.0". Co-Authored-By: Qwen-Coder --------- Co-authored-by: Qwen-Coder (Qwen Code) Co-authored-by: Qwen-Coder --- .../core/src/utils/schemaValidator.test.ts | 182 ++++++++++++++++++ packages/core/src/utils/schemaValidator.ts | 59 ++++++ 2 files changed, 241 insertions(+) diff --git a/packages/core/src/utils/schemaValidator.test.ts b/packages/core/src/utils/schemaValidator.test.ts index 40fb96483e..fe42bf64d5 100644 --- a/packages/core/src/utils/schemaValidator.test.ts +++ b/packages/core/src/utils/schemaValidator.test.ts @@ -391,6 +391,188 @@ describe('SchemaValidator', () => { }); }); + describe('numeric string coercion', () => { + it('should coerce string "3" to integer 3', () => { + const schema = { + type: 'object', + properties: { + depth: { type: 'integer' }, + }, + required: ['depth'], + }; + const params = { depth: '3' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.depth).toBe(3); + }); + + it('should coerce string "5.5" to number 5.5', () => { + const schema = { + type: 'object', + properties: { + timeout: { type: 'number' }, + }, + required: ['timeout'], + }; + const params = { timeout: '5.5' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.timeout).toBe(5.5); + }); + + it('should coerce negative numeric strings', () => { + const schema = { + type: 'object', + properties: { + offset: { type: 'integer' }, + }, + required: ['offset'], + }; + const params = { offset: '-10' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.offset).toBe(-10); + }); + + it('should not coerce non-numeric strings', () => { + const schema = { + type: 'object', + properties: { + count: { type: 'integer' }, + }, + required: ['count'], + }; + const params = { count: 'abc' }; + expect(SchemaValidator.validate(schema, params)).not.toBeNull(); + }); + + it('should not coerce when schema also accepts string', () => { + const schema = { + type: 'object', + properties: { + value: { anyOf: [{ type: 'string' }, { type: 'integer' }] }, + }, + required: ['value'], + }; + const params = { value: '42' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + // Should remain a string since string is accepted + expect(params.value).toBe('42'); + }); + + it('should coerce numeric strings in nested objects', () => { + const schema = { + type: 'object', + properties: { + options: { + type: 'object', + properties: { + retries: { type: 'integer' }, + }, + }, + }, + }; + const params = { options: { retries: '3' } }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect((params.options as unknown as { retries: number }).retries).toBe( + 3, + ); + }); + + it('should not affect actual number values', () => { + const schema = { + type: 'object', + properties: { + depth: { type: 'integer' }, + }, + required: ['depth'], + }; + const params = { depth: 3 }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.depth).toBe(3); + }); + + it('should not corrupt string fields with numeric-looking values', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + count: { type: 'integer' }, + }, + required: ['name', 'count'], + }; + const params = { name: '42', count: '7' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.name).toBe('42'); + expect(params.count).toBe(7); + }); + + it('should work with draft-2020-12 schema (MCP servers)', () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + time: { type: 'number' }, + }, + required: ['time'], + }; + const params = { time: '5' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.time).toBe(5); + }); + + it('should not coerce decimal string for integer-only schema', () => { + const schema = { + type: 'object', + properties: { + count: { type: 'integer' }, + }, + required: ['count'], + }; + const params = { count: '5.5' }; + // Should NOT coerce — let validation fail so LLM self-corrects + expect(SchemaValidator.validate(schema, params)).not.toBeNull(); + expect(params.count).toBe('5.5'); + }); + + it('should coerce decimal string when number is accepted via anyOf', () => { + const schema = { + type: 'object', + properties: { + value: { anyOf: [{ type: 'integer' }, { type: 'number' }] }, + }, + required: ['value'], + }; + const params = { value: '5.5' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.value).toBe(5.5); + }); + + it('should coerce whole-number decimal string to integer (e.g. "3.0")', () => { + const schema = { + type: 'object', + properties: { + depth: { type: 'integer' }, + }, + required: ['depth'], + }; + const params = { depth: '3.0' }; + // "3.0" represents an integer — coerce it rather than rejecting. + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.depth).toBe(3); + }); + + it('should coerce numeric strings inside arrays of integers', () => { + const schema = { + type: 'object', + properties: { + ports: { type: 'array', items: { type: 'integer' } }, + }, + required: ['ports'], + }; + const params = { ports: ['8080', '3000'] }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.ports).toEqual([8080, 3000]); + }); + }); + describe('JSON Schema version support', () => { it('should support JSON Schema draft-2020-12', () => { const schema = { diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index 794fd10527..0af751c43a 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -170,6 +170,13 @@ export class SchemaValidator { data as Record, anySchema as Record, ); + // Coerce numeric strings ("3", "5.0") to actual numbers when the schema + // expects integer/number. LLMs frequently emit numeric parameters as + // strings which strict MCP servers (e.g. Playwright) reject. + fixNumericValues( + data as Record, + anySchema as Record, + ); valid = validate(data); if (!valid && validate.errors) { @@ -275,6 +282,58 @@ function fixStringifiedJsonValues( } } +/** + * Coerces string numeric values to actual numbers. + * LLMs frequently emit numeric parameters as strings (e.g. `{"depth": "3"}`) + * which strict MCP servers (e.g. Playwright) reject with schema validation + * errors like "params/depth must be number". + * + * Only coerces when: + * 1. The value is a string that looks like a clean number + * 2. The schema accepts integer/number but NOT string + */ +function fixNumericValues( + data: Record, + schema: Record, +) { + const properties = schema['properties'] as + | Record> + | undefined; + const items = schema['items'] as Record | undefined; + + for (const key of Object.keys(data)) { + if (!(key in data)) continue; + const value = data[key]; + const childSchema = Array.isArray(data) ? items : properties?.[key]; + + if (typeof value === 'object' && value !== null) { + fixNumericValues(value as Record, childSchema ?? {}); + continue; + } + + if (typeof value !== 'string') continue; + + const accepted = childSchema ? getAcceptedTypes(childSchema) : null; + if (!accepted || accepted.has('string')) continue; + const wantsInteger = accepted.has('integer'); + const wantsNumber = accepted.has('number'); + if (!wantsInteger && !wantsNumber) continue; + + const trimmed = value.trim(); + if (!/^-?\d+(\.\d+)?$/.test(trimmed)) continue; + + const num = parseFloat(trimmed); + // Reject non-integer values (e.g. "5.5") for integer-only schemas so the + // LLM self-corrects. Whole-number decimals (e.g. "3.0") still coerce. + if (wantsInteger && !wantsNumber && num % 1 !== 0) continue; + + const parsed = wantsNumber ? num : parseInt(trimmed, 10); + if (Number.isFinite(parsed)) { + data[key] = parsed; + } + } +} + function fixBooleanValues( data: Record, schema?: Record,