From 63633507d2eeeff7c76b73481894dd9f1148ba93 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 16:04:17 -0500 Subject: [PATCH] feat(cliproxy): add ToolSanitizationProxy for Gemini 64-char limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/cliproxy/cliproxy-executor.ts | 79 ++- src/cliproxy/index.ts | 16 + src/cliproxy/tool-name-mapper.ts | 153 ++++++ src/cliproxy/tool-name-sanitizer.ts | 130 +++++ src/cliproxy/tool-sanitization-proxy.ts | 457 ++++++++++++++++++ tests/unit/cliproxy/tool-name-mapper.test.js | 257 ++++++++++ .../unit/cliproxy/tool-name-sanitizer.test.js | 174 +++++++ 7 files changed, 1251 insertions(+), 15 deletions(-) create mode 100644 src/cliproxy/tool-name-mapper.ts create mode 100644 src/cliproxy/tool-name-sanitizer.ts create mode 100644 src/cliproxy/tool-sanitization-proxy.ts create mode 100644 tests/unit/cliproxy/tool-name-mapper.test.js create mode 100644 tests/unit/cliproxy/tool-name-sanitizer.test.js diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 96b1e14b..3842ee6b 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -38,6 +38,7 @@ import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { CodexReasoningProxy } from './codex-reasoning-proxy'; +import { ToolSanitizationProxy } from './tool-sanitization-proxy'; import { findAccountByQuery, getProviderAccounts, @@ -841,6 +842,37 @@ export async function execClaudeWithCLIProxy( // This adds thinking suffix like model(high) or model(8192) for CLIProxyAPIPlus applyThinkingConfig(envVars, provider, thinkingOverride); + // Tool sanitization proxy - applies to ALL CLIProxy providers. + // Sanitizes MCP tool names exceeding Gemini's 64-char limit. + // Chain: Claude CLI → ToolSanitizationProxy → [CodexReasoningProxy] → CLIProxy → Gemini + let toolSanitizationProxy: ToolSanitizationProxy | null = null; + let toolSanitizationPort: number | null = null; + + if (envVars.ANTHROPIC_BASE_URL) { + try { + toolSanitizationProxy = new ToolSanitizationProxy({ + upstreamBaseUrl: envVars.ANTHROPIC_BASE_URL, + verbose, + warnOnSanitize: true, + }); + toolSanitizationPort = await toolSanitizationProxy.start(); + log(`Tool sanitization proxy active on port ${toolSanitizationPort}`); + } catch (error) { + // Non-fatal: continue without sanitization + const err = error as Error; + toolSanitizationProxy = null; + toolSanitizationPort = null; + if (verbose) { + console.error(warn(`Tool sanitization proxy disabled: ${err.message}`)); + } + } + } + + // Determine the effective upstream URL after tool sanitization + const postSanitizationBaseUrl = toolSanitizationPort + ? `http://127.0.0.1:${toolSanitizationPort}` + : envVars.ANTHROPIC_BASE_URL; + // Codex-only: inject OpenAI reasoning effort based on tier model mapping. // Maps by request.model: // - OPUS/default model → xhigh @@ -850,7 +882,7 @@ export async function execClaudeWithCLIProxy( let codexReasoningProxy: CodexReasoningProxy | null = null; let codexReasoningPort: number | null = null; if (provider === 'codex') { - if (!envVars.ANTHROPIC_BASE_URL) { + if (!postSanitizationBaseUrl) { log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled'); } else { try { @@ -861,7 +893,7 @@ export async function execClaudeWithCLIProxy( // because remote CLIProxyAPI uses root paths (/v1/messages), not provider-prefixed const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined; codexReasoningProxy = new CodexReasoningProxy({ - upstreamBaseUrl: envVars.ANTHROPIC_BASE_URL, + upstreamBaseUrl: postSanitizationBaseUrl, verbose, defaultEffort: 'medium', traceFilePath: traceEnabled @@ -890,13 +922,22 @@ export async function execClaudeWithCLIProxy( } } - const effectiveEnvVars = - codexReasoningProxy && codexReasoningPort - ? { - ...envVars, - ANTHROPIC_BASE_URL: `http://127.0.0.1:${codexReasoningPort}/api/provider/codex`, - } - : envVars; + // Determine the final ANTHROPIC_BASE_URL based on active proxies + // Chain order: Claude CLI → [CodexReasoningProxy] → [ToolSanitizationProxy] → CLIProxy + // The outermost active proxy becomes the effective base URL + let finalBaseUrl = envVars.ANTHROPIC_BASE_URL; + if (toolSanitizationPort) { + finalBaseUrl = `http://127.0.0.1:${toolSanitizationPort}`; + } + if (codexReasoningPort) { + // Codex reasoning proxy is the outermost layer for codex provider + finalBaseUrl = `http://127.0.0.1:${codexReasoningPort}/api/provider/codex`; + } + + const effectiveEnvVars = { + ...envVars, + ANTHROPIC_BASE_URL: finalBaseUrl, + }; const webSearchEnv = getWebSearchHookEnv(); const env = { ...process.env, @@ -906,16 +947,12 @@ export async function execClaudeWithCLIProxy( }; log(`Claude env: ANTHROPIC_BASE_URL=${effectiveEnvVars.ANTHROPIC_BASE_URL}`); - log(`Claude env: ANTHROPIC_MODEL=${effectiveEnvVars.ANTHROPIC_MODEL}`); + log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`); if (Object.keys(webSearchEnv).length > 0) { log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`); } // Log global env vars for visibility - if ( - effectiveEnvVars.DISABLE_TELEMETRY || - effectiveEnvVars.DISABLE_ERROR_REPORTING || - effectiveEnvVars.DISABLE_BUG_COMMAND - ) { + if (envVars.DISABLE_TELEMETRY || envVars.DISABLE_ERROR_REPORTING || envVars.DISABLE_BUG_COMMAND) { log(`Claude env: Global env applied (telemetry/reporting disabled)`); } @@ -983,6 +1020,10 @@ export async function execClaudeWithCLIProxy( codexReasoningProxy.stop(); } + if (toolSanitizationProxy) { + toolSanitizationProxy.stop(); + } + if (httpsTunnel) { httpsTunnel.stop(); } @@ -1007,6 +1048,10 @@ export async function execClaudeWithCLIProxy( codexReasoningProxy.stop(); } + if (toolSanitizationProxy) { + toolSanitizationProxy.stop(); + } + if (httpsTunnel) { httpsTunnel.stop(); } @@ -1026,6 +1071,10 @@ export async function execClaudeWithCLIProxy( codexReasoningProxy.stop(); } + if (toolSanitizationProxy) { + toolSanitizationProxy.stop(); + } + if (httpsTunnel) { httpsTunnel.stop(); } diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index f762e438..b09c5741 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -189,3 +189,19 @@ export { getSyncableProfileCount, isProfileSyncable, } from './sync'; + +// Tool name sanitization (for Gemini 64-char limit compliance) +export type { SanitizeResult } from './tool-name-sanitizer'; +export { + sanitizeToolName, + isValidToolName, + removeDuplicateSegments, + smartTruncate, + GEMINI_MAX_TOOL_NAME_LENGTH, +} from './tool-name-sanitizer'; + +export type { Tool, ToolUseBlock, ContentBlock, SanitizationChange } from './tool-name-mapper'; +export { ToolNameMapper } from './tool-name-mapper'; + +export type { ToolSanitizationProxyConfig } from './tool-sanitization-proxy'; +export { ToolSanitizationProxy } from './tool-sanitization-proxy'; diff --git a/src/cliproxy/tool-name-mapper.ts b/src/cliproxy/tool-name-mapper.ts new file mode 100644 index 00000000..8cc67288 --- /dev/null +++ b/src/cliproxy/tool-name-mapper.ts @@ -0,0 +1,153 @@ +/** + * Tool Name Mapper + * + * Bidirectional mapping class to track original ↔ sanitized tool names. + * Used to restore original names in API responses after sanitization. + * + * Flow: + * 1. Request: registerTools() sanitizes names and stores mapping + * 2. Response: restoreToolUse() restores original names using mapping + */ + +import { sanitizeToolName, type SanitizeResult } from './tool-name-sanitizer'; + +/** MCP tool definition from Claude API */ +export interface Tool { + name: string; + description?: string; + input_schema?: Record; + [key: string]: unknown; +} + +/** Tool use content block from Claude API response */ +export interface ToolUseBlock { + type: 'tool_use'; + id: string; + name: string; + input: Record; +} + +/** Content block from Claude API response (union type) */ +export interface ContentBlock { + type: string; + [key: string]: unknown; +} + +/** Record of a sanitization change */ +export interface SanitizationChange { + original: string; + sanitized: string; +} + +/** + * Bidirectional mapper for tool name sanitization. + * + * Maintains a per-request mapping between sanitized and original tool names. + * Must be cleared between requests to avoid memory leaks. + */ +export class ToolNameMapper { + /** Map from sanitized name → original name */ + private mapping: Map = new Map(); + + /** List of all changes made during registration */ + private changes: SanitizationChange[] = []; + + /** + * Register tools and sanitize their names. + * Stores mapping for later restoration. + * + * @param tools Array of tool definitions + * @returns Array of tools with sanitized names + */ + registerTools(tools: Tool[]): Tool[] { + return tools.map((tool) => { + const result: SanitizeResult = sanitizeToolName(tool.name); + + if (result.changed) { + // Store mapping: sanitized → original + this.mapping.set(result.sanitized, tool.name); + this.changes.push({ + original: tool.name, + sanitized: result.sanitized, + }); + } + + return { + ...tool, + name: result.sanitized, + }; + }); + } + + /** + * Restore original tool names in content blocks. + * Looks for tool_use blocks and restores their names. + * + * @param content Array of content blocks from API response + * @returns Array with restored tool names + */ + restoreToolUse(content: ContentBlock[]): ContentBlock[] { + return content.map((block) => { + if (block.type !== 'tool_use') { + return block; + } + + // Type guard: we know this is a tool_use block + const toolUseName = block.name as string | undefined; + if (!toolUseName) { + return block; + } + + const originalName = this.mapping.get(toolUseName); + + if (originalName) { + return { + ...block, + name: originalName, + }; + } + + return block; + }); + } + + /** + * Restore tool name in a single tool_use block. + * Useful for streaming responses. + * + * @param name The sanitized tool name + * @returns Original name if mapped, otherwise the input name + */ + restoreName(name: string): string { + return this.mapping.get(name) ?? name; + } + + /** + * Check if any sanitization occurred during registration. + */ + hasChanges(): boolean { + return this.changes.length > 0; + } + + /** + * Get all sanitization changes for logging. + */ + getChanges(): SanitizationChange[] { + return [...this.changes]; + } + + /** + * Get the number of tools that were sanitized. + */ + getChangeCount(): number { + return this.changes.length; + } + + /** + * Clear all mappings. Call between requests. + */ + clear(): void { + this.mapping.clear(); + this.changes = []; + } +} diff --git a/src/cliproxy/tool-name-sanitizer.ts b/src/cliproxy/tool-name-sanitizer.ts new file mode 100644 index 00000000..f69b8a59 --- /dev/null +++ b/src/cliproxy/tool-name-sanitizer.ts @@ -0,0 +1,130 @@ +/** + * Tool Name Sanitizer + * + * Sanitizes MCP tool names to comply with Gemini API constraints: + * - Max 64 characters + * - Must start with letter or underscore + * - Only a-z A-Z 0-9 _ . : - allowed + * + * Strategies: + * 1. Remove duplicate segments (e.g., gitmcp__foo__foo → gitmcp__foo) + * 2. Smart truncate with hash suffix if still >64 chars + */ + +import { createHash } from 'crypto'; + +/** Maximum tool name length allowed by Gemini API */ +export const GEMINI_MAX_TOOL_NAME_LENGTH = 64; + +/** Valid characters pattern for Gemini tool names */ +const VALID_CHARS_REGEX = /^[a-zA-Z_][a-zA-Z0-9_.:/-]*$/; + +/** Result of sanitization operation */ +export interface SanitizeResult { + /** The sanitized tool name */ + sanitized: string; + /** Whether the name was changed */ + changed: boolean; +} + +/** + * Check if a tool name is valid for Gemini API. + * + * Requirements: + * - Length <= 64 characters + * - Starts with letter or underscore + * - Contains only valid characters: a-z A-Z 0-9 _ . : - + */ +export function isValidToolName(name: string): boolean { + if (!name || name.length === 0) { + return false; + } + if (name.length > GEMINI_MAX_TOOL_NAME_LENGTH) { + return false; + } + return VALID_CHARS_REGEX.test(name); +} + +/** + * Remove consecutive duplicate segments separated by '__'. + * + * Examples: + * - 'gitmcp__foo__foo' → 'gitmcp__foo' + * - 'a__b__c__b__c' → 'a__b__c' + * - 'no_dupes' → 'no_dupes' + */ +export function removeDuplicateSegments(name: string): string { + const segments = name.split('__'); + const deduped: string[] = []; + + for (const segment of segments) { + // Only add if different from previous segment + if (deduped.length === 0 || deduped[deduped.length - 1] !== segment) { + deduped.push(segment); + } + } + + return deduped.join('__'); +} + +/** + * Generate a short hash from a string for truncation suffix. + * Uses first 6 characters of MD5 hash (16M combinations). + */ +function generateShortHash(input: string): string { + return createHash('md5').update(input).digest('hex').slice(0, 6); +} + +/** + * Smart truncate a name to fit within maxLen. + * Preserves start of name and appends hash suffix for uniqueness. + * + * Format: _<6-char-hash> + * + * @param name The name to truncate + * @param maxLen Maximum allowed length (default: 64) + */ +export function smartTruncate(name: string, maxLen: number = GEMINI_MAX_TOOL_NAME_LENGTH): string { + if (name.length <= maxLen) { + return name; + } + + // Format: prefix + '_' + 6-char hash = 7 chars for suffix + const hash = generateShortHash(name); + const prefixLen = maxLen - 7; // 7 = '_' (1) + hash (6) + const prefix = name.slice(0, prefixLen); + + return `${prefix}_${hash}`; +} + +/** + * Sanitize a tool name to comply with Gemini API constraints. + * + * Process: + * 1. Remove duplicate segments (always, as duplicates are likely unintentional) + * 2. Truncate with hash if >64 chars + * 3. Return result with changed flag + * + * @param name The original tool name + * @returns Sanitization result with sanitized name and changed flag + */ +export function sanitizeToolName(name: string): SanitizeResult { + // Step 1: Always try to remove duplicate segments + // Duplicates like gitmcp__foo__foo are likely unintentional from MCP naming + let sanitized = removeDuplicateSegments(name); + + // Step 2: Truncate if still too long + if (sanitized.length > GEMINI_MAX_TOOL_NAME_LENGTH) { + sanitized = smartTruncate(sanitized); + } + + // Step 3: If still invalid after sanitization, apply truncation to original + if (!isValidToolName(sanitized)) { + sanitized = smartTruncate(name); + } + + return { + sanitized, + changed: sanitized !== name, + }; +} diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts new file mode 100644 index 00000000..ae241742 --- /dev/null +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -0,0 +1,457 @@ +/** + * Tool Sanitization Proxy + * + * 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 + * + * Follows CodexReasoningProxy pattern for consistency. + */ + +import * as http from 'http'; +import * as https from 'https'; +import { URL } from 'url'; +import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper'; + +export interface ToolSanitizationProxyConfig { + /** Upstream CLIProxy URL */ + upstreamBaseUrl: string; + /** Enable verbose logging */ + verbose?: boolean; + /** Log warnings when sanitization occurs */ + warnOnSanitize?: boolean; + /** Request timeout in milliseconds */ + timeoutMs?: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export class ToolSanitizationProxy { + private server: http.Server | null = null; + private port: number | null = null; + private readonly config: Required; + + constructor(config: ToolSanitizationProxyConfig) { + this.config = { + upstreamBaseUrl: config.upstreamBaseUrl, + verbose: config.verbose ?? false, + warnOnSanitize: config.warnOnSanitize ?? true, + timeoutMs: config.timeoutMs ?? 120000, + }; + } + + private log(message: string): void { + if (this.config.verbose) { + console.error(`[tool-sanitization-proxy] ${message}`); + } + } + + private warn(message: string): void { + if (this.config.warnOnSanitize) { + console.error(`[!] ${message}`); + } + } + + /** + * Start the proxy server on an ephemeral port. + * @returns The assigned port number + */ + async start(): Promise { + if (this.server) return this.port ?? 0; + + return new Promise((resolve, reject) => { + this.server = http.createServer((req, res) => { + void this.handleRequest(req, res); + }); + + this.server.listen(0, '127.0.0.1', () => { + const address = this.server?.address(); + this.port = typeof address === 'object' && address ? address.port : 0; + resolve(this.port); + }); + + this.server.on('error', (err) => reject(err)); + }); + } + + /** + * Stop the proxy server. + */ + stop(): void { + if (!this.server) return; + this.server.close(); + this.server = null; + this.port = null; + } + + /** + * Get the port the proxy is listening on. + */ + getPort(): number | null { + return this.port; + } + + private readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const maxSize = 10 * 1024 * 1024; // 10MB + let total = 0; + + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > maxSize) { + req.destroy(); + reject(new Error('Request body too large (max 10MB)')); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const method = req.method || 'GET'; + const requestPath = req.url || '/'; + const upstreamBase = new URL(this.config.upstreamBaseUrl); + const fullUpstreamUrl = new URL(requestPath, upstreamBase); + + this.log(`${method} ${requestPath} → ${fullUpstreamUrl.href}`); + + // Only buffer+rewrite JSON POST requests + const contentType = String(req.headers['content-type'] || ''); + const isJson = contentType.includes('application/json'); + const shouldRewrite = isJson && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()); + + try { + if (!shouldRewrite) { + await this.forwardRaw(req, res, fullUpstreamUrl); + return; + } + + const rawBody = await this.readBody(req); + let parsed: unknown; + try { + parsed = rawBody.length ? JSON.parse(rawBody) : {}; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON in request body' })); + return; + } + + // Create mapper for this request + const mapper = new ToolNameMapper(); + + // Sanitize tools if present + let modifiedBody = parsed; + if (isRecord(parsed) && Array.isArray(parsed.tools)) { + const sanitizedTools = mapper.registerTools(parsed.tools as Tool[]); + modifiedBody = { ...parsed, tools: sanitizedTools }; + + // Log sanitization warnings + if (mapper.hasChanges()) { + const changes = mapper.getChanges(); + for (const change of changes) { + this.warn(`Tool name sanitized: "${change.original}" → "${change.sanitized}"`); + } + this.log(`Sanitized ${changes.length} tool name(s)`); + } + } + + // Check if streaming is requested + const isStreaming = isRecord(parsed) && parsed.stream === true; + + if (isStreaming) { + await this.forwardJsonStreaming(req, res, fullUpstreamUrl, modifiedBody, mapper); + } else { + await this.forwardJsonBuffered(req, res, fullUpstreamUrl, modifiedBody, mapper); + } + } catch (error) { + const err = error as Error; + this.log(`Error: ${err.message}`); + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + } + res.end(JSON.stringify({ error: err.message })); + } + } + + private buildForwardHeaders( + originalHeaders: http.IncomingHttpHeaders, + bodyString?: string + ): http.OutgoingHttpHeaders { + const headers: http.OutgoingHttpHeaders = {}; + + // RFC 7230 hop-by-hop headers that should not be forwarded + const hopByHop = new Set([ + 'host', + 'content-length', + 'connection', + 'transfer-encoding', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'upgrade', + ]); + + for (const [key, value] of Object.entries(originalHeaders)) { + if (!value) continue; + const lower = key.toLowerCase(); + if (hopByHop.has(lower)) continue; + headers[key] = value; + } + + if (bodyString !== undefined) { + headers['Content-Type'] = headers['Content-Type'] || 'application/json'; + headers['Content-Length'] = Buffer.byteLength(bodyString); + } + + return headers; + } + + private getRequestFn(url: URL): typeof http.request | typeof https.request { + return url.protocol === 'https:' ? https.request : http.request; + } + + private forwardRaw( + originalReq: http.IncomingMessage, + clientRes: http.ServerResponse, + upstreamUrl: URL + ): Promise { + return new Promise((resolve, reject) => { + const requestFn = this.getRequestFn(upstreamUrl); + const upstreamReq = requestFn( + { + protocol: upstreamUrl.protocol, + hostname: upstreamUrl.hostname, + port: upstreamUrl.port, + path: upstreamUrl.pathname + upstreamUrl.search, + method: originalReq.method, + timeout: this.config.timeoutMs, + headers: this.buildForwardHeaders(originalReq.headers), + }, + (upstreamRes) => { + clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); + upstreamRes.pipe(clientRes); + upstreamRes.on('end', () => resolve()); + upstreamRes.on('error', reject); + } + ); + + upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); + upstreamReq.on('error', reject); + originalReq.pipe(upstreamReq); + }); + } + + /** + * Forward JSON request and buffer response for tool name restoration. + */ + private forwardJsonBuffered( + originalReq: http.IncomingMessage, + clientRes: http.ServerResponse, + upstreamUrl: URL, + body: unknown, + mapper: ToolNameMapper + ): Promise { + return new Promise((resolve, reject) => { + const bodyString = JSON.stringify(body); + const requestFn = this.getRequestFn(upstreamUrl); + const upstreamReq = requestFn( + { + protocol: upstreamUrl.protocol, + hostname: upstreamUrl.hostname, + port: upstreamUrl.port, + path: upstreamUrl.pathname + upstreamUrl.search, + method: originalReq.method, + timeout: this.config.timeoutMs, + headers: this.buildForwardHeaders(originalReq.headers, bodyString), + }, + (upstreamRes) => { + const chunks: Buffer[] = []; + + upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk)); + upstreamRes.on('end', () => { + try { + const responseBody = Buffer.concat(chunks).toString('utf8'); + const contentType = upstreamRes.headers['content-type'] || ''; + + // Only process JSON responses with tool_use blocks + if (contentType.includes('application/json') && mapper.hasChanges()) { + try { + const parsed = JSON.parse(responseBody); + if (isRecord(parsed) && Array.isArray(parsed.content)) { + parsed.content = mapper.restoreToolUse(parsed.content as ContentBlock[]); + const modifiedResponse = JSON.stringify(parsed); + + // Update content-length header + const headers = { ...upstreamRes.headers }; + headers['content-length'] = String(Buffer.byteLength(modifiedResponse)); + + clientRes.writeHead(upstreamRes.statusCode || 200, headers); + clientRes.end(modifiedResponse); + resolve(); + return; + } + } catch { + // JSON parse failed, pass through unchanged + } + } + + // Pass through unchanged + clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); + clientRes.end(responseBody); + resolve(); + } catch (err) { + reject(err); + } + }); + upstreamRes.on('error', reject); + } + ); + + upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); + upstreamReq.on('error', reject); + upstreamReq.write(bodyString); + upstreamReq.end(); + }); + } + + /** + * Forward JSON request and stream response with tool name restoration. + * Handles SSE (Server-Sent Events) format. + */ + private forwardJsonStreaming( + originalReq: http.IncomingMessage, + clientRes: http.ServerResponse, + upstreamUrl: URL, + body: unknown, + mapper: ToolNameMapper + ): Promise { + return new Promise((resolve, reject) => { + const bodyString = JSON.stringify(body); + const requestFn = this.getRequestFn(upstreamUrl); + const upstreamReq = requestFn( + { + protocol: upstreamUrl.protocol, + hostname: upstreamUrl.hostname, + port: upstreamUrl.port, + path: upstreamUrl.pathname + upstreamUrl.search, + method: originalReq.method, + timeout: this.config.timeoutMs, + headers: this.buildForwardHeaders(originalReq.headers, bodyString), + }, + (upstreamRes) => { + clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); + + // If no changes were made, just pipe through + if (!mapper.hasChanges()) { + upstreamRes.pipe(clientRes); + upstreamRes.on('end', () => resolve()); + upstreamRes.on('error', reject); + return; + } + + // Process SSE events for tool name restoration + let buffer = ''; + + upstreamRes.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + + // Process complete SSE events + const events = buffer.split('\n\n'); + buffer = events.pop() || ''; // Keep incomplete event in buffer + + for (const event of events) { + if (!event.trim()) continue; + + const processedEvent = this.processSSEEvent(event, mapper); + clientRes.write(processedEvent + '\n\n'); + } + }); + + upstreamRes.on('end', () => { + // Process any remaining buffer + if (buffer.trim()) { + const processedEvent = this.processSSEEvent(buffer, mapper); + clientRes.write(processedEvent + '\n\n'); + } + clientRes.end(); + resolve(); + }); + + upstreamRes.on('error', reject); + } + ); + + upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); + upstreamReq.on('error', reject); + upstreamReq.write(bodyString); + upstreamReq.end(); + }); + } + + /** + * Process a single SSE event, restoring tool names if present. + */ + private processSSEEvent(event: string, mapper: ToolNameMapper): string { + // Parse SSE format: data: {...} + const lines = event.split('\n'); + const processedLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6); // Remove 'data: ' prefix + + // Skip [DONE] marker + if (jsonStr.trim() === '[DONE]') { + processedLines.push(line); + continue; + } + + try { + const data = JSON.parse(jsonStr); + + // Handle content_block_start with tool_use + if ( + isRecord(data) && + data.type === 'content_block_start' && + isRecord(data.content_block) && + data.content_block.type === 'tool_use' && + typeof data.content_block.name === 'string' + ) { + const originalName = mapper.restoreName(data.content_block.name); + data.content_block.name = originalName; + processedLines.push('data: ' + JSON.stringify(data)); + continue; + } + + // Handle message with content array (final message) + if (isRecord(data) && Array.isArray(data.content)) { + data.content = mapper.restoreToolUse(data.content as ContentBlock[]); + processedLines.push('data: ' + JSON.stringify(data)); + continue; + } + + // Pass through unchanged + processedLines.push(line); + } catch { + // Not valid JSON, pass through unchanged + processedLines.push(line); + } + } else { + // Non-data lines (event:, id:, etc.) pass through + processedLines.push(line); + } + } + + return processedLines.join('\n'); + } +} diff --git a/tests/unit/cliproxy/tool-name-mapper.test.js b/tests/unit/cliproxy/tool-name-mapper.test.js new file mode 100644 index 00000000..ee37f2f0 --- /dev/null +++ b/tests/unit/cliproxy/tool-name-mapper.test.js @@ -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'); + }); + }); +}); diff --git a/tests/unit/cliproxy/tool-name-sanitizer.test.js b/tests/unit/cliproxy/tool-name-sanitizer.test.js new file mode 100644 index 00000000..0f77af5a --- /dev/null +++ b/tests/unit/cliproxy/tool-name-sanitizer.test.js @@ -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'); + }); + }); +});