From f8c179f6da47f6a0d0cb65fe3be12ea44a05bdef Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:19:58 -0500 Subject: [PATCH] fix(cliproxy): sanitize MCP tool input_schema to remove non-standard properties (#459) Squash merge PR #459: MCP tool input_schema sanitization Fixes #456 - Gemini/Vertex APIs reject non-standard JSON Schema properties from MCP tools Changes: - Add allowlist-based schema sanitizer for JSON Schema Draft-07 keywords - Recursive sanitization with circular ref protection and depth limit - Handle all schema containers: properties, items, additionalProperties, additionalItems, oneOf/anyOf/allOf, not, if/then/else, $defs, definitions, patternProperties, contains, propertyNames, dependencies - 22 unit tests covering edge cases - Integrates into ToolSanitizationProxy pipeline before tool name sanitization --- src/cliproxy/index.ts | 4 + src/cliproxy/schema-sanitizer.ts | 369 +++++++++++++++++ src/cliproxy/tool-sanitization-proxy.ts | 26 +- tests/unit/cliproxy/schema-sanitizer.test.ts | 405 +++++++++++++++++++ 4 files changed, 801 insertions(+), 3 deletions(-) create mode 100644 src/cliproxy/schema-sanitizer.ts create mode 100644 tests/unit/cliproxy/schema-sanitizer.test.ts diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index b09c5741..a504a0f8 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -205,3 +205,7 @@ export { ToolNameMapper } from './tool-name-mapper'; export type { ToolSanitizationProxyConfig } from './tool-sanitization-proxy'; export { ToolSanitizationProxy } from './tool-sanitization-proxy'; + +// Schema sanitization (for MCP input_schema non-standard property removal) +export type { SchemaSanitizationResult } from './schema-sanitizer'; +export { sanitizeInputSchema, sanitizeToolSchemas } from './schema-sanitizer'; diff --git a/src/cliproxy/schema-sanitizer.ts b/src/cliproxy/schema-sanitizer.ts new file mode 100644 index 00000000..a63c2d6f --- /dev/null +++ b/src/cliproxy/schema-sanitizer.ts @@ -0,0 +1,369 @@ +/** + * Schema Sanitizer + * + * Sanitizes MCP tool input_schema to remove non-standard JSON Schema properties + * that Gemini/Vertex APIs reject. + * + * MCP servers (especially design tools) include UI-specific metadata in schemas: + * - cornerRadius, fillColor, fontFamily, fontSize, fontWeight, gap, padding, etc. + * + * These are valid as MCP hints but invalid for strict JSON Schema validation. + */ + +/** Valid JSON Schema Draft-07 keywords (used by Anthropic/Gemini APIs) */ +const VALID_JSON_SCHEMA_KEYWORDS = new Set([ + // Core + 'type', + 'properties', + 'required', + 'items', + 'enum', + 'const', + 'default', + + // 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', + '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', +]); + +/** Maximum recursion depth to prevent stack overflow */ +const MAX_DEPTH = 100; + +export interface SchemaSanitizationResult { + /** The sanitized schema */ + schema: Record; + /** Number of properties removed */ + removedCount: number; + /** List of removed property paths for logging */ + removedPaths: string[]; +} + +/** + * Check if a key is a valid JSON Schema keyword. + */ +function isValidSchemaKey(key: string): boolean { + return VALID_JSON_SCHEMA_KEYWORDS.has(key); +} + +/** + * Recursively sanitize a JSON Schema object. + * Removes non-standard properties while preserving valid schema structure. + * + * @param schema The schema object to sanitize + * @param path Current path for logging (e.g., "properties.foo.items") + * @param removedPaths Accumulator for removed property paths + * @param visited WeakSet to track visited objects and prevent circular references + * @param depth Current recursion depth to prevent stack overflow + * @returns Sanitized schema object + */ +function sanitizeSchemaRecursive( + schema: unknown, + path: string, + removedPaths: string[], + visited: WeakSet = new WeakSet(), + depth: number = 0 +): unknown { + // Guard: prevent stack overflow from deep nesting + if (depth > MAX_DEPTH) { + return schema; + } + + // Handle non-objects (primitives, null) + if (typeof schema !== 'object' || schema === null) { + return schema; + } + + // Guard: prevent infinite recursion from circular references + if (visited.has(schema)) { + return schema; + } + + // Mark object as visited + visited.add(schema); + + // Handle arrays + if (Array.isArray(schema)) { + return schema.map((item, index) => + sanitizeSchemaRecursive(item, `${path}[${index}]`, removedPaths, visited, depth + 1) + ); + } + + // Handle objects + const result: Record = {}; + const obj = schema as Record; + + for (const [key, value] of Object.entries(obj)) { + const keyPath = path ? `${path}.${key}` : key; + + // Special handling for nested schema containers + if (key === 'properties' && typeof value === 'object' && value !== null) { + // Recurse into each property definition + const sanitizedProps: Record = {}; + for (const [propName, propSchema] of Object.entries(value as Record)) { + sanitizedProps[propName] = sanitizeSchemaRecursive( + propSchema, + `${keyPath}.${propName}`, + removedPaths, + visited, + depth + 1 + ); + } + result[key] = sanitizedProps; + continue; + } + + if (key === 'items') { + // items can be object or array of objects + result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1); + 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)) { + 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 = {}; + for (const [defName, defSchema] of Object.entries(value as Record)) { + 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 = {}; + for (const [pattern, patternSchema] of Object.entries(value as Record)) { + 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 = {}; + for (const [depName, depValue] of Object.entries(value as Record)) { + // 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)) { + // Recurse for nested objects that might contain schemas + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1); + } else { + result[key] = value; + } + } else { + // Non-standard property - remove it + removedPaths.push(keyPath); + } + } + + return result; +} + +/** + * Sanitize an input_schema object, removing non-standard JSON Schema properties. + * + * @param inputSchema The tool's input_schema object + * @returns Sanitization result with cleaned schema and metadata + */ +export function sanitizeInputSchema( + inputSchema: Record | null | undefined +): SchemaSanitizationResult { + // Preserve null/undefined as-is (graceful handling of missing schemas) + if (inputSchema === null || inputSchema === undefined) { + return { + schema: inputSchema as unknown as Record, + removedCount: 0, + removedPaths: [], + }; + } + + // Non-object types (arrays, primitives) - return empty object + if (typeof inputSchema !== 'object' || Array.isArray(inputSchema)) { + return { schema: {}, removedCount: 0, removedPaths: [] }; + } + + const removedPaths: string[] = []; + + const sanitized = sanitizeSchemaRecursive(inputSchema, '', removedPaths) as Record< + string, + unknown + >; + + return { + schema: sanitized, + removedCount: removedPaths.length, + removedPaths, + }; +} + +/** + * Sanitize an array of tools, cleaning their input_schema properties. + * + * @param tools Array of tool definitions + * @returns Object with sanitized tools and total removed count + */ +export function sanitizeToolSchemas( + tools: Array<{ name: string; input_schema?: Record; [key: string]: unknown }> +): { + tools: typeof tools; + totalRemoved: number; + removedByTool: Array<{ name: string; removed: string[] }>; +} { + const removedByTool: Array<{ name: string; removed: string[] }> = []; + let totalRemoved = 0; + + const sanitizedTools = tools.map((tool) => { + // Guard against non-object elements + if (typeof tool !== 'object' || tool === null) { + return tool; + } + if (!tool.input_schema || typeof tool.input_schema !== 'object') { + return tool; + } + + const result = sanitizeInputSchema(tool.input_schema); + + if (result.removedCount > 0) { + removedByTool.push({ + name: tool.name, + removed: result.removedPaths, + }); + totalRemoved += result.removedCount; + } + + return { + ...tool, + input_schema: result.schema, + }; + }); + + return { + tools: sanitizedTools, + totalRemoved, + removedByTool, + }; +} diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 5bfde38f..78ddecdb 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -3,8 +3,9 @@ * * HTTP proxy that intercepts Claude CLI → CLIProxy requests to: * 1. Sanitize MCP tool names exceeding Gemini's 64-char limit - * 2. Forward sanitized requests to upstream - * 3. Restore original names in responses + * 2. Sanitize MCP tool input_schema to remove non-standard JSON Schema properties + * 3. Forward sanitized requests to upstream + * 4. Restore original names in responses * * Follows CodexReasoningProxy pattern for consistency. */ @@ -16,6 +17,7 @@ import * as path from 'path'; import * as os from 'os'; import { URL } from 'url'; import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper'; +import { sanitizeToolSchemas } from './schema-sanitizer'; import { getCcsDir } from '../utils/config-manager'; export interface ToolSanitizationProxyConfig { @@ -207,7 +209,25 @@ export class ToolSanitizationProxy { // Sanitize tools if present let modifiedBody = parsed; if (isRecord(parsed) && Array.isArray(parsed.tools)) { - const sanitizedTools = mapper.registerTools(parsed.tools as Tool[]); + // Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties) + const schemaResult = sanitizeToolSchemas( + parsed.tools as Array<{ name: string; input_schema?: Record }> + ); + + if (schemaResult.totalRemoved > 0) { + for (const entry of schemaResult.removedByTool) { + this.writeLog( + 'warn', + `[tool-sanitization-proxy] Schema sanitized for "${entry.name}": removed ${entry.removed.length} non-standard properties` + ); + } + this.log( + `Sanitized ${schemaResult.totalRemoved} schema properties across ${schemaResult.removedByTool.length} tool(s)` + ); + } + + // Step 2: Sanitize tool names (truncate to 64 chars for Gemini) + const sanitizedTools = mapper.registerTools(schemaResult.tools as Tool[]); modifiedBody = { ...parsed, tools: sanitizedTools }; // Log sanitization warnings diff --git a/tests/unit/cliproxy/schema-sanitizer.test.ts b/tests/unit/cliproxy/schema-sanitizer.test.ts new file mode 100644 index 00000000..c9a5cabb --- /dev/null +++ b/tests/unit/cliproxy/schema-sanitizer.test.ts @@ -0,0 +1,405 @@ +/** + * Schema Sanitizer Unit Tests + * + * Tests for MCP tool input_schema sanitization. + */ + +import { describe, expect, test } from 'bun:test'; +import { sanitizeInputSchema, sanitizeToolSchemas } from '../../../dist/cliproxy/schema-sanitizer.js'; + +describe('sanitizeInputSchema', () => { + test('preserves valid JSON Schema properties', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string', description: 'The name' }, + age: { type: 'number', minimum: 0, maximum: 150 }, + }, + required: ['name'], + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(0); + expect(result.removedPaths).toEqual([]); + expect(result.schema).toEqual(schema); + }); + + test('removes non-standard properties at top level', () => { + const schema = { + type: 'object', + cornerRadius: 8, + fillColor: '#ffffff', + properties: { + name: { type: 'string' }, + }, + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(2); + expect(result.removedPaths).toContain('cornerRadius'); + expect(result.removedPaths).toContain('fillColor'); + expect(result.schema).toEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + }, + }); + }); + + test('removes non-standard properties from nested properties', () => { + const schema = { + type: 'object', + properties: { + style: { + type: 'object', + fontFamily: 'Arial', + fontSize: 12, + properties: { + color: { type: 'string' }, + }, + }, + }, + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(2); + expect(result.removedPaths).toContain('properties.style.fontFamily'); + expect(result.removedPaths).toContain('properties.style.fontSize'); + expect(result.schema).toEqual({ + type: 'object', + properties: { + style: { + type: 'object', + properties: { + color: { type: 'string' }, + }, + }, + }, + }); + }); + + test('removes non-standard properties from array items', () => { + const schema = { + type: 'array', + items: { + type: 'object', + gap: 10, + padding: 20, + properties: { + id: { type: 'string' }, + }, + }, + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(2); + expect(result.removedPaths).toContain('items.gap'); + expect(result.removedPaths).toContain('items.padding'); + expect(result.schema).toEqual({ + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }); + }); + + test('handles oneOf/anyOf/allOf with nested schemas', () => { + const schema = { + type: 'object', + oneOf: [ + { type: 'string', customProp: 'remove' }, + { type: 'number', anotherCustom: 123 }, + ], + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(2); + expect(result.schema).toEqual({ + type: 'object', + oneOf: [{ type: 'string' }, { type: 'number' }], + }); + }); + + test('handles deeply nested structures', () => { + const schema = { + type: 'object', + properties: { + level1: { + type: 'object', + properties: { + level2: { + type: 'object', + uiHint: 'remove-me', + properties: { + value: { type: 'string' }, + }, + }, + }, + }, + }, + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(1); + expect(result.removedPaths).toContain('properties.level1.properties.level2.uiHint'); + }); + + test('preserves $defs and definitions', () => { + const schema = { + type: 'object', + $defs: { + address: { + type: 'object', + customUI: 'remove', + properties: { + street: { type: 'string' }, + }, + }, + }, + properties: { + home: { $ref: '#/$defs/address' }, + }, + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(1); + expect(result.removedPaths).toContain('$defs.address.customUI'); + expect(result.schema).toEqual({ + type: 'object', + $defs: { + address: { + type: 'object', + properties: { + street: { type: 'string' }, + }, + }, + }, + properties: { + home: { $ref: '#/$defs/address' }, + }, + }); + }); + + test('handles empty schema', () => { + const result = sanitizeInputSchema({}); + + expect(result.removedCount).toBe(0); + expect(result.schema).toEqual({}); + }); + + test('preserves all standard metadata keywords', () => { + const schema = { + $id: 'https://example.com/schema', + $schema: 'https://json-schema.org/draft-07/schema#', + title: 'Test Schema', + description: 'A test schema', + type: 'string', + examples: ['example1', 'example2'], + deprecated: false, + readOnly: true, + $comment: 'Internal comment', + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(0); + expect(result.schema).toEqual(schema); + }); + + 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', () => { + 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 + }, + }; + 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' } } }, + }); + }); + + test('handles null input gracefully', () => { + const result = sanitizeInputSchema(null as any); + expect(result.schema).toBeNull(); + expect(result.removedCount).toBe(0); + }); + + test('handles undefined input gracefully', () => { + const result = sanitizeInputSchema(undefined as any); + expect(result.schema).toBeUndefined(); + expect(result.removedCount).toBe(0); + }); +}); + +describe('sanitizeToolSchemas', () => { + test('sanitizes multiple tools and tracks per-tool changes', () => { + const tools = [ + { + name: 'clean_tool', + input_schema: { + type: 'object', + properties: { name: { type: 'string' } }, + }, + }, + { + name: 'figma_tool', + input_schema: { + type: 'object', + cornerRadius: 8, + fillColor: '#000', + properties: { id: { type: 'string' } }, + }, + }, + { + name: 'design_tool', + input_schema: { + type: 'object', + fontWeight: 'bold', + properties: { text: { type: 'string' } }, + }, + }, + ]; + + const result = sanitizeToolSchemas(tools); + + expect(result.totalRemoved).toBe(3); + expect(result.removedByTool).toHaveLength(2); + expect(result.removedByTool[0].name).toBe('figma_tool'); + expect(result.removedByTool[0].removed).toContain('cornerRadius'); + expect(result.removedByTool[0].removed).toContain('fillColor'); + expect(result.removedByTool[1].name).toBe('design_tool'); + expect(result.removedByTool[1].removed).toContain('fontWeight'); + + // Verify schemas are actually sanitized + const figmaTool = result.tools.find((t) => t.name === 'figma_tool'); + expect(figmaTool?.input_schema).toEqual({ + type: 'object', + properties: { id: { type: 'string' } }, + }); + }); + + test('handles tools without input_schema', () => { + const tools = [ + { name: 'simple_tool', description: 'No schema' }, + { name: 'another_tool' }, + ]; + + const result = sanitizeToolSchemas(tools); + + expect(result.totalRemoved).toBe(0); + expect(result.removedByTool).toHaveLength(0); + expect(result.tools).toEqual(tools); + }); + + test('returns unmodified tools when all schemas are valid', () => { + const tools = [ + { + name: 'valid_tool', + input_schema: { + type: 'object', + properties: { value: { type: 'number', minimum: 0 } }, + required: ['value'], + }, + }, + ]; + + const result = sanitizeToolSchemas(tools); + + expect(result.totalRemoved).toBe(0); + expect(result.removedByTool).toHaveLength(0); + }); + + test('handles tools without input_schema (skips null tools)', () => { + const tools = [ + { name: 'simple_tool', description: 'No schema' }, + { name: 'valid', input_schema: { type: 'object' } }, + ]; + const result = sanitizeToolSchemas(tools); + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('simple_tool'); + expect(result.tools[1].name).toBe('valid'); + }); +});