qwen-code/packages/core/src/utils/schemaValidator.ts
pomelo a76183f9c6
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 (#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) <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>
2026-06-15 23:15:32 +08:00

378 lines
14 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import AjvPkg, { type AnySchema, type Ajv } from 'ajv';
// Ajv2020 is the documented way to use draft-2020-12: https://ajv.js.org/json-schema.html#draft-2020-12
// eslint-disable-next-line import/no-internal-modules
import Ajv2020Pkg from 'ajv/dist/2020.js';
import * as addFormats from 'ajv-formats';
import { createDebugLogger } from './debugLogger.js';
// Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const AjvClass = (AjvPkg as any).default || AjvPkg;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Ajv2020Class = (Ajv2020Pkg as any).default || Ajv2020Pkg;
const debugLogger = createDebugLogger('SchemaValidator');
const ajvOptions = {
// See: https://ajv.js.org/options.html#strict-mode-options
// strictSchema defaults to true and prevents use of JSON schemas that
// include unrecognized keywords. The JSON schema spec specifically allows
// for the use of non-standard keywords and the spec-compliant behavior
// is to ignore those keywords. Note that setting this to false also
// allows use of non-standard or custom formats (the unknown format value
// will be logged but the schema will still be considered valid).
strictSchema: false,
};
// Draft-07 validator (default)
const ajvDefault: Ajv = new AjvClass(ajvOptions);
// Draft-2020-12 validator for MCP servers using rmcp
const ajv2020: Ajv = new Ajv2020Class(ajvOptions);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const addFormatsFunc = (addFormats as any).default || addFormats;
addFormatsFunc(ajvDefault);
addFormatsFunc(ajv2020);
// Canonical draft-2020-12 meta-schema URI (used by rmcp MCP servers).
// JSON Schema authors commonly include both `…/schema` and `…/schema#`
// — the trailing `#` is an empty fragment and points at the same
// document. Normalize before comparing so either form selects ajv2020.
const DRAFT_2020_12_SCHEMA = 'https://json-schema.org/draft/2020-12/schema';
function isDraft2020Uri(uri: unknown): boolean {
if (typeof uri !== 'string') return false;
const normalized = uri.endsWith('#') ? uri.slice(0, -1) : uri;
return normalized === DRAFT_2020_12_SCHEMA;
}
/**
* Returns the appropriate validator based on schema's $schema field.
*/
function getValidator(schema: AnySchema): Ajv {
if (
typeof schema === 'object' &&
schema !== null &&
'$schema' in schema &&
isDraft2020Uri(schema.$schema)
) {
return ajv2020;
}
return ajvDefault;
}
/**
* Simple utility to validate objects against JSON Schemas.
* Supports both draft-07 (default) and draft-2020-12 schemas.
*/
export class SchemaValidator {
/**
* Strictly compiles a schema. Returns an error message if the schema is
* malformed or uses unsupported draft/features for our Ajv configuration
* (see {@link getValidator} — `$schema` selects between draft-07 and
* draft-2020-12; anything else falls through to draft-07's compiler).
* Returns null on success. Unlike {@link validate}, this does NOT
* silently skip on compile failure — callers (e.g. the CLI's
* `--json-schema` parser) need to surface invalid schemas instead of
* letting them no-op at runtime.
*/
static compileStrict(schema: unknown): string | null {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
return 'schema must be a JSON object';
}
// Use a dedicated Ajv with `strictSchema: true` 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.
//
// We deliberately do NOT pass `strict: true` (which would also
// enable `strictRequired`, `strictTypes`, etc): those rules go
// beyond JSON Schema validity and would reject spec-valid schemas
// like `{type:'object', required:['x']}` (no matching `properties`)
// or anything using a custom `format`. Keep typo detection;
// tolerate the looser-but-still-spec-valid patterns users actually
// ship in `--json-schema`.
const strictOptions = {
strictSchema: true, // catches unknown keywords (typos)
strictRequired: false, // allow `required` without `properties`
strictTypes: false, // allow inferred / partial type info
validateFormats: false, // unknown `format` values don't fail
allowUnionTypes: true, // type: ["a","b"]
};
const strictAjv: Ajv = isDraft2020Uri(
(schema as { $schema?: unknown }).$schema,
)
? new Ajv2020Class(strictOptions)
: new AjvClass(strictOptions);
addFormatsFunc(strictAjv);
try {
strictAjv.compile(schema as AnySchema);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
/**
* Returns null if the data conforms to the schema described by schema (or if schema
* is null). Otherwise, returns a string describing the error.
*/
static validate(schema: unknown | undefined, data: unknown): string | null {
if (!schema) {
return null;
}
if (typeof data !== 'object' || data === null) {
return 'Value of params must be an object';
}
const anySchema = schema as AnySchema;
const validator = getValidator(anySchema);
// Try to compile and validate; skip validation if schema can't be compiled.
// This handles schemas using JSON Schema versions AJV doesn't support
// (e.g., draft-2019-09, future versions).
// This matches LenientJsonSchemaValidator behavior in mcp-client.ts.
let validate;
try {
validate = validator.compile(anySchema);
} catch (error) {
// Schema compilation failed (unsupported version, invalid $ref, etc.)
// Skip validation rather than blocking tool usage.
debugLogger.warn(
`Failed to compile schema (${
(schema as Record<string, unknown>)?.['$schema'] ?? '<no $schema>'
}): ${error instanceof Error ? error.message : String(error)}. ` +
'Skipping parameter validation.',
);
return null;
}
let valid = validate(data);
if (!valid && validate.errors) {
// Coerce string boolean values ("true"/"false") to actual booleans
fixBooleanValues(
data as Record<string, unknown>,
anySchema as Record<string, unknown>,
);
// Coerce stringified JSON values (arrays/objects) back to their proper types.
// Some LLMs serialize complex values as strings when the schema uses
// anyOf/oneOf (e.g., '["url"]' instead of ["url"] for anyOf: [array, null]).
fixStringifiedJsonValues(
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) {
return validator.errorsText(validate.errors, { dataVar: 'params' });
}
}
return null;
}
}
/**
* Coerces string boolean values to actual booleans.
* This handles cases where LLMs return "true"/"false" strings instead of boolean values,
* which is common with self-hosted LLMs.
*
* Converts:
* - "true", "True", "TRUE" -> true
* - "false", "False", "FALSE" -> false
*/
/**
* Returns the set of JSON Schema types that a property accepts,
* considering `type`, `anyOf`, and `oneOf` keywords.
*/
function getAcceptedTypes(
propSchema: Record<string, unknown>,
): Set<string> | null {
const types = new Set<string>();
if (typeof propSchema['type'] === 'string') {
types.add(propSchema['type'] as string);
} else if (Array.isArray(propSchema['type'])) {
for (const t of propSchema['type'] as string[]) {
types.add(t);
}
}
for (const keyword of ['anyOf', 'oneOf']) {
const variants = propSchema[keyword];
if (Array.isArray(variants)) {
for (const variant of variants as Array<Record<string, unknown>>) {
if (typeof variant['type'] === 'string') {
types.add(variant['type'] as string);
} else if (Array.isArray(variant['type'])) {
for (const t of variant['type'] as string[]) {
types.add(t);
}
}
}
}
}
return types.size > 0 ? types : null;
}
/**
* Coerces stringified JSON values back to their proper types.
* Some LLMs serialize arrays/objects as JSON strings when the schema uses
* anyOf/oneOf with mixed types (e.g., `list[str] | None` in Python becomes
* `anyOf: [{type: "array"}, {type: "null"}]`). The model may return
* '["url"]' (a string) instead of ["url"] (an actual array).
*
* This function parses such strings back to their intended type when:
* 1. The value is a string starting with `[` or `{`
* 2. The schema accepts array or object but not string
* 3. The parsed result matches one of the accepted types
*/
function fixStringifiedJsonValues(
data: Record<string, unknown>,
schema: Record<string, unknown>,
) {
const properties = schema['properties'] as
| Record<string, Record<string, unknown>>
| undefined;
if (!properties) return;
for (const key of Object.keys(data)) {
const value = data[key];
const propSchema = properties[key];
if (!propSchema || typeof value !== 'string') continue;
const trimmed = value.trim();
if (
(trimmed.startsWith('[') && trimmed.endsWith(']')) ||
(trimmed.startsWith('{') && trimmed.endsWith('}'))
) {
const accepted = getAcceptedTypes(propSchema);
if (!accepted) continue;
// Only coerce if the schema does NOT accept string — otherwise the
// string value may be intentional.
if (accepted.has('string')) continue;
if (!accepted.has('array') && !accepted.has('object')) continue;
try {
const parsed = JSON.parse(trimmed);
const parsedType = Array.isArray(parsed) ? 'array' : typeof parsed;
if (accepted.has(parsedType)) {
data[key] = parsed;
}
} catch {
// Not valid JSON — leave the value unchanged
}
}
}
}
/**
* 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>,
) {
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];
// Array elements share the `items` schema; object fields use their
// per-property schema.
const childSchema = Array.isArray(data) ? items : properties?.[key];
if (typeof value === 'object' && value !== null) {
fixBooleanValues(value as Record<string, unknown>, childSchema);
continue;
}
if (typeof value !== 'string') continue;
// Only coerce when the field's schema explicitly types it as boolean (and
// does not also accept string). Without this guard a legitimate string
// value of "true"/"false" — e.g. an `old_string`/`content` argument that
// happens to be the text "true" — would be silently rewritten into a
// boolean, corrupting the tool call. Mirrors fixStringifiedJsonValues,
// which is already schema-aware for the same reason.
const accepted = childSchema ? getAcceptedTypes(childSchema) : null;
if (!accepted || accepted.has('string') || !accepted.has('boolean')) {
continue;
}
const lower = value.toLowerCase();
if (lower === 'true') {
data[key] = true;
} else if (lower === 'false') {
data[key] = false;
}
}
}