mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user