♻️ refactor: consolidate MCP tool name utilities into shared module

- Merge buildFunctionCallToolName from src/main/utils/mcp.ts into packages/shared/mcp.ts
- Create unified buildMcpToolName base function with options for prefix, delimiter, maxLength, existingNames
- Fix toCamelCase to normalize uppercase snake case (MY_SERVER → myServer)
- Fix maxLength + existingNames interaction to respect length limit when adding collision suffix
- Add comprehensive JSDoc documentation
- Update tests and hub.test.ts for new lowercase normalization behavior
This commit is contained in:
Vaayne 2025-12-29 20:10:36 +08:00
parent e1829b807e
commit d005d4291f
6 changed files with 337 additions and 278 deletions

View File

@ -1,13 +1,23 @@
/**
* Convert a string to camelCase, ensuring it's a valid JavaScript identifier.
*
* - Normalizes to lowercase first, then capitalizes word boundaries
* - Non-alphanumeric characters are treated as word separators
* - Non-ASCII characters are dropped (ASCII-only output)
* - If result starts with a digit, prefixes with underscore
*
* @example
* toCamelCase('my-server') // 'myServer'
* toCamelCase('MY_SERVER') // 'myServer'
* toCamelCase('123tool') // '_123tool'
*/
export function toCamelCase(str: string): string {
let result = str
.replace(/[^a-zA-Z0-9]+(.)/g, (_, char) => char.toUpperCase())
.replace(/^[A-Z]/, (char) => char.toLowerCase())
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+(.)/g, (_, char) => char.toUpperCase())
.replace(/[^a-zA-Z0-9]/g, '')
// Ensure valid JS identifier: must start with letter or underscore
if (result && !/^[a-zA-Z_]/.test(result)) {
result = '_' + result
}
@ -15,29 +25,92 @@ export function toCamelCase(str: string): string {
return result
}
export type McpToolNameOptions = {
/** Prefix added before the name (e.g., 'mcp__'). Must be JS-identifier-safe. */
prefix?: string
/** Delimiter between server and tool parts (e.g., '_' or '__'). Must be JS-identifier-safe. */
delimiter?: string
/** Maximum length of the final name. Suffix numbers for uniqueness are included in this limit. */
maxLength?: number
/** Mutable Set for collision detection. The final name will be added to this Set. */
existingNames?: Set<string>
}
/**
* Build a valid JavaScript function name from server and tool names.
* Uses camelCase for both parts.
*
* @param serverName - The MCP server name (optional)
* @param toolName - The tool name
* @param options - Configuration options
* @returns A valid JS identifier
*/
export function buildMcpToolName(
serverName: string | undefined,
toolName: string,
options: McpToolNameOptions = {}
): string {
const { prefix = '', delimiter = '_', maxLength, existingNames } = options
const serverPart = serverName ? toCamelCase(serverName) : ''
const toolPart = toCamelCase(toolName)
const baseName = serverPart ? `${prefix}${serverPart}${delimiter}${toolPart}` : `${prefix}${toolPart}`
if (!existingNames) {
return maxLength ? truncateToLength(baseName, maxLength) : baseName
}
let name = maxLength ? truncateToLength(baseName, maxLength) : baseName
let counter = 1
while (existingNames.has(name)) {
const suffix = String(counter)
const truncatedBase = maxLength ? truncateToLength(baseName, maxLength - suffix.length) : baseName
name = `${truncatedBase}${suffix}`
counter++
}
existingNames.add(name)
return name
}
function truncateToLength(str: string, maxLength: number): string {
if (str.length <= maxLength) {
return str
}
return str.slice(0, maxLength).replace(/_+$/, '')
}
/**
* Generate a unique function name from server name and tool name.
* Format: serverName_toolName (camelCase)
*
* @example
* generateMcpToolFunctionName('github', 'search_issues') // 'github_searchIssues'
*/
export function generateMcpToolFunctionName(
serverName: string | undefined,
toolName: string,
existingNames?: Set<string>
): string {
const serverPrefix = serverName ? toCamelCase(serverName) : ''
const toolNameCamel = toCamelCase(toolName)
const baseName = serverPrefix ? `${serverPrefix}_${toolNameCamel}` : toolNameCamel
if (!existingNames) {
return baseName
}
let name = baseName
let counter = 1
while (existingNames.has(name)) {
name = `${baseName}${counter}`
counter++
}
existingNames.add(name)
return name
return buildMcpToolName(serverName, toolName, { existingNames })
}
/**
* Builds a valid JavaScript function name for MCP tool calls.
* Format: mcp__{serverName}__{toolName}
*
* @param serverName - The MCP server name
* @param toolName - The tool name from the server
* @returns A valid JS identifier in format mcp__{server}__{tool}, max 63 chars
*
* @example
* buildFunctionCallToolName('github', 'search_issues') // 'mcp__github__searchIssues'
*/
export function buildFunctionCallToolName(serverName: string, toolName: string): string {
return buildMcpToolName(serverName, toolName, {
prefix: 'mcp__',
delimiter: '__',
maxLength: 63
})
}

View File

@ -0,0 +1,240 @@
import { buildFunctionCallToolName, buildMcpToolName, generateMcpToolFunctionName, toCamelCase } from '@shared/mcp'
import { describe, expect, it } from 'vitest'
describe('toCamelCase', () => {
it('should convert hyphenated strings', () => {
expect(toCamelCase('my-server')).toBe('myServer')
expect(toCamelCase('my-tool-name')).toBe('myToolName')
})
it('should convert underscored strings', () => {
expect(toCamelCase('my_server')).toBe('myServer')
expect(toCamelCase('search_issues')).toBe('searchIssues')
})
it('should handle mixed delimiters', () => {
expect(toCamelCase('my-server_name')).toBe('myServerName')
})
it('should handle leading numbers by prefixing underscore', () => {
expect(toCamelCase('123server')).toBe('_123server')
})
it('should handle special characters', () => {
expect(toCamelCase('test@server!')).toBe('testServer')
expect(toCamelCase('tool#name$')).toBe('toolName')
})
it('should trim whitespace', () => {
expect(toCamelCase(' server ')).toBe('server')
})
it('should handle empty string', () => {
expect(toCamelCase('')).toBe('')
})
it('should handle uppercase snake case', () => {
expect(toCamelCase('MY_SERVER')).toBe('myServer')
expect(toCamelCase('SEARCH_ISSUES')).toBe('searchIssues')
})
it('should handle mixed case', () => {
expect(toCamelCase('MyServer')).toBe('myserver')
expect(toCamelCase('myTOOL')).toBe('mytool')
})
})
describe('buildMcpToolName', () => {
it('should build basic name with defaults', () => {
expect(buildMcpToolName('github', 'search_issues')).toBe('github_searchIssues')
})
it('should handle undefined server name', () => {
expect(buildMcpToolName(undefined, 'search_issues')).toBe('searchIssues')
})
it('should apply custom prefix and delimiter', () => {
expect(buildMcpToolName('github', 'search', { prefix: 'mcp__', delimiter: '__' })).toBe('mcp__github__search')
})
it('should respect maxLength', () => {
const result = buildMcpToolName('veryLongServerName', 'veryLongToolName', { maxLength: 20 })
expect(result.length).toBeLessThanOrEqual(20)
})
it('should handle collision with existingNames', () => {
const existingNames = new Set(['github_search'])
const result = buildMcpToolName('github', 'search', { existingNames })
expect(result).toBe('github_search1')
expect(existingNames.has('github_search1')).toBe(true)
})
it('should respect maxLength when adding collision suffix', () => {
const existingNames = new Set(['a'.repeat(20)])
const result = buildMcpToolName('a'.repeat(20), '', { maxLength: 20, existingNames })
expect(result.length).toBeLessThanOrEqual(20)
expect(existingNames.has(result)).toBe(true)
})
it('should handle multiple collisions with maxLength', () => {
const existingNames = new Set(['abcd', 'abc1', 'abc2'])
const result = buildMcpToolName('abcd', '', { maxLength: 4, existingNames })
expect(result).toBe('abc3')
expect(result.length).toBeLessThanOrEqual(4)
})
})
describe('generateMcpToolFunctionName', () => {
it('should return format serverName_toolName in camelCase', () => {
expect(generateMcpToolFunctionName('github', 'search_issues')).toBe('github_searchIssues')
})
it('should handle hyphenated names', () => {
expect(generateMcpToolFunctionName('my-server', 'my-tool')).toBe('myServer_myTool')
})
it('should handle undefined server name', () => {
expect(generateMcpToolFunctionName(undefined, 'search_issues')).toBe('searchIssues')
})
it('should handle collision detection', () => {
const existingNames = new Set<string>()
const first = generateMcpToolFunctionName('github', 'search', existingNames)
const second = generateMcpToolFunctionName('github', 'search', existingNames)
expect(first).toBe('github_search')
expect(second).toBe('github_search1')
})
})
describe('buildFunctionCallToolName', () => {
describe('basic format', () => {
it('should return format mcp__{server}__{tool} in camelCase', () => {
const result = buildFunctionCallToolName('github', 'search_issues')
expect(result).toBe('mcp__github__searchIssues')
})
it('should handle simple server and tool names', () => {
expect(buildFunctionCallToolName('fetch', 'get_page')).toBe('mcp__fetch__getPage')
expect(buildFunctionCallToolName('database', 'query')).toBe('mcp__database__query')
})
})
describe('valid JavaScript identifier', () => {
it('should always start with mcp__ prefix (valid JS identifier start)', () => {
const result = buildFunctionCallToolName('123server', '456tool')
expect(result).toMatch(/^mcp__/)
})
it('should handle hyphenated names with camelCase', () => {
const result = buildFunctionCallToolName('my-server', 'my-tool')
expect(result).toBe('mcp__myServer__myTool')
})
it('should be a valid JavaScript identifier', () => {
const testCases = [
['github', 'create_issue'],
['my-server', 'fetch-data'],
['test@server', 'tool#name'],
['server.name', 'tool.action']
]
for (const [server, tool] of testCases) {
const result = buildFunctionCallToolName(server, tool)
expect(result).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
}
})
})
describe('character sanitization', () => {
it('should convert special characters to camelCase boundaries', () => {
expect(buildFunctionCallToolName('my-server', 'my-tool-name')).toBe('mcp__myServer__myToolName')
expect(buildFunctionCallToolName('test@server!', 'tool#name$')).toBe('mcp__testServer__toolName')
expect(buildFunctionCallToolName('server.name', 'tool.action')).toBe('mcp__serverName__toolAction')
})
it('should handle spaces', () => {
const result = buildFunctionCallToolName('my server', 'my tool')
expect(result).toBe('mcp__myServer__myTool')
})
})
describe('length constraints', () => {
it('should not exceed 63 characters', () => {
const longServerName = 'a'.repeat(50)
const longToolName = 'b'.repeat(50)
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result.length).toBeLessThanOrEqual(63)
})
it('should not end with underscores after truncation', () => {
const longServerName = 'a'.repeat(30)
const longToolName = 'b'.repeat(30)
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result).not.toMatch(/_+$/)
expect(result.length).toBeLessThanOrEqual(63)
})
})
describe('edge cases', () => {
it('should handle empty server name', () => {
const result = buildFunctionCallToolName('', 'tool')
expect(result).toBe('mcp__tool')
})
it('should handle empty tool name', () => {
const result = buildFunctionCallToolName('server', '')
expect(result).toBe('mcp__server__')
})
it('should trim whitespace from names', () => {
const result = buildFunctionCallToolName(' server ', ' tool ')
expect(result).toBe('mcp__server__tool')
})
it('should handle mixed case by normalizing to lowercase first', () => {
const result = buildFunctionCallToolName('MyServer', 'MyTool')
expect(result).toBe('mcp__myserver__mytool')
})
it('should handle uppercase snake case', () => {
const result = buildFunctionCallToolName('MY_SERVER', 'SEARCH_ISSUES')
expect(result).toBe('mcp__myServer__searchIssues')
})
})
describe('deterministic output', () => {
it('should produce consistent results for same input', () => {
const result1 = buildFunctionCallToolName('github', 'search_repos')
const result2 = buildFunctionCallToolName('github', 'search_repos')
expect(result1).toBe(result2)
})
it('should produce different results for different inputs', () => {
const result1 = buildFunctionCallToolName('server1', 'tool')
const result2 = buildFunctionCallToolName('server2', 'tool')
expect(result1).not.toBe(result2)
})
})
describe('real-world scenarios', () => {
it('should handle GitHub MCP server', () => {
expect(buildFunctionCallToolName('github', 'create_issue')).toBe('mcp__github__createIssue')
expect(buildFunctionCallToolName('github', 'search_repositories')).toBe('mcp__github__searchRepositories')
})
it('should handle filesystem MCP server', () => {
expect(buildFunctionCallToolName('filesystem', 'read_file')).toBe('mcp__filesystem__readFile')
expect(buildFunctionCallToolName('filesystem', 'write_file')).toBe('mcp__filesystem__writeFile')
})
it('should handle hyphenated server names (common in npm packages)', () => {
expect(buildFunctionCallToolName('cherry-fetch', 'get_page')).toBe('mcp__cherryFetch__getPage')
expect(buildFunctionCallToolName('mcp-server-github', 'search')).toBe('mcp__mcpServerGithub__search')
})
it('should handle scoped npm package style names', () => {
const result = buildFunctionCallToolName('@anthropic/mcp-server', 'chat')
expect(result).toBe('mcp__AnthropicMcpServer__chat')
})
})
})

View File

@ -97,10 +97,10 @@ describe('HubServer Integration', () => {
expect(searchResult.content).toBeDefined()
const searchText = JSON.parse(searchResult.content[0].text)
expect(searchText.total).toBeGreaterThan(0)
expect(searchText.tools).toContain('gitHub_searchRepos')
expect(searchText.tools).toContain('github_searchRepos')
const execResult = await (hubServer as any).handleExec({
code: 'return await gitHub_searchRepos({ query: "test" })'
code: 'return await github_searchRepos({ query: "test" })'
})
expect(execResult.content).toBeDefined()
@ -114,8 +114,8 @@ describe('HubServer Integration', () => {
const execResult = await (hubServer as any).handleExec({
code: `
const results = await parallel(
gitHub_searchRepos({ query: "react" }),
gitHub_getUser({ username: "octocat" })
github_searchRepos({ query: "react" }),
github_getUser({ username: "octocat" })
);
return results
`

View File

@ -6,7 +6,6 @@ import { loggerService } from '@logger'
import { getMCPServersFromRedux } from '@main/apiServer/utils/mcp'
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
import { makeSureDirExists, removeEnvProxy } from '@main/utils'
import { buildFunctionCallToolName } from '@main/utils/mcp'
import { findCommandInShellEnv, getBinaryName, getBinaryPath, isBinaryExists } from '@main/utils/process'
import getLoginShellEnvironment from '@main/utils/shell-env'
import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
@ -36,6 +35,7 @@ import { HOME_CHERRY_DIR } from '@shared/config/constant'
import type { MCPProgressEvent } from '@shared/config/types'
import type { MCPServerLogEntry } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import { buildFunctionCallToolName } from '@shared/mcp'
import { defaultAppHeaders } from '@shared/utils'
import {
BuiltinMCPServerNames,

View File

@ -1,225 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildFunctionCallToolName } from '../mcp'
describe('buildFunctionCallToolName', () => {
describe('basic format', () => {
it('should return format mcp__{server}__{tool}', () => {
const result = buildFunctionCallToolName('github', 'search_issues')
expect(result).toBe('mcp__github__search_issues')
})
it('should handle simple server and tool names', () => {
expect(buildFunctionCallToolName('fetch', 'get_page')).toBe('mcp__fetch__get_page')
expect(buildFunctionCallToolName('database', 'query')).toBe('mcp__database__query')
expect(buildFunctionCallToolName('cherry_studio', 'search')).toBe('mcp__cherry_studio__search')
})
})
describe('valid JavaScript identifier', () => {
it('should always start with mcp__ prefix (valid JS identifier start)', () => {
const result = buildFunctionCallToolName('123server', '456tool')
expect(result).toMatch(/^mcp__/)
expect(result).toBe('mcp__123server__456tool')
})
it('should only contain alphanumeric chars and underscores', () => {
const result = buildFunctionCallToolName('my-server', 'my-tool')
expect(result).toBe('mcp__my_server__my_tool')
expect(result).toMatch(/^[a-zA-Z][a-zA-Z0-9_]*$/)
})
it('should be a valid JavaScript identifier', () => {
const testCases = [
['github', 'create_issue'],
['my-server', 'fetch-data'],
['test@server', 'tool#name'],
['server.name', 'tool.action'],
['123abc', 'def456']
]
for (const [server, tool] of testCases) {
const result = buildFunctionCallToolName(server, tool)
// Valid JS identifiers match this pattern
expect(result).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
}
})
})
describe('character sanitization', () => {
it('should replace dashes with underscores', () => {
const result = buildFunctionCallToolName('my-server', 'my-tool-name')
expect(result).toBe('mcp__my_server__my_tool_name')
})
it('should replace special characters with underscores', () => {
const result = buildFunctionCallToolName('test@server!', 'tool#name$')
expect(result).toBe('mcp__test_server__tool_name')
})
it('should replace dots with underscores', () => {
const result = buildFunctionCallToolName('server.name', 'tool.action')
expect(result).toBe('mcp__server_name__tool_action')
})
it('should replace spaces with underscores', () => {
const result = buildFunctionCallToolName('my server', 'my tool')
expect(result).toBe('mcp__my_server__my_tool')
})
it('should collapse consecutive underscores', () => {
const result = buildFunctionCallToolName('my--server', 'my___tool')
expect(result).toBe('mcp__my_server__my_tool')
expect(result).not.toMatch(/_{3,}/)
})
it('should trim leading and trailing underscores from parts', () => {
const result = buildFunctionCallToolName('_server_', '_tool_')
expect(result).toBe('mcp__server__tool')
})
it('should handle names with only special characters', () => {
const result = buildFunctionCallToolName('---', '###')
expect(result).toBe('mcp____')
})
})
describe('length constraints', () => {
it('should not exceed 63 characters', () => {
const longServerName = 'a'.repeat(50)
const longToolName = 'b'.repeat(50)
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result.length).toBeLessThanOrEqual(63)
})
it('should truncate server name to max 20 chars', () => {
const longServerName = 'abcdefghijklmnopqrstuvwxyz' // 26 chars
const result = buildFunctionCallToolName(longServerName, 'tool')
expect(result).toBe('mcp__abcdefghijklmnopqrst__tool')
expect(result).toContain('abcdefghijklmnopqrst') // First 20 chars
expect(result).not.toContain('uvwxyz') // Truncated
})
it('should truncate tool name to max 35 chars', () => {
const longToolName = 'a'.repeat(40)
const result = buildFunctionCallToolName('server', longToolName)
const expectedTool = 'a'.repeat(35)
expect(result).toBe(`mcp__server__${expectedTool}`)
})
it('should not end with underscores after truncation', () => {
// Create a name that would end with underscores after truncation
const longServerName = 'a'.repeat(20)
const longToolName = 'b'.repeat(35) + '___extra'
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result).not.toMatch(/_+$/)
expect(result.length).toBeLessThanOrEqual(63)
})
it('should handle max length edge case exactly', () => {
// mcp__ (5) + server (20) + __ (2) + tool (35) = 62 chars
const server = 'a'.repeat(20)
const tool = 'b'.repeat(35)
const result = buildFunctionCallToolName(server, tool)
expect(result.length).toBe(62)
expect(result).toBe(`mcp__${'a'.repeat(20)}__${'b'.repeat(35)}`)
})
})
describe('edge cases', () => {
it('should handle empty server name', () => {
const result = buildFunctionCallToolName('', 'tool')
expect(result).toBe('mcp____tool')
})
it('should handle empty tool name', () => {
const result = buildFunctionCallToolName('server', '')
expect(result).toBe('mcp__server__')
})
it('should handle both empty names', () => {
const result = buildFunctionCallToolName('', '')
expect(result).toBe('mcp____')
})
it('should handle whitespace-only names', () => {
const result = buildFunctionCallToolName(' ', ' ')
expect(result).toBe('mcp____')
})
it('should trim whitespace from names', () => {
const result = buildFunctionCallToolName(' server ', ' tool ')
expect(result).toBe('mcp__server__tool')
})
it('should handle unicode characters', () => {
const result = buildFunctionCallToolName('服务器', '工具')
// Unicode chars are replaced with underscores, then collapsed
expect(result).toMatch(/^mcp__/)
})
it('should handle mixed case', () => {
const result = buildFunctionCallToolName('MyServer', 'MyTool')
expect(result).toBe('mcp__MyServer__MyTool')
})
})
describe('deterministic output', () => {
it('should produce consistent results for same input', () => {
const serverName = 'github'
const toolName = 'search_repos'
const result1 = buildFunctionCallToolName(serverName, toolName)
const result2 = buildFunctionCallToolName(serverName, toolName)
const result3 = buildFunctionCallToolName(serverName, toolName)
expect(result1).toBe(result2)
expect(result2).toBe(result3)
})
it('should produce different results for different inputs', () => {
const result1 = buildFunctionCallToolName('server1', 'tool')
const result2 = buildFunctionCallToolName('server2', 'tool')
const result3 = buildFunctionCallToolName('server', 'tool1')
const result4 = buildFunctionCallToolName('server', 'tool2')
expect(result1).not.toBe(result2)
expect(result3).not.toBe(result4)
})
})
describe('real-world scenarios', () => {
it('should handle GitHub MCP server', () => {
expect(buildFunctionCallToolName('github', 'create_issue')).toBe('mcp__github__create_issue')
expect(buildFunctionCallToolName('github', 'search_repositories')).toBe('mcp__github__search_repositories')
expect(buildFunctionCallToolName('github', 'get_pull_request')).toBe('mcp__github__get_pull_request')
})
it('should handle filesystem MCP server', () => {
expect(buildFunctionCallToolName('filesystem', 'read_file')).toBe('mcp__filesystem__read_file')
expect(buildFunctionCallToolName('filesystem', 'write_file')).toBe('mcp__filesystem__write_file')
expect(buildFunctionCallToolName('filesystem', 'list_directory')).toBe('mcp__filesystem__list_directory')
})
it('should handle hyphenated server names (common in npm packages)', () => {
expect(buildFunctionCallToolName('cherry-fetch', 'get_page')).toBe('mcp__cherry_fetch__get_page')
expect(buildFunctionCallToolName('mcp-server-github', 'search')).toBe('mcp__mcp_server_github__search')
})
it('should handle scoped npm package style names', () => {
const result = buildFunctionCallToolName('@anthropic/mcp-server', 'chat')
expect(result).toBe('mcp__anthropic_mcp_server__chat')
})
it('should handle tools with long descriptive names', () => {
const result = buildFunctionCallToolName('github', 'search_repositories_by_language_and_stars')
expect(result.length).toBeLessThanOrEqual(63)
expect(result).toMatch(/^mcp__github__search_repositories_by_lan/)
})
})
})

View File

@ -1,29 +0,0 @@
/**
* Builds a valid JavaScript function name for MCP tool calls.
* Format: mcp__{server_name}__{tool_name}
*
* @param serverName - The MCP server name
* @param toolName - The tool name from the server
* @returns A valid JS identifier in format mcp__{server}__{tool}, max 63 chars
*/
export function buildFunctionCallToolName(serverName: string, toolName: string): string {
// Sanitize to valid JS identifier chars (alphanumeric + underscore only)
const sanitize = (str: string): string =>
str
.trim()
.replace(/[^a-zA-Z0-9]/g, '_') // Replace all non-alphanumeric with underscore
.replace(/_{2,}/g, '_') // Collapse multiple underscores
.replace(/^_+|_+$/g, '') // Trim leading/trailing underscores
const server = sanitize(serverName).slice(0, 20) // Keep server name short
const tool = sanitize(toolName).slice(0, 35) // More room for tool name
let name = `mcp__${server}__${tool}`
// Ensure max 63 chars and clean trailing underscores
if (name.length > 63) {
name = name.slice(0, 63).replace(/_+$/, '')
}
return name
}