fix(core): coerce numeric string params in SchemaValidator for MCP tools (#4967)
Some checks failed
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
Sync cua-driver to Aliyun OSS / Mirror cua-driver binaries to Aliyun OSS (push) Has been cancelled

* 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) <noreply@qwen.ai>

* 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 <noreply@qwen.ai>

* 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 <noreply@qwenlm.ai>

---------

Co-authored-by: Qwen-Coder (Qwen Code) <noreply@qwen.ai>
Co-authored-by: Qwen-Coder <noreply@qwenlm.ai>
This commit is contained in:
pomelo 2026-06-15 23:15:32 +08:00 committed by GitHub
parent 1d2ee34f44
commit a76183f9c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 241 additions and 0 deletions

View file

@ -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 = {

View file

@ -170,6 +170,13 @@ export class SchemaValidator {
data as Record<string, unknown>,
anySchema as Record<string, unknown>,
);
// 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<string, unknown>,
anySchema as Record<string, unknown>,
);
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<string, unknown>,
schema: Record<string, unknown>,
) {
const properties = schema['properties'] as
| Record<string, Record<string, unknown>>
| undefined;
const items = schema['items'] as Record<string, unknown> | 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<string, unknown>, 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<string, unknown>,
schema?: Record<string, unknown>,