mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-20 02:17:05 +00:00
feat(cliproxy): add ToolSanitizationProxy for Gemini 64-char limit
Fixes #219 - MCP tool names exceeding Gemini's 64-character limit now get sanitized automatically. - Add tool-name-sanitizer: dedupe segments + smart truncate with hash - Add tool-name-mapper: bidirectional mapping for response restoration - Add tool-sanitization-proxy: HTTP proxy layer for all CLIProxy providers - Chain: Claude CLI → ToolSanitizationProxy → [CodexReasoningProxy] → CLIProxy - Add unit tests for sanitizer and mapper modules
This commit is contained in:
1 parent
7f7dcb21f9
commit
63633507d2
7 files changed
+1251
-15
No files matched your search
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Unit tests for tool-name-mapper module
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const { ToolNameMapper } = require('../../../dist/cliproxy/tool-name-mapper');
|
||||
|
||||
describe('Tool Name Mapper', () => {
|
||||
describe('registerTools', () => {
|
||||
it('returns unchanged tools when all names are valid', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [
|
||||
{ name: 'valid_tool', description: 'A valid tool' },
|
||||
{ name: 'another_tool', description: 'Another tool' },
|
||||
];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].name, 'valid_tool');
|
||||
assert.strictEqual(result[1].name, 'another_tool');
|
||||
assert.strictEqual(mapper.hasChanges(), false);
|
||||
});
|
||||
|
||||
it('sanitizes long tool names', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const longName = 'a'.repeat(100);
|
||||
const tools = [{ name: longName, description: 'Long name tool' }];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].name.length, 64);
|
||||
assert.strictEqual(mapper.hasChanges(), true);
|
||||
});
|
||||
|
||||
it('sanitizes duplicate segment names', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [{ name: 'foo__bar__bar', description: 'Duplicate segments' }];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].name, 'foo__bar');
|
||||
assert.strictEqual(mapper.hasChanges(), true);
|
||||
});
|
||||
|
||||
it('preserves other tool properties', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [
|
||||
{
|
||||
name: 'foo__bar__bar',
|
||||
description: 'Test tool',
|
||||
input_schema: { type: 'object' },
|
||||
custom_field: 'preserved',
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].description, 'Test tool');
|
||||
assert.deepStrictEqual(result[0].input_schema, { type: 'object' });
|
||||
assert.strictEqual(result[0].custom_field, 'preserved');
|
||||
});
|
||||
|
||||
it('handles empty tools array', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const result = mapper.registerTools([]);
|
||||
|
||||
assert.deepStrictEqual(result, []);
|
||||
assert.strictEqual(mapper.hasChanges(), false);
|
||||
});
|
||||
|
||||
it('handles mixed valid and invalid names', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [
|
||||
{ name: 'valid_tool' },
|
||||
{ name: 'foo__bar__bar' },
|
||||
{ name: 'another_valid' },
|
||||
];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].name, 'valid_tool');
|
||||
assert.strictEqual(result[1].name, 'foo__bar');
|
||||
assert.strictEqual(result[2].name, 'another_valid');
|
||||
assert.strictEqual(mapper.hasChanges(), true);
|
||||
assert.strictEqual(mapper.getChangeCount(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreToolUse', () => {
|
||||
it('restores sanitized names in tool_use blocks', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const content = [
|
||||
{ type: 'tool_use', id: 'call_1', name: 'foo__bar', input: {} },
|
||||
];
|
||||
|
||||
const result = mapper.restoreToolUse(content);
|
||||
|
||||
assert.strictEqual(result[0].name, 'foo__bar__bar');
|
||||
});
|
||||
|
||||
it('leaves non-tool_use blocks unchanged', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const content = [
|
||||
{ type: 'text', text: 'Hello world' },
|
||||
{ type: 'tool_use', id: 'call_1', name: 'foo__bar', input: {} },
|
||||
];
|
||||
|
||||
const result = mapper.restoreToolUse(content);
|
||||
|
||||
assert.strictEqual(result[0].type, 'text');
|
||||
assert.strictEqual(result[0].text, 'Hello world');
|
||||
assert.strictEqual(result[1].name, 'foo__bar__bar');
|
||||
});
|
||||
|
||||
it('leaves unknown tool names unchanged', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const content = [
|
||||
{ type: 'tool_use', id: 'call_1', name: 'unknown_tool', input: {} },
|
||||
];
|
||||
|
||||
const result = mapper.restoreToolUse(content);
|
||||
|
||||
assert.strictEqual(result[0].name, 'unknown_tool');
|
||||
});
|
||||
|
||||
it('handles empty content array', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const result = mapper.restoreToolUse([]);
|
||||
|
||||
assert.deepStrictEqual(result, []);
|
||||
});
|
||||
|
||||
it('handles tool_use without name property', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const content = [{ type: 'tool_use', id: 'call_1', input: {} }];
|
||||
|
||||
const result = mapper.restoreToolUse(content);
|
||||
|
||||
// Should return unchanged
|
||||
assert.deepStrictEqual(result[0], content[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreName', () => {
|
||||
it('restores a single sanitized name', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const result = mapper.restoreName('foo__bar');
|
||||
|
||||
assert.strictEqual(result, 'foo__bar__bar');
|
||||
});
|
||||
|
||||
it('returns input for unknown names', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const result = mapper.restoreName('unknown');
|
||||
|
||||
assert.strictEqual(result, 'unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChanges', () => {
|
||||
it('returns false when no sanitization occurred', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'valid_tool' }]);
|
||||
|
||||
assert.strictEqual(mapper.hasChanges(), false);
|
||||
});
|
||||
|
||||
it('returns true when sanitization occurred', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
assert.strictEqual(mapper.hasChanges(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChanges', () => {
|
||||
it('returns empty array when no changes', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'valid_tool' }]);
|
||||
|
||||
assert.deepStrictEqual(mapper.getChanges(), []);
|
||||
});
|
||||
|
||||
it('returns list of all changes', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([
|
||||
{ name: 'foo__bar__bar' },
|
||||
{ name: 'baz__qux__qux' },
|
||||
]);
|
||||
|
||||
const changes = mapper.getChanges();
|
||||
|
||||
assert.strictEqual(changes.length, 2);
|
||||
assert.strictEqual(changes[0].original, 'foo__bar__bar');
|
||||
assert.strictEqual(changes[0].sanitized, 'foo__bar');
|
||||
assert.strictEqual(changes[1].original, 'baz__qux__qux');
|
||||
assert.strictEqual(changes[1].sanitized, 'baz__qux');
|
||||
});
|
||||
|
||||
it('returns a copy (not the internal array)', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const changes1 = mapper.getChanges();
|
||||
const changes2 = mapper.getChanges();
|
||||
|
||||
assert.notStrictEqual(changes1, changes2);
|
||||
assert.deepStrictEqual(changes1, changes2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChangeCount', () => {
|
||||
it('returns 0 when no changes', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
assert.strictEqual(mapper.getChangeCount(), 0);
|
||||
});
|
||||
|
||||
it('returns correct count after sanitization', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([
|
||||
{ name: 'foo__bar__bar' },
|
||||
{ name: 'valid_tool' },
|
||||
{ name: 'baz__qux__qux' },
|
||||
]);
|
||||
|
||||
assert.strictEqual(mapper.getChangeCount(), 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('resets all state', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
assert.strictEqual(mapper.hasChanges(), true);
|
||||
|
||||
mapper.clear();
|
||||
|
||||
assert.strictEqual(mapper.hasChanges(), false);
|
||||
assert.deepStrictEqual(mapper.getChanges(), []);
|
||||
assert.strictEqual(mapper.restoreName('foo__bar'), 'foo__bar');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Unit tests for tool-name-sanitizer module
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const {
|
||||
isValidToolName,
|
||||
removeDuplicateSegments,
|
||||
smartTruncate,
|
||||
sanitizeToolName,
|
||||
GEMINI_MAX_TOOL_NAME_LENGTH,
|
||||
} = require('../../../dist/cliproxy/tool-name-sanitizer');
|
||||
|
||||
describe('Tool Name Sanitizer', () => {
|
||||
describe('GEMINI_MAX_TOOL_NAME_LENGTH', () => {
|
||||
it('should be 64', () => {
|
||||
assert.strictEqual(GEMINI_MAX_TOOL_NAME_LENGTH, 64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidToolName', () => {
|
||||
it('returns true for valid short names', () => {
|
||||
assert.strictEqual(isValidToolName('my_tool'), true);
|
||||
assert.strictEqual(isValidToolName('a'), true);
|
||||
assert.strictEqual(isValidToolName('_underscore'), true);
|
||||
assert.strictEqual(isValidToolName('Tool123'), true);
|
||||
});
|
||||
|
||||
it('returns true for names with valid special characters', () => {
|
||||
assert.strictEqual(isValidToolName('my.tool'), true);
|
||||
assert.strictEqual(isValidToolName('my:tool'), true);
|
||||
assert.strictEqual(isValidToolName('my-tool'), true);
|
||||
assert.strictEqual(isValidToolName('my_tool.v1:test-123'), true);
|
||||
});
|
||||
|
||||
it('returns true for exactly 64 character names', () => {
|
||||
const name64 = 'a'.repeat(64);
|
||||
assert.strictEqual(isValidToolName(name64), true);
|
||||
});
|
||||
|
||||
it('returns false for names exceeding 64 characters', () => {
|
||||
const name65 = 'a'.repeat(65);
|
||||
assert.strictEqual(isValidToolName(name65), false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
assert.strictEqual(isValidToolName(''), false);
|
||||
});
|
||||
|
||||
it('returns false for names starting with number', () => {
|
||||
assert.strictEqual(isValidToolName('123start'), false);
|
||||
assert.strictEqual(isValidToolName('0tool'), false);
|
||||
});
|
||||
|
||||
it('returns false for names with invalid characters', () => {
|
||||
assert.strictEqual(isValidToolName('has space'), false);
|
||||
assert.strictEqual(isValidToolName('has@symbol'), false);
|
||||
assert.strictEqual(isValidToolName('has#hash'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeDuplicateSegments', () => {
|
||||
it('removes consecutive duplicate segments', () => {
|
||||
assert.strictEqual(removeDuplicateSegments('foo__bar__bar'), 'foo__bar');
|
||||
assert.strictEqual(removeDuplicateSegments('gitmcp__x__x'), 'gitmcp__x');
|
||||
});
|
||||
|
||||
it('removes multiple consecutive duplicates', () => {
|
||||
assert.strictEqual(removeDuplicateSegments('a__b__b__b'), 'a__b');
|
||||
});
|
||||
|
||||
it('handles non-consecutive duplicates (keeps them)', () => {
|
||||
// Only consecutive duplicates are removed
|
||||
assert.strictEqual(removeDuplicateSegments('a__b__a'), 'a__b__a');
|
||||
});
|
||||
|
||||
it('returns unchanged when no duplicates', () => {
|
||||
assert.strictEqual(removeDuplicateSegments('foo__bar__baz'), 'foo__bar__baz');
|
||||
assert.strictEqual(removeDuplicateSegments('no_dupes'), 'no_dupes');
|
||||
});
|
||||
|
||||
it('handles single segment', () => {
|
||||
assert.strictEqual(removeDuplicateSegments('single'), 'single');
|
||||
});
|
||||
|
||||
it('handles real-world MCP tool name with duplicates', () => {
|
||||
const input = 'gitmcp__plus-pro-components__plus-pro-components';
|
||||
const expected = 'gitmcp__plus-pro-components';
|
||||
assert.strictEqual(removeDuplicateSegments(input), expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smartTruncate', () => {
|
||||
it('returns unchanged for short names', () => {
|
||||
assert.strictEqual(smartTruncate('short', 64), 'short');
|
||||
assert.strictEqual(smartTruncate('a'.repeat(64), 64), 'a'.repeat(64));
|
||||
});
|
||||
|
||||
it('truncates long names with hash suffix', () => {
|
||||
const longName = 'a'.repeat(100);
|
||||
const result = smartTruncate(longName, 64);
|
||||
|
||||
assert.strictEqual(result.length, 64);
|
||||
assert.ok(result.includes('_')); // Has underscore separator
|
||||
});
|
||||
|
||||
it('produces deterministic output', () => {
|
||||
const name = 'a'.repeat(100);
|
||||
const result1 = smartTruncate(name, 64);
|
||||
const result2 = smartTruncate(name, 64);
|
||||
|
||||
assert.strictEqual(result1, result2);
|
||||
});
|
||||
|
||||
it('produces different hashes for different inputs', () => {
|
||||
const result1 = smartTruncate('a'.repeat(100), 64);
|
||||
const result2 = smartTruncate('b'.repeat(100), 64);
|
||||
|
||||
assert.notStrictEqual(result1, result2);
|
||||
});
|
||||
|
||||
it('respects custom maxLen', () => {
|
||||
const result = smartTruncate('a'.repeat(50), 30);
|
||||
assert.strictEqual(result.length, 30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeToolName', () => {
|
||||
it('returns unchanged for valid names without duplicates', () => {
|
||||
const result = sanitizeToolName('valid_name');
|
||||
assert.deepStrictEqual(result, { sanitized: 'valid_name', changed: false });
|
||||
});
|
||||
|
||||
it('returns unchanged for 64-char valid names without duplicates', () => {
|
||||
const name64 = 'a'.repeat(64);
|
||||
const result = sanitizeToolName(name64);
|
||||
assert.deepStrictEqual(result, { sanitized: name64, changed: false });
|
||||
});
|
||||
|
||||
it('removes duplicate segments', () => {
|
||||
const result = sanitizeToolName('foo__bar__bar');
|
||||
assert.strictEqual(result.sanitized, 'foo__bar');
|
||||
assert.strictEqual(result.changed, true);
|
||||
});
|
||||
|
||||
it('truncates names exceeding 64 chars', () => {
|
||||
const longName = 'a'.repeat(100);
|
||||
const result = sanitizeToolName(longName);
|
||||
|
||||
assert.strictEqual(result.sanitized.length, 64);
|
||||
assert.strictEqual(result.changed, true);
|
||||
});
|
||||
|
||||
it('handles combined dedupe + truncate', () => {
|
||||
// Create a name that needs both dedupe and truncate
|
||||
const segment = 'very_long_segment_name_here';
|
||||
const duplicated = `prefix__${segment}__${segment}__${segment}`;
|
||||
const result = sanitizeToolName(duplicated);
|
||||
|
||||
assert.ok(result.sanitized.length <= 64);
|
||||
assert.strictEqual(result.changed, true);
|
||||
});
|
||||
|
||||
it('sanitizes real-world problematic MCP tool name', () => {
|
||||
const problematic = 'gitmcp__plus-pro-components__plus-pro-components';
|
||||
const result = sanitizeToolName(problematic);
|
||||
|
||||
assert.ok(result.sanitized.length <= 64);
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.strictEqual(result.sanitized, 'gitmcp__plus-pro-components');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user