fix(cliproxy): strip Gemini-unsupported schema fields including "examples"

Replace permissive JSON Schema Draft-07 whitelist with strict
Gemini-compatible field set (22 fields). The "examples" field
in Claude Code tool schemas caused 400 errors from Gemini API.

Also strips other unsupported fields: $ref, $defs, oneOf, allOf,
additionalProperties, const, if/then/else, etc.

Safe change — sanitizer only runs for CLIProxy profiles (Gemini,
Codex, Antigravity), never for direct Anthropic API requests.

Closes #155
This commit is contained in:
kaitranntt committed 2026-02-07 06:17:26 -05:00
1 parent 7ceb019bb7
commit 505d6d0f11
2 files changed
+264 -263

No files matched your search

+46 -152
View File
@@ -1,78 +1,60 @@
/** /**
* Schema Sanitizer * Schema Sanitizer
* *
* Sanitizes MCP tool input_schema to remove non-standard JSON Schema properties * Sanitizes tool input_schema to only include fields supported by Google's
* that Gemini/Vertex APIs reject. * Gemini/Vertex AI function_declarations Schema object.
* *
* MCP servers (especially design tools) include UI-specific metadata in schemas: * This sanitizer runs exclusively in the CLIProxy execution path (Gemini, Codex,
* - cornerRadius, fillColor, fontFamily, fontSize, fontWeight, gap, padding, etc. * 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 // Core
'type', 'type',
'format',
'title',
'description',
'nullable',
'example',
'default',
// Enum
'enum',
// Object
'properties', 'properties',
'required', 'required',
'minProperties',
'maxProperties',
// Array
'items', 'items',
'enum', 'minItems',
'const', 'maxItems',
'default',
// String validation // String validation
'minLength', 'minLength',
'maxLength', 'maxLength',
'pattern', 'pattern',
'format',
// Number validation // Number validation
'minimum', 'minimum',
'maximum', 'maximum',
'exclusiveMinimum',
'exclusiveMaximum',
'multipleOf',
// Array validation // Composition (only anyOf supported, NOT oneOf/allOf/not)
'minItems',
'maxItems',
'uniqueItems',
'contains',
'additionalItems',
// Object validation
'additionalProperties',
'patternProperties',
'minProperties',
'maxProperties',
'propertyNames',
'dependencies',
// Composition
'oneOf',
'anyOf', 'anyOf',
'allOf',
'not',
'if',
'then',
'else',
// Metadata (allowed by most APIs) // Gemini-specific (non-standard OpenAPI)
'title', 'propertyOrdering',
'description',
'$id',
'$schema',
'$ref',
'$defs',
'definitions',
'$comment',
'examples',
'readOnly',
'writeOnly',
'deprecated',
'contentMediaType',
'contentEncoding',
]); ]);
/** Maximum recursion depth to prevent stack overflow */ /** 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 { 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. * 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 schema The schema object to sanitize
* @param path Current path for logging (e.g., "properties.foo.items") * @param path Current path for logging (e.g., "properties.foo.items")
@@ -167,115 +149,27 @@ function sanitizeSchemaRecursive(
continue; continue;
} }
if (key === 'additionalProperties' && typeof value === 'object') { // anyOf is the only composition keyword Gemini supports
// Can be boolean or schema object if (key === 'anyOf' && Array.isArray(value)) {
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)) {
result[key] = value.map((item, index) => result[key] = value.map((item, index) =>
sanitizeSchemaRecursive(item, `${keyPath}[${index}]`, removedPaths, visited, depth + 1) sanitizeSchemaRecursive(item, `${keyPath}[${index}]`, removedPaths, visited, depth + 1)
); );
continue; continue;
} }
if (key === 'not' && typeof value === 'object') { // Check if this is a Gemini-supported field
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
if (isValidSchemaKey(key)) { if (isValidSchemaKey(key)) {
// Recurse for nested objects that might contain schemas // "example" and "default" hold arbitrary values, not nested schemas — pass through as-is
if (typeof value === 'object' && value !== null && !Array.isArray(value)) { 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); result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
} else { } else {
result[key] = value; result[key] = value;
} }
} else { } else {
// Non-standard property - remove it // Unsupported field — remove it
removedPaths.push(keyPath); 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 * @param inputSchema The tool's input_schema object
* @returns Sanitization result with cleaned schema and metadata * @returns Sanitization result with cleaned schema and metadata
+218 -111
View File
@@ -1,14 +1,15 @@
/** /**
* Schema Sanitizer Unit Tests * 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 { describe, expect, test } from 'bun:test';
import { sanitizeInputSchema, sanitizeToolSchemas } from '../../../dist/cliproxy/schema-sanitizer.js'; import { sanitizeInputSchema, sanitizeToolSchemas } from '../../../dist/cliproxy/schema-sanitizer.js';
describe('sanitizeInputSchema', () => { describe('sanitizeInputSchema', () => {
test('preserves valid JSON Schema properties', () => { test('preserves Gemini-supported properties', () => {
const schema = { const schema = {
type: 'object', type: 'object',
properties: { properties: {
@@ -110,10 +111,10 @@ describe('sanitizeInputSchema', () => {
}); });
}); });
test('handles oneOf/anyOf/allOf with nested schemas', () => { test('preserves anyOf and sanitizes nested schemas', () => {
const schema = { const schema = {
type: 'object', type: 'object',
oneOf: [ anyOf: [
{ type: 'string', customProp: 'remove' }, { type: 'string', customProp: 'remove' },
{ type: 'number', anotherCustom: 123 }, { type: 'number', anotherCustom: 123 },
], ],
@@ -124,10 +125,25 @@ describe('sanitizeInputSchema', () => {
expect(result.removedCount).toBe(2); expect(result.removedCount).toBe(2);
expect(result.schema).toEqual({ expect(result.schema).toEqual({
type: 'object', 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', () => { test('handles deeply nested structures', () => {
const schema = { const schema = {
type: 'object', type: 'object',
@@ -153,51 +169,52 @@ describe('sanitizeInputSchema', () => {
expect(result.removedPaths).toContain('properties.level1.properties.level2.uiHint'); expect(result.removedPaths).toContain('properties.level1.properties.level2.uiHint');
}); });
test('preserves $defs and definitions', () => { test('strips "examples" field (issue #155)', () => {
const schema = { const schema = {
type: 'object', type: 'object',
$defs: {
address: {
type: 'object',
customUI: 'remove',
properties: {
street: { type: 'string' },
},
},
},
properties: { 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); const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1); expect(result.removedCount).toBe(2);
expect(result.removedPaths).toContain('$defs.address.customUI'); expect(result.removedPaths).toContain('properties.command.examples');
expect(result.removedPaths).toContain('properties.timeout.examples');
expect(result.schema).toEqual({ expect(result.schema).toEqual({
type: 'object', type: 'object',
$defs: {
address: {
type: 'object',
properties: {
street: { type: 'string' },
},
},
},
properties: { properties: {
home: { $ref: '#/$defs/address' }, command: { type: 'string', description: 'The command to execute' },
timeout: { type: 'number', description: 'Timeout in ms' },
}, },
}); });
}); });
test('handles empty schema', () => { test('keeps "example" (singular) but strips "examples" (plural)', () => {
const result = sanitizeInputSchema({}); const schema = {
type: 'string',
example: 'hello',
examples: ['hello', 'world'],
};
expect(result.removedCount).toBe(0); const result = sanitizeInputSchema(schema);
expect(result.schema).toEqual({});
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 = { const schema = {
$id: 'https://example.com/schema', $id: 'https://example.com/schema',
$schema: 'https://json-schema.org/draft-07/schema#', $schema: 'https://json-schema.org/draft-07/schema#',
@@ -212,95 +229,121 @@ describe('sanitizeInputSchema', () => {
const result = sanitizeInputSchema(schema); const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(0); // $id, $schema, examples, deprecated, readOnly, $comment = 6 removed
expect(result.schema).toEqual(schema); 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', () => { test('strips $defs, $ref, definitions (not supported by Gemini)', () => {
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', () => {
const schema = { const schema = {
type: 'object', type: 'object',
patternProperties: { '^S_': { type: 'string', customProp: 'remove' } }, $defs: {
}; address: {
const result = sanitizeInputSchema(schema); type: 'object',
expect(result.removedCount).toBe(1); properties: { street: { type: 'string' } },
expect(result.removedPaths).toContain('patternProperties.^S_.customProp'); },
expect(result.schema.patternProperties).toEqual({ '^S_': { type: 'string' } }); },
}); properties: {
home: { $ref: '#/$defs/address' },
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
}, },
}; };
const result = sanitizeInputSchema(schema); const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('dependencies.baz.customProp'); expect(result.removedCount).toBe(2);
expect(result.schema.dependencies).toEqual({ expect(result.removedPaths).toContain('$defs');
bar: ['foo'], expect(result.removedPaths).toContain('properties.home.$ref');
baz: { properties: { qux: { type: 'string' } } }, 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('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', () => { test('handles null input gracefully', () => {
const result = sanitizeInputSchema(null as any); const result = sanitizeInputSchema(null as any);
expect(result.schema).toBeNull(); expect(result.schema).toBeNull();
@@ -361,6 +404,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', () => { test('handles tools without input_schema', () => {
const tools = [ const tools = [
{ name: 'simple_tool', description: 'No schema' }, { name: 'simple_tool', description: 'No schema' },