Merge pull request #497 from kaitranntt/kai/fix/155-gemini-schema-compat

fix(cliproxy): strip Gemini-unsupported schema fields including "examples"
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-11 01:32:42 +07:00
committed by GitHub
3 changed files with 331 additions and 264 deletions
+46 -152
View File
@@ -1,78 +1,60 @@
/**
* Schema Sanitizer
*
* Sanitizes MCP tool input_schema to remove non-standard JSON Schema properties
* that Gemini/Vertex APIs reject.
* Sanitizes tool input_schema to only include fields supported by Google's
* Gemini/Vertex AI function_declarations Schema object.
*
* MCP servers (especially design tools) include UI-specific metadata in schemas:
* - cornerRadius, fillColor, fontFamily, fontSize, fontWeight, gap, padding, etc.
* This sanitizer runs exclusively in the CLIProxy execution path (Gemini, Codex,
* Antigravity, etc.), never for direct Anthropic API requests.
*
* These are valid as MCP hints but invalid for strict JSON Schema validation.
* Gemini supports a subset of OpenAPI 3.0 schema — fields outside this subset
* (like "examples", "$ref", "oneOf", etc.) cause 400 errors.
*
* Reference: https://ai.google.dev/api/rest/v1beta/cachedContents#Schema
*/
/** Valid JSON Schema Draft-07 keywords (used by Anthropic/Gemini APIs) */
const VALID_JSON_SCHEMA_KEYWORDS = new Set([
/**
* Fields supported by Gemini's function_declarations Schema object.
* Source: https://ai.google.dev/api/rest/v1beta/cachedContents#Schema
*/
const GEMINI_SUPPORTED_SCHEMA_FIELDS = new Set([
// Core
'type',
'format',
'title',
'description',
'nullable',
'example',
'default',
// Enum
'enum',
// Object
'properties',
'required',
'minProperties',
'maxProperties',
// Array
'items',
'enum',
'const',
'default',
'minItems',
'maxItems',
// String validation
'minLength',
'maxLength',
'pattern',
'format',
// Number validation
'minimum',
'maximum',
'exclusiveMinimum',
'exclusiveMaximum',
'multipleOf',
// Array validation
'minItems',
'maxItems',
'uniqueItems',
'contains',
'additionalItems',
// Object validation
'additionalProperties',
'patternProperties',
'minProperties',
'maxProperties',
'propertyNames',
'dependencies',
// Composition
'oneOf',
// Composition (only anyOf supported, NOT oneOf/allOf/not)
'anyOf',
'allOf',
'not',
'if',
'then',
'else',
// Metadata (allowed by most APIs)
'title',
'description',
'$id',
'$schema',
'$ref',
'$defs',
'definitions',
'$comment',
'examples',
'readOnly',
'writeOnly',
'deprecated',
'contentMediaType',
'contentEncoding',
// Gemini-specific (non-standard OpenAPI)
'propertyOrdering',
]);
/** Maximum recursion depth to prevent stack overflow */
@@ -88,15 +70,15 @@ export interface SchemaSanitizationResult {
}
/**
* Check if a key is a valid JSON Schema keyword.
* Check if a key is supported by Gemini's Schema object.
*/
function isValidSchemaKey(key: string): boolean {
return VALID_JSON_SCHEMA_KEYWORDS.has(key);
return GEMINI_SUPPORTED_SCHEMA_FIELDS.has(key);
}
/**
* Recursively sanitize a JSON Schema object.
* Removes non-standard properties while preserving valid schema structure.
* Removes fields unsupported by Gemini while preserving valid schema structure.
*
* @param schema The schema object to sanitize
* @param path Current path for logging (e.g., "properties.foo.items")
@@ -167,115 +149,27 @@ function sanitizeSchemaRecursive(
continue;
}
if (key === 'additionalProperties' && typeof value === 'object') {
// Can be boolean or schema object
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
if (key === 'additionalItems' && typeof value === 'object' && value !== null) {
// Can be boolean or schema object (tuple validation)
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
// Composition keywords contain schema arrays
if (['oneOf', 'anyOf', 'allOf'].includes(key) && Array.isArray(value)) {
// anyOf is the only composition keyword Gemini supports
if (key === 'anyOf' && Array.isArray(value)) {
result[key] = value.map((item, index) =>
sanitizeSchemaRecursive(item, `${keyPath}[${index}]`, removedPaths, visited, depth + 1)
);
continue;
}
if (key === 'not' && typeof value === 'object') {
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
if (key === 'propertyNames' && typeof value === 'object') {
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
// Conditional keywords
if (['if', 'then', 'else'].includes(key) && typeof value === 'object') {
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
if (key === 'contains' && typeof value === 'object') {
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
if (key === '$defs' || key === 'definitions') {
// Definition containers
if (typeof value === 'object' && value !== null) {
const sanitizedDefs: Record<string, unknown> = {};
for (const [defName, defSchema] of Object.entries(value as Record<string, unknown>)) {
sanitizedDefs[defName] = sanitizeSchemaRecursive(
defSchema,
`${keyPath}.${defName}`,
removedPaths,
visited,
depth + 1
);
}
result[key] = sanitizedDefs;
continue;
}
}
if (key === 'patternProperties') {
// Pattern property containers - preserve pattern keys and sanitize schema values
if (typeof value === 'object' && value !== null) {
const sanitizedPatterns: Record<string, unknown> = {};
for (const [pattern, patternSchema] of Object.entries(value as Record<string, unknown>)) {
sanitizedPatterns[pattern] = sanitizeSchemaRecursive(
patternSchema,
`${keyPath}.${pattern}`,
removedPaths,
visited,
depth + 1
);
}
result[key] = sanitizedPatterns;
continue;
}
}
if (key === 'dependencies') {
if (typeof value === 'object' && value !== null) {
const sanitizedDeps: Record<string, unknown> = {};
for (const [depName, depValue] of Object.entries(value as Record<string, unknown>)) {
// Schema dependencies need recursion, property dependencies (arrays) pass through
if (typeof depValue === 'object' && depValue !== null && !Array.isArray(depValue)) {
sanitizedDeps[depName] = sanitizeSchemaRecursive(
depValue,
`${keyPath}.${depName}`,
removedPaths,
visited,
depth + 1
);
} else {
sanitizedDeps[depName] = depValue;
}
}
result[key] = sanitizedDeps;
continue;
}
}
// Check if this is a valid JSON Schema keyword
// Check if this is a Gemini-supported field
if (isValidSchemaKey(key)) {
// Recurse for nested objects that might contain schemas
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// "example" and "default" hold arbitrary values, not nested schemas — pass through as-is
if (key === 'example' || key === 'default') {
result[key] = value;
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Recurse for nested objects that might contain schemas
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
} else {
result[key] = value;
}
} else {
// Non-standard property - remove it
// Unsupported field — remove it
removedPaths.push(keyPath);
}
}
@@ -284,7 +178,7 @@ function sanitizeSchemaRecursive(
}
/**
* Sanitize an input_schema object, removing non-standard JSON Schema properties.
* Sanitize an input_schema object, removing fields unsupported by Gemini.
*
* @param inputSchema The tool's input_schema object
* @returns Sanitization result with cleaned schema and metadata
+1 -1
View File
@@ -218,7 +218,7 @@ export class ToolSanitizationProxy {
for (const entry of schemaResult.removedByTool) {
this.writeLog(
'warn',
`[tool-sanitization-proxy] Schema sanitized for "${entry.name}": removed ${entry.removed.length} non-standard properties`
`[tool-sanitization-proxy] Schema sanitized for "${entry.name}": removed ${entry.removed.length} Gemini-unsupported properties`
);
}
this.log(
+284 -111
View File
@@ -1,14 +1,15 @@
/**
* Schema Sanitizer Unit Tests
*
* Tests for MCP tool input_schema sanitization.
* Tests for Gemini-compatible tool input_schema sanitization.
* Verifies that only Gemini-supported fields are preserved.
*/
import { describe, expect, test } from 'bun:test';
import { sanitizeInputSchema, sanitizeToolSchemas } from '../../../dist/cliproxy/schema-sanitizer.js';
describe('sanitizeInputSchema', () => {
test('preserves valid JSON Schema properties', () => {
test('preserves Gemini-supported properties', () => {
const schema = {
type: 'object',
properties: {
@@ -110,10 +111,10 @@ describe('sanitizeInputSchema', () => {
});
});
test('handles oneOf/anyOf/allOf with nested schemas', () => {
test('preserves anyOf and sanitizes nested schemas', () => {
const schema = {
type: 'object',
oneOf: [
anyOf: [
{ type: 'string', customProp: 'remove' },
{ type: 'number', anotherCustom: 123 },
],
@@ -124,10 +125,25 @@ describe('sanitizeInputSchema', () => {
expect(result.removedCount).toBe(2);
expect(result.schema).toEqual({
type: 'object',
oneOf: [{ type: 'string' }, { type: 'number' }],
anyOf: [{ type: 'string' }, { type: 'number' }],
});
});
test('removes oneOf and allOf (not supported by Gemini)', () => {
const schema = {
type: 'object',
oneOf: [{ type: 'string' }, { type: 'number' }],
allOf: [{ required: ['name'] }],
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(2);
expect(result.removedPaths).toContain('oneOf');
expect(result.removedPaths).toContain('allOf');
expect(result.schema).toEqual({ type: 'object' });
});
test('handles deeply nested structures', () => {
const schema = {
type: 'object',
@@ -153,51 +169,52 @@ describe('sanitizeInputSchema', () => {
expect(result.removedPaths).toContain('properties.level1.properties.level2.uiHint');
});
test('preserves $defs and definitions', () => {
test('strips "examples" field (issue #155)', () => {
const schema = {
type: 'object',
$defs: {
address: {
type: 'object',
customUI: 'remove',
properties: {
street: { type: 'string' },
},
},
},
properties: {
home: { $ref: '#/$defs/address' },
command: {
type: 'string',
description: 'The command to execute',
examples: ['ls -la', 'git status'],
},
timeout: {
type: 'number',
description: 'Timeout in ms',
examples: [5000, 10000],
},
},
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('$defs.address.customUI');
expect(result.removedCount).toBe(2);
expect(result.removedPaths).toContain('properties.command.examples');
expect(result.removedPaths).toContain('properties.timeout.examples');
expect(result.schema).toEqual({
type: 'object',
$defs: {
address: {
type: 'object',
properties: {
street: { type: 'string' },
},
},
},
properties: {
home: { $ref: '#/$defs/address' },
command: { type: 'string', description: 'The command to execute' },
timeout: { type: 'number', description: 'Timeout in ms' },
},
});
});
test('handles empty schema', () => {
const result = sanitizeInputSchema({});
test('keeps "example" (singular) but strips "examples" (plural)', () => {
const schema = {
type: 'string',
example: 'hello',
examples: ['hello', 'world'],
};
expect(result.removedCount).toBe(0);
expect(result.schema).toEqual({});
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('examples');
expect(result.schema).toEqual({ type: 'string', example: 'hello' });
});
test('preserves all standard metadata keywords', () => {
test('strips Gemini-unsupported JSON Schema metadata fields', () => {
const schema = {
$id: 'https://example.com/schema',
$schema: 'https://json-schema.org/draft-07/schema#',
@@ -212,95 +229,187 @@ describe('sanitizeInputSchema', () => {
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(0);
expect(result.schema).toEqual(schema);
// $id, $schema, examples, deprecated, readOnly, $comment = 6 removed
expect(result.removedCount).toBe(6);
expect(result.removedPaths).toContain('$id');
expect(result.removedPaths).toContain('$schema');
expect(result.removedPaths).toContain('examples');
expect(result.removedPaths).toContain('deprecated');
expect(result.removedPaths).toContain('readOnly');
expect(result.removedPaths).toContain('$comment');
expect(result.schema).toEqual({
title: 'Test Schema',
description: 'A test schema',
type: 'string',
});
});
test('preserves additionalItems keyword', () => {
const schema = {
type: 'array',
items: [{ type: 'string' }],
additionalItems: { type: 'boolean' },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(0);
expect(result.schema.additionalItems).toEqual({ type: 'boolean' });
});
test('sanitizes nested schemas in additionalItems', () => {
const schema = {
type: 'array',
items: [{ type: 'string' }],
additionalItems: { type: 'boolean', customProp: 'remove' },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('additionalItems.customProp');
expect(result.schema.additionalItems).toEqual({ type: 'boolean' });
});
test('preserves if/then/else and sanitizes nested schemas', () => {
const schema = {
if: { properties: { type: { const: 'foo' } }, uiHint: 'remove' },
then: { required: ['foo'], badProp: 123 },
else: { required: ['bar'] },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(2);
expect(result.schema.if).toEqual({ properties: { type: { const: 'foo' } } });
expect(result.schema.then).toEqual({ required: ['foo'] });
});
test('preserves patternProperties keyword and sanitizes nested schemas', () => {
test('strips $defs, $ref, definitions (not supported by Gemini)', () => {
const schema = {
type: 'object',
patternProperties: { '^S_': { type: 'string', customProp: 'remove' } },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('patternProperties.^S_.customProp');
expect(result.schema.patternProperties).toEqual({ '^S_': { type: 'string' } });
});
test('preserves contains keyword and sanitizes nested schema', () => {
const schema = {
type: 'array',
contains: { type: 'number', minimum: 5, customProp: 'remove' },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('contains.customProp');
expect(result.schema.contains).toEqual({ type: 'number', minimum: 5 });
});
test('preserves propertyNames keyword and sanitizes nested schema', () => {
const schema = {
type: 'object',
propertyNames: { type: 'string', pattern: '^[a-z]+$', customProp: 'remove' },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('propertyNames.customProp');
expect(result.schema.propertyNames).toEqual({ type: 'string', pattern: '^[a-z]+$' });
});
test('handles dependencies with both property and schema dependencies', () => {
const schema = {
type: 'object',
dependencies: {
bar: ['foo'], // property dependency (array) - pass through
baz: { properties: { qux: { type: 'string' } }, customProp: 'remove' }, // schema dependency
$defs: {
address: {
type: 'object',
properties: { street: { type: 'string' } },
},
},
properties: {
home: { $ref: '#/$defs/address' },
},
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('dependencies.baz.customProp');
expect(result.schema.dependencies).toEqual({
bar: ['foo'],
baz: { properties: { qux: { type: 'string' } } },
expect(result.removedCount).toBe(2);
expect(result.removedPaths).toContain('$defs');
expect(result.removedPaths).toContain('properties.home.$ref');
expect(result.schema).toEqual({
type: 'object',
properties: {
home: {},
},
});
});
test('strips additionalProperties (not supported by Gemini)', () => {
const schema = {
type: 'object',
properties: { name: { type: 'string' } },
additionalProperties: false,
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('additionalProperties');
});
test('strips const, not, if/then/else (not supported by Gemini)', () => {
const schema = {
type: 'object',
const: 'fixed',
not: { type: 'number' },
if: { properties: { type: { const: 'foo' } } },
then: { required: ['foo'] },
else: { required: ['bar'] },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(5);
expect(result.removedPaths).toContain('const');
expect(result.removedPaths).toContain('not');
expect(result.removedPaths).toContain('if');
expect(result.removedPaths).toContain('then');
expect(result.removedPaths).toContain('else');
expect(result.schema).toEqual({ type: 'object' });
});
test('strips number/array/object validation fields unsupported by Gemini', () => {
const schema = {
type: 'object',
properties: {
score: {
type: 'number',
exclusiveMinimum: 0,
exclusiveMaximum: 100,
multipleOf: 5,
},
tags: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
contains: { type: 'string' },
additionalItems: false,
},
meta: {
type: 'object',
writeOnly: true,
definitions: { addr: { type: 'string' } },
},
},
};
const result = sanitizeInputSchema(schema);
expect(result.removedPaths).toContain('properties.score.exclusiveMinimum');
expect(result.removedPaths).toContain('properties.score.exclusiveMaximum');
expect(result.removedPaths).toContain('properties.score.multipleOf');
expect(result.removedPaths).toContain('properties.tags.uniqueItems');
expect(result.removedPaths).toContain('properties.tags.contains');
expect(result.removedPaths).toContain('properties.tags.additionalItems');
expect(result.removedPaths).toContain('properties.meta.writeOnly');
expect(result.removedPaths).toContain('properties.meta.definitions');
expect(result.removedCount).toBe(8);
});
test('preserves example/default with complex values without recursion', () => {
const schema = {
type: 'object',
example: ['value1', 'value2'],
default: { nested: { deep: { unsupportedKey: true } } },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(0);
expect(result.schema.example).toEqual(['value1', 'value2']);
expect(result.schema.default).toEqual({ nested: { deep: { unsupportedKey: true } } });
});
test('handles empty properties and anyOf edge cases', () => {
const schema = {
type: 'object',
properties: {},
anyOf: [],
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(0);
expect(result.schema.properties).toEqual({});
expect(result.schema.anyOf).toEqual([]);
});
test('preserves all Gemini-supported fields', () => {
const schema = {
type: 'object',
format: 'date',
title: 'Test',
description: 'A test',
nullable: true,
example: { name: 'test' },
default: {},
enum: ['a', 'b'],
properties: { name: { type: 'string' } },
required: ['name'],
minProperties: 1,
maxProperties: 10,
items: { type: 'string' },
minItems: 0,
maxItems: 100,
minLength: 1,
maxLength: 255,
pattern: '^[a-z]+$',
minimum: 0,
maximum: 100,
anyOf: [{ type: 'string' }],
propertyOrdering: ['name'],
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(0);
expect(result.removedPaths).toEqual([]);
});
test('handles empty schema', () => {
const result = sanitizeInputSchema({});
expect(result.removedCount).toBe(0);
expect(result.schema).toEqual({});
});
test('handles null input gracefully', () => {
const result = sanitizeInputSchema(null as any);
expect(result.schema).toBeNull();
@@ -361,6 +470,70 @@ describe('sanitizeToolSchemas', () => {
});
});
test('strips examples from Claude Code tool schemas (issue #155)', () => {
const tools = [
{
name: 'Bash',
input_schema: {
type: 'object',
properties: {
command: {
type: 'string',
description: 'The command to execute',
examples: ['ls -la', 'git status', 'npm install'],
},
timeout: {
type: 'number',
description: 'Timeout in ms',
examples: [5000, 30000],
},
},
required: ['command'],
},
},
{
name: 'Read',
input_schema: {
type: 'object',
properties: {
file_path: {
type: 'string',
description: 'File path to read',
examples: ['/src/index.ts', '/package.json'],
},
},
required: ['file_path'],
},
},
];
const result = sanitizeToolSchemas(tools);
expect(result.totalRemoved).toBe(3);
expect(result.removedByTool).toHaveLength(2);
// Verify examples stripped from Bash tool
const bashTool = result.tools.find((t) => t.name === 'Bash');
expect(bashTool?.input_schema).toEqual({
type: 'object',
properties: {
command: { type: 'string', description: 'The command to execute' },
timeout: { type: 'number', description: 'Timeout in ms' },
},
required: ['command'],
});
// Verify examples stripped from Read tool
const readTool = result.tools.find((t) => t.name === 'Read');
expect(readTool?.input_schema).toEqual({
type: 'object',
properties: {
file_path: { type: 'string', description: 'File path to read' },
},
required: ['file_path'],
});
});
test('handles tools without input_schema', () => {
const tools = [
{ name: 'simple_tool', description: 'No schema' },