mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
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:
1 parent
7ceb019bb7
commit
505d6d0f11
2 files changed
+264
-263
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in new issue
Block a user