mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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
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:
parent
1d2ee34f44
commit
a76183f9c6
2 changed files with 241 additions and 0 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue