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
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-04 20:19:58 -05:00
committed by GitHub
parent dbb6ae8da3
commit f8c179f6da
4 changed files with 801 additions and 3 deletions
+4
View File
@@ -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';
+369
View File
@@ -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<string, unknown>;
/** 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<object> = 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<string, unknown> = {};
const obj = schema as Record<string, unknown>;
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<string, unknown> = {};
for (const [propName, propSchema] of Object.entries(value as Record<string, unknown>)) {
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<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)) {
// 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<string, unknown> | 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<string, unknown>,
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<string, unknown>; [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,
};
}
+23 -3
View File
@@ -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<string, unknown> }>
);
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
@@ -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');
});
});