mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +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,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,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,121 @@ 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('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 +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', () => {
|
||||
const tools = [
|
||||
{ name: 'simple_tool', description: 'No schema' },
|
||||
|
||||
Reference in New Issue
Block a user