feat(mcp): add MCP Hub server for multi-server tool orchestration (#12192)

* feat(mcp): add hub server type definitions

- Add 'hub' to BuiltinMCPServerNames enum as '@cherry/hub'
- Create GeneratedTool, SearchQuery, ExecInput, ExecOutput types
- Add ExecutionContext and ConsoleMethods interfaces

Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e
Co-authored-by: Amp <amp@ampcode.com>

* feat(mcp): implement hub server core components

- generator.ts: Convert MCP tools to JS functions with JSDoc
- tool-registry.ts: In-memory cache with 10-min TTL
- search.ts: Comma-separated keyword search with ranking
- runtime.ts: Code execution with parallel/settle/console helpers

Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e
Co-authored-by: Amp <amp@ampcode.com>

* feat(mcp): integrate hub server with MCP infrastructure

- Create HubServer class with search/exec tools
- Implement mcp-bridge for calling tools via MCPService
- Register hub server in factory with dependency injection
- Initialize hub dependencies in MCPService constructor
- Add hub server description label for i18n

Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e
Co-authored-by: Amp <amp@ampcode.com>

* test(mcp): add unit tests for hub server

- generator.test.ts: Test schema conversion and JSDoc generation
- search.test.ts: Test keyword matching, ranking, and limits
- runtime.test.ts: Test code execution, helpers, and error handling

Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e
Co-authored-by: Amp <amp@ampcode.com>

* docs(mcp): add hub server documentation

- Document search/exec tool usage and parameters
- Explain configuration and caching behavior
- Include architecture diagram and file structure

Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e
Co-authored-by: Amp <amp@ampcode.com>

* ♻️ refactor(hub): simplify dependency injection for HubServer

- Remove HubServerDependencies interface and setHubServerDependencies from factory
- Add initHubBridge() to mcp-bridge for direct initialization
- Make HubServer constructor parameterless (uses pre-initialized bridge)
- MCPService now calls initHubBridge() directly instead of factory setter
- Add integration tests for full search → exec flow

* 📝 docs(hub): add comments explaining why hub is not in builtin list

- Add JSDoc to HubServer class explaining its purpose and design
- Add comment to builtinMCPServers explaining hub exclusion
- Hub is a meta-server for LLM code mode, auto-enabled internally

*  feat: add available tools section to HUB_MODE_SYSTEM_PROMPT

- Add shared utility for generating MCP tool function names (serverName_toolName format)
- Update hub server to use consistent function naming across search, exec and prompt
- Add fetchAllActiveServerTools to ApiService for renderer process
- Update parameterBuilder to include available tools in auto/hub mode prompt
- Use CacheService for 1-minute tools caching in hub server
- Remove ToolRegistry in favor of direct fetching with caching
- Update search ranking to include server name matching
- Fix tests to use new naming format

Amp-Thread-ID: https://ampcode.com/threads/T-019b6971-d5c9-7719-9245-a89390078647
Co-authored-by: Amp <amp@ampcode.com>

* ♻️ 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

*  feat: isolate hub exec worker and filter disabled tools

* 🐛 fix: inline hub worker source

* 🐛 fix: sync hub tool cache and map

* Update import path for buildFunctionCallToolName in BaseService

*  feat: refine hub mode system prompt

* 🐛 fix: propagate hub tool errors

* 📝 docs: clarify hub exec return

*  feat(hub): improve prompts and tool descriptions for better LLM success rate

- Rewrite HUB_MODE_SYSTEM_PROMPT_BASE with Critical Rules section
- Add Common Mistakes to Avoid section with examples
- Update exec tool description with IMPORTANT return requirement
- Improve search tool description clarity
- Simplify generator output with return reminder in header
- Add per-field @param JSDoc with required/optional markers

Fixes issue where LLMs forgot to return values from exec code

* ♻️ refactor(hub): return empty string when no tools available

*  feat(hub): add dedicated AUTO_MODE_SYSTEM_PROMPT for auto mode

- Create self-contained prompt teaching XML tool_use format
- Only shows search/exec tools (no generic examples)
- Add complete workflow example with common mistakes
- Update parameterBuilder to use getAutoModeSystemPrompt()
- User prompt comes first, then auto mode instructions
- Skip hub prompt when no tools available

* ♻️ refactor: move hub prompts to dedicated prompts-code-mode.ts

- Create src/renderer/src/config/prompts-code-mode.ts
- Move HUB_MODE_SYSTEM_PROMPT_BASE and AUTO_MODE_SYSTEM_PROMPT_BASE
- Move getHubModeSystemPrompt() and getAutoModeSystemPrompt()
- Extract shared buildToolsSection() helper
- Update parameterBuilder.ts import

* ♻️ refactor: add mcpMode support to promptToolUsePlugin

- Add mcpMode parameter to PromptToolUseConfig and defaultBuildSystemPrompt
- Pass mcpMode through middleware config to plugin builder
- Consolidate getAutoModeSystemPrompt into getHubModeSystemPrompt
- Update parameterBuilder to use getHubModeSystemPrompt

* ♻️ refactor: move getHubModeSystemPrompt to shared package

- Create @cherrystudio/shared workspace package with exports
- Move getHubModeSystemPrompt and ToolInfo to packages/shared/prompts
- Add @cherrystudio/shared dependency to @cherrystudio/ai-core
- Update promptToolUsePlugin to import from shared package
- Update renderer prompts-code-mode.ts to re-export from shared
- Add toolSetToToolInfoArray converter for type compatibility

* Revert "♻️ refactor: move getHubModeSystemPrompt to shared package"

This reverts commit 894b2fd487.

* Remove duplicate Tool Use Examples header from system prompt

* fix: add handleModeChange call in MCPToolsButton for manual mode activation

* style: update AssistantMCPSettings to use min-height instead of overflow for better layout control

* feat(i18n): add MCP server modes and truncate messages in multiple languages

- Introduced new "mode" options for MCP servers: auto, disabled, and manual with corresponding descriptions and labels.
- Added translations for "base64DataTruncated" and "truncated" messages across various language files.
- Enhanced user experience by providing clearer feedback on data truncation.

* Normalize tool names for search and exec in parser

* Clarify tool usage rules in code mode prompts and examples

* Clarify code execution instructions and update example usage

* refactor: simplify JSDoc description handling by removing unnecessary truncation

* refactor: optimize listAllActiveServerTools method to use Promise.allSettled for improved error handling and performance

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
LiuVaayne 2026-01-07 16:35:51 +08:00 committed by GitHub
parent 334b9bbe04
commit 6d15b0dfd1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 3193 additions and 479 deletions

View File

@ -21,9 +21,6 @@ const TOOL_USE_TAG_CONFIG: TagConfig = {
separator: '\n'
}
/**
*
*/
export const DEFAULT_SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \
You can use one or more tools per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use.
@ -38,10 +35,16 @@ Tool use is formatted using XML-style tags. The tool name is enclosed in opening
The tool name should be the exact name of the tool you are using, and the arguments should be a JSON object containing the parameters required by that tool. For example:
<tool_use>
<name>python_interpreter</name>
<arguments>{"code": "5 + 3 + 1294.678"}</arguments>
<name>search</name>
<arguments>{ "query": "browser,fetch" }</arguments>
</tool_use>
<tool_use>
<name>exec</name>
<arguments>{ "code": "const page = await CherryBrowser_fetch({ url: "https://example.com" })\nreturn page" }</arguments>
</tool_use>
The user will respond with the result of the tool use, which should be formatted as follows:
<tool_use_result>
@ -59,13 +62,6 @@ For example, if the result of the tool use is an image file, you can use it in t
Always adhere to this format for the tool use to ensure proper parsing and execution.
## Tool Use Examples
{{ TOOL_USE_EXAMPLES }}
## Tool Use Available Tools
Above example were using notional tools that might not exist for you. You only have access to these tools:
{{ AVAILABLE_TOOLS }}
## Tool Use Rules
Here are the rules you should always follow to solve your task:
1. Always use the right arguments for the tools. Never use variable names as the action arguments, use the value instead.
@ -74,6 +70,8 @@ Here are the rules you should always follow to solve your task:
4. Never re-do a tool call that you previously did with the exact same parameters.
5. For tool use, MAKE SURE use XML tag format as shown in the examples above. Do not use any other format.
{{ TOOLS_INFO }}
## Response rules
Respond in the language of the user's query, unless the user instructions specify additional requirements for the language to be used.
@ -185,13 +183,30 @@ ${result}
/**
* Cherry Studio
*/
function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet): string {
function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet, mcpMode?: string): string {
const availableTools = buildAvailableTools(tools)
if (availableTools === null) return userSystemPrompt
const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES)
if (mcpMode == 'auto') {
return DEFAULT_SYSTEM_PROMPT.replace('{{ TOOLS_INFO }}', '').replace(
'{{ USER_SYSTEM_PROMPT }}',
userSystemPrompt || ''
)
}
const toolsInfo = `
## Tool Use Examples
{{ TOOL_USE_EXAMPLES }}
## Tool Use Available Tools
Above example were using notional tools that might not exist for you. You only have access to these tools:
{{ AVAILABLE_TOOLS }}`
.replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES)
.replace('{{ AVAILABLE_TOOLS }}', availableTools)
.replace('{{ USER_SYSTEM_PROMPT }}', userSystemPrompt || '')
const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOLS_INFO }}', toolsInfo).replace(
'{{ USER_SYSTEM_PROMPT }}',
userSystemPrompt || ''
)
return fullPrompt
}
@ -224,7 +239,17 @@ function defaultParseToolUse(content: string, tools: ToolSet): { results: ToolUs
// Find all tool use blocks
while ((match = toolUsePattern.exec(contentToProcess)) !== null) {
const fullMatch = match[0]
const toolName = match[2].trim()
let toolName = match[2].trim()
switch (toolName.toLowerCase()) {
case 'search':
toolName = 'mcp__CherryHub__search'
break
case 'exec':
toolName = 'mcp__CherryHub__exec'
break
default:
break
}
const toolArgs = match[4].trim()
// Try to parse the arguments as JSON
@ -256,7 +281,12 @@ function defaultParseToolUse(content: string, tools: ToolSet): { results: ToolUs
}
export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
const { enabled = true, buildSystemPrompt = defaultBuildSystemPrompt, parseToolUse = defaultParseToolUse } = config
const {
enabled = true,
buildSystemPrompt = defaultBuildSystemPrompt,
parseToolUse = defaultParseToolUse,
mcpMode
} = config
return definePlugin({
name: 'built-in:prompt-tool-use',
@ -286,7 +316,7 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
// 构建系统提示符(只包含非 provider-defined 工具)
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools)
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools, mcpMode)
let systemMessage: string | null = systemPrompt
if (config.createSystemMessage) {
// 🎯 如果用户提供了自定义处理函数,使用它

View File

@ -23,6 +23,7 @@ export interface PromptToolUseConfig extends BaseToolUsePluginConfig {
// 自定义工具解析函数(可选,有默认实现)
parseToolUse?: (content: string, tools: ToolSet) => { results: ToolUseResult[]; content: string }
createSystemMessage?: (systemPrompt: string, originalParams: any, context: AiRequestContext) => string | null
mcpMode?: string
}
/**

116
packages/shared/mcp.ts Normal file
View File

@ -0,0 +1,116 @@
/**
* 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
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+(.)/g, (_, char) => char.toUpperCase())
.replace(/[^a-zA-Z0-9]/g, '')
if (result && !/^[a-zA-Z_]/.test(result)) {
result = '_' + result
}
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 {
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

@ -9,6 +9,7 @@ import DiDiMcpServer from './didi-mcp'
import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
import HubServer from './hub'
import MemoryServer from './memory'
import PythonServer from './python'
import ThinkingServer from './sequentialthinking'
@ -52,6 +53,9 @@ export function createInMemoryMCPServer(
case BuiltinMCPServerNames.browser: {
return new BrowserServer().server
}
case BuiltinMCPServerNames.hub: {
return new HubServer().server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@ -0,0 +1,213 @@
# Hub MCP Server
A built-in MCP server that aggregates all active MCP servers in Cherry Studio and exposes them through `search` and `exec` tools.
## Overview
The Hub server enables LLMs to discover and call tools from all active MCP servers without needing to know the specific server names or tool signatures upfront.
## Auto Mode Integration
The Hub server is the core component of Cherry Studio's **Auto MCP Mode**. When an assistant is set to Auto mode:
1. **Automatic Injection**: The Hub server is automatically injected as the only MCP server for the assistant
2. **System Prompt**: A specialized system prompt (`HUB_MODE_SYSTEM_PROMPT`) is appended to guide the LLM on how to use the `search` and `exec` tools
3. **Dynamic Discovery**: The LLM can discover and use any tools from all active MCP servers without manual configuration
### MCP Modes
Cherry Studio supports three MCP modes per assistant:
| Mode | Description | Tools Available |
|------|-------------|-----------------|
| **Disabled** | No MCP tools | None |
| **Auto** | Hub server only | `search`, `exec` |
| **Manual** | User selects servers | Selected server tools |
### How Auto Mode Works
```
User Message
┌─────────────────────────────────────────┐
│ Assistant (mcpMode: 'auto') │
│ │
│ System Prompt + HUB_MODE_SYSTEM_PROMPT │
│ Tools: [hub.search, hub.exec] │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ LLM decides to use MCP tools │
│ │
│ 1. search({ query: "github,repo" }) │
│ 2. exec({ code: "await searchRepos()" })│
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Hub Server │
│ │
│ Aggregates all active MCP servers │
│ Routes tool calls to appropriate server │
└─────────────────────────────────────────┘
```
### Relevant Code
- **Type Definition**: `src/renderer/src/types/index.ts` - `McpMode` type and `getEffectiveMcpMode()`
- **Hub Server Constant**: `src/renderer/src/store/mcp.ts` - `hubMCPServer`
- **Server Selection**: `src/renderer/src/services/ApiService.ts` - `getMcpServersForAssistant()`
- **System Prompt**: `src/renderer/src/config/prompts.ts` - `HUB_MODE_SYSTEM_PROMPT`
- **Prompt Injection**: `src/renderer/src/aiCore/prepareParams/parameterBuilder.ts`
## Tools
### `search`
Search for available MCP tools by keywords.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `query` | string | Yes | Search keywords, comma-separated for OR matching |
| `limit` | number | No | Maximum results to return (default: 10, max: 50) |
**Example:**
```json
{
"query": "browser,chrome",
"limit": 5
}
```
**Returns:** JavaScript function declarations with JSDoc comments that can be used in the `exec` tool.
```javascript
// Found 2 tool(s):
/**
* Launch a browser instance
*
* @param {{ browser?: "chromium" | "firefox" | "webkit", headless?: boolean }} params
* @returns {Promise<unknown>}
*/
async function launchBrowser(params) {
return await __callTool("browser__launch_browser", params);
}
```
### `exec`
Execute JavaScript code that calls MCP tools.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `code` | string | Yes | JavaScript code to execute |
**Built-in Helpers:**
- `parallel(...promises)` - Run multiple tool calls concurrently (Promise.all)
- `settle(...promises)` - Run multiple tool calls and get all results (Promise.allSettled)
- `console.log/warn/error/info/debug` - Captured in output logs
**Example:**
```javascript
// Call a single tool
const result = await searchRepos({ query: "react" });
return result;
// Call multiple tools in parallel
const [users, repos] = await parallel(
getUsers({ limit: 10 }),
searchRepos({ query: "typescript" })
);
return { users, repos };
```
**Returns:**
```json
{
"result": { "users": [...], "repos": [...] },
"logs": ["[log] Processing..."],
"error": null
}
```
## Usage Flow
1. **Search** for tools using keywords:
```
search({ query: "github,repository" })
```
2. **Review** the returned function signatures and JSDoc
3. **Execute** code using the discovered tools:
```
exec({ code: 'return await searchRepos({ query: "react" })' })
```
## Configuration
The Hub server is a built-in server identified as `@cherry/hub`.
### Using Auto Mode (Recommended)
The easiest way to use the Hub server is through Auto mode:
1. Click the **MCP Tools** button (hammer icon) in the input bar
2. Select **Auto** mode
3. The Hub server is automatically enabled for the assistant
### Manual Configuration
Alternatively, you can enable the Hub server manually:
1. Go to **Settings** → **MCP Servers**
2. Find **Hub** in the built-in servers list
3. Toggle it on
4. In the assistant's MCP settings, select the Hub server
## Caching
- Tool definitions are cached for **10 minutes**
- Cache is automatically refreshed when expired
- Cache is invalidated when MCP servers connect/disconnect
## Limitations
- Code execution has a **60-second timeout**
- Console logs are limited to **1000 entries**
- Search results are limited to **50 tools** maximum
- The Hub server excludes itself from the aggregated server list
## Architecture
```
LLM
HubServer
├── search → ToolRegistry → SearchIndex
└── exec → Runtime → callMcpTool()
MCPService.callTool()
External MCP Servers
```
## Files
| File | Description |
|------|-------------|
| `index.ts` | Main HubServer class |
| `types.ts` | TypeScript type definitions |
| `generator.ts` | Converts MCP tools to JS functions with JSDoc |
| `tool-registry.ts` | In-memory tool cache with TTL |
| `search.ts` | Keyword-based tool search |
| `runtime.ts` | JavaScript code execution engine |
| `mcp-bridge.ts` | Bridge to Cherry Studio's MCPService |

View File

@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import { generateToolFunction, generateToolsCode } from '../generator'
import type { GeneratedTool } from '../types'
describe('generator', () => {
describe('generateToolFunction', () => {
it('generates a simple tool function', () => {
const tool = {
id: 'test-id',
name: 'search_repos',
description: 'Search for GitHub repositories',
serverId: 'github',
serverName: 'github-server',
inputSchema: {
type: 'object' as const,
properties: {
query: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results' }
},
required: ['query']
},
type: 'mcp' as const
}
const existingNames = new Set<string>()
const callTool = async () => ({ success: true })
const result = generateToolFunction(tool, existingNames, callTool)
expect(result.functionName).toBe('githubServer_searchRepos')
expect(result.jsCode).toContain('async function githubServer_searchRepos')
expect(result.jsCode).toContain('Search for GitHub repositories')
expect(result.jsCode).toContain('__callTool')
})
it('handles unique function names', () => {
const tool = {
id: 'test-id',
name: 'search',
serverId: 'server1',
serverName: 'server1',
inputSchema: { type: 'object' as const, properties: {} },
type: 'mcp' as const
}
const existingNames = new Set<string>(['server1_search'])
const callTool = async () => ({})
const result = generateToolFunction(tool, existingNames, callTool)
expect(result.functionName).toBe('server1_search1')
})
it('handles enum types in schema', () => {
const tool = {
id: 'test-id',
name: 'launch_browser',
serverId: 'browser',
serverName: 'browser',
inputSchema: {
type: 'object' as const,
properties: {
browser: {
type: 'string',
enum: ['chromium', 'firefox', 'webkit']
}
}
},
type: 'mcp' as const
}
const existingNames = new Set<string>()
const callTool = async () => ({})
const result = generateToolFunction(tool, existingNames, callTool)
expect(result.jsCode).toContain('"chromium" | "firefox" | "webkit"')
})
})
describe('generateToolsCode', () => {
it('generates code for multiple tools', () => {
const tools: GeneratedTool[] = [
{
serverId: 's1',
serverName: 'server1',
toolName: 'tool1',
functionName: 'server1_tool1',
jsCode: 'async function server1_tool1() {}',
fn: async () => ({}),
signature: '{}',
returns: 'unknown'
},
{
serverId: 's2',
serverName: 'server2',
toolName: 'tool2',
functionName: 'server2_tool2',
jsCode: 'async function server2_tool2() {}',
fn: async () => ({}),
signature: '{}',
returns: 'unknown'
}
]
const result = generateToolsCode(tools)
expect(result).toContain('2 tool(s)')
expect(result).toContain('async function server1_tool1')
expect(result).toContain('async function server2_tool2')
})
it('returns message for empty tools', () => {
const result = generateToolsCode([])
expect(result).toBe('// No tools available')
})
})
})

View File

@ -0,0 +1,229 @@
import type { MCPTool } from '@types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HubServer } from '../index'
const mockTools: MCPTool[] = [
{
id: 'github__search_repos',
name: 'search_repos',
description: 'Search for GitHub repositories',
serverId: 'github',
serverName: 'GitHub',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results' }
},
required: ['query']
},
type: 'mcp'
},
{
id: 'github__get_user',
name: 'get_user',
description: 'Get GitHub user profile',
serverId: 'github',
serverName: 'GitHub',
inputSchema: {
type: 'object',
properties: {
username: { type: 'string', description: 'GitHub username' }
},
required: ['username']
},
type: 'mcp'
},
{
id: 'database__query',
name: 'query',
description: 'Execute a database query',
serverId: 'database',
serverName: 'Database',
inputSchema: {
type: 'object',
properties: {
sql: { type: 'string', description: 'SQL query to execute' }
},
required: ['sql']
},
type: 'mcp'
}
]
vi.mock('@main/services/MCPService', () => ({
default: {
listAllActiveServerTools: vi.fn(async () => mockTools),
callToolById: vi.fn(async (toolId: string, args: unknown) => {
if (toolId === 'github__search_repos') {
return {
content: [{ type: 'text', text: JSON.stringify({ repos: ['repo1', 'repo2'], query: args }) }]
}
}
if (toolId === 'github__get_user') {
return {
content: [{ type: 'text', text: JSON.stringify({ username: (args as any).username, id: 123 }) }]
}
}
if (toolId === 'database__query') {
return {
content: [{ type: 'text', text: JSON.stringify({ rows: [{ id: 1 }, { id: 2 }] }) }]
}
}
return { content: [{ type: 'text', text: '{}' }] }
}),
abortTool: vi.fn(async () => true)
}
}))
import mcpService from '@main/services/MCPService'
describe('HubServer Integration', () => {
let hubServer: HubServer
beforeEach(() => {
vi.clearAllMocks()
hubServer = new HubServer()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('full search → exec flow', () => {
it('searches for tools and executes them', async () => {
const searchResult = await (hubServer as any).handleSearch({ query: 'github,repos' })
expect(searchResult.content).toBeDefined()
const searchText = JSON.parse(searchResult.content[0].text)
expect(searchText.total).toBeGreaterThan(0)
expect(searchText.tools).toContain('github_searchRepos')
const execResult = await (hubServer as any).handleExec({
code: 'return await github_searchRepos({ query: "test" })'
})
expect(execResult.content).toBeDefined()
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.result).toEqual({ repos: ['repo1', 'repo2'], query: { query: 'test' } })
})
it('handles multiple tool calls in parallel', async () => {
await (hubServer as any).handleSearch({ query: 'github' })
const execResult = await (hubServer as any).handleExec({
code: `
const results = await parallel(
github_searchRepos({ query: "react" }),
github_getUser({ username: "octocat" })
);
return results
`
})
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.result).toHaveLength(2)
expect(execOutput.result[0]).toEqual({ repos: ['repo1', 'repo2'], query: { query: 'react' } })
expect(execOutput.result[1]).toEqual({ username: 'octocat', id: 123 })
})
it('searches across multiple servers', async () => {
const searchResult = await (hubServer as any).handleSearch({ query: 'query' })
const searchText = JSON.parse(searchResult.content[0].text)
expect(searchText.tools).toContain('database_query')
})
})
describe('tools caching', () => {
it('uses cached tools within TTL', async () => {
await (hubServer as any).handleSearch({ query: 'github' })
const firstCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
await (hubServer as any).handleSearch({ query: 'github' })
const secondCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
expect(secondCallCount).toBe(firstCallCount) // Should use cache
})
it('refreshes tools after cache invalidation', async () => {
await (hubServer as any).handleSearch({ query: 'github' })
const firstCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
hubServer.invalidateCache()
await (hubServer as any).handleSearch({ query: 'github' })
const secondCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
expect(secondCallCount).toBe(firstCallCount + 1)
})
})
describe('error handling', () => {
it('throws error for invalid search query', async () => {
await expect((hubServer as any).handleSearch({})).rejects.toThrow('query parameter is required')
})
it('throws error for invalid exec code', async () => {
await expect((hubServer as any).handleExec({})).rejects.toThrow('code parameter is required')
})
it('handles runtime errors in exec', async () => {
const execResult = await (hubServer as any).handleExec({
code: 'throw new Error("test error")'
})
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.error).toBe('test error')
expect(execOutput.isError).toBe(true)
})
})
describe('exec timeouts', () => {
afterEach(() => {
vi.useRealTimers()
})
it('aborts in-flight tool calls and returns logs on timeout', async () => {
vi.useFakeTimers()
let toolCallStarted: (() => void) | null = null
const toolCallStartedPromise = new Promise<void>((resolve) => {
toolCallStarted = resolve
})
vi.mocked(mcpService.callToolById).mockImplementationOnce(async () => {
toolCallStarted?.()
return await new Promise(() => {})
})
const execPromise = (hubServer as any).handleExec({
code: `
console.log("starting");
return await github_searchRepos({ query: "hang" });
`
})
await toolCallStartedPromise
await vi.advanceTimersByTimeAsync(60000)
await vi.runAllTimersAsync()
const execResult = await execPromise
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.error).toBe('Execution timed out after 60000ms')
expect(execOutput.result).toBeUndefined()
expect(execOutput.isError).toBe(true)
expect(execOutput.logs).toContain('[log] starting')
expect(vi.mocked(mcpService.abortTool)).toHaveBeenCalled()
})
})
describe('server instance', () => {
it('creates a valid MCP server instance', () => {
expect(hubServer.server).toBeDefined()
expect(hubServer.server.setRequestHandler).toBeDefined()
})
})
})

View File

@ -0,0 +1,159 @@
import { describe, expect, it, vi } from 'vitest'
import { Runtime } from '../runtime'
import type { GeneratedTool } from '../types'
vi.mock('../mcp-bridge', () => ({
callMcpTool: vi.fn(async (toolId: string, params: unknown) => {
if (toolId === 'server__failing_tool') {
throw new Error('Tool failed')
}
return { toolId, params, success: true }
})
}))
const createMockTool = (partial: Partial<GeneratedTool>): GeneratedTool => ({
serverId: 'server1',
serverName: 'server1',
toolName: 'tool',
functionName: 'server1_mockTool',
jsCode: 'async function server1_mockTool() {}',
fn: async (params) => ({ result: params }),
signature: '{}',
returns: 'unknown',
...partial
})
describe('Runtime', () => {
describe('execute', () => {
it('executes simple code and returns result', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute('return 1 + 1', tools)
expect(result.result).toBe(2)
expect(result.error).toBeUndefined()
})
it('executes async code', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute('return await Promise.resolve(42)', tools)
expect(result.result).toBe(42)
})
it('calls tool functions', async () => {
const runtime = new Runtime()
const tools = [
createMockTool({
functionName: 'searchRepos',
fn: async (params) => ({ repos: ['repo1', 'repo2'], query: params })
})
]
const result = await runtime.execute('return await searchRepos({ query: "test" })', tools)
expect(result.result).toEqual({ toolId: 'searchRepos', params: { query: 'test' }, success: true })
})
it('captures console logs', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
console.log("hello");
console.warn("warning");
return "done"
`,
tools
)
expect(result.result).toBe('done')
expect(result.logs).toContain('[log] hello')
expect(result.logs).toContain('[warn] warning')
})
it('handles errors gracefully', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute('throw new Error("test error")', tools)
expect(result.result).toBeUndefined()
expect(result.error).toBe('test error')
expect(result.isError).toBe(true)
})
it('supports parallel helper', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
const results = await parallel(
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3)
);
return results
`,
tools
)
expect(result.result).toEqual([1, 2, 3])
})
it('supports settle helper', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
const results = await settle(
Promise.resolve(1),
Promise.reject(new Error("fail"))
);
return results.map(r => r.status)
`,
tools
)
expect(result.result).toEqual(['fulfilled', 'rejected'])
})
it('returns last expression when no explicit return', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
const x = 10;
const y = 20;
return x + y
`,
tools
)
expect(result.result).toBe(30)
})
it('stops execution when a tool throws', async () => {
const runtime = new Runtime()
const tools = [
createMockTool({
functionName: 'server__failing_tool'
})
]
const result = await runtime.execute('return await server__failing_tool({})', tools)
expect(result.result).toBeUndefined()
expect(result.error).toBe('Tool failed')
expect(result.isError).toBe(true)
})
})
})

View File

@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import { searchTools } from '../search'
import type { GeneratedTool } from '../types'
const createMockTool = (partial: Partial<GeneratedTool>): GeneratedTool => {
const functionName = partial.functionName || 'server1_tool'
return {
serverId: 'server1',
serverName: 'server1',
toolName: partial.toolName || 'tool',
functionName,
jsCode: `async function ${functionName}() {}`,
fn: async () => ({}),
signature: '{}',
returns: 'unknown',
...partial
}
}
describe('search', () => {
describe('searchTools', () => {
it('returns all tools when query is empty', () => {
const tools = [
createMockTool({ toolName: 'tool1', functionName: 'tool1' }),
createMockTool({ toolName: 'tool2', functionName: 'tool2' })
]
const result = searchTools(tools, { query: '' })
expect(result.total).toBe(2)
expect(result.tools).toContain('tool1')
expect(result.tools).toContain('tool2')
})
it('filters tools by single keyword', () => {
const tools = [
createMockTool({ toolName: 'search_repos', functionName: 'searchRepos' }),
createMockTool({ toolName: 'get_user', functionName: 'getUser' }),
createMockTool({ toolName: 'search_users', functionName: 'searchUsers' })
]
const result = searchTools(tools, { query: 'search' })
expect(result.total).toBe(2)
expect(result.tools).toContain('searchRepos')
expect(result.tools).toContain('searchUsers')
expect(result.tools).not.toContain('getUser')
})
it('supports OR matching with comma-separated keywords', () => {
const tools = [
createMockTool({ toolName: 'browser_open', functionName: 'browserOpen' }),
createMockTool({ toolName: 'chrome_launch', functionName: 'chromeLaunch' }),
createMockTool({ toolName: 'file_read', functionName: 'fileRead' })
]
const result = searchTools(tools, { query: 'browser,chrome' })
expect(result.total).toBe(2)
expect(result.tools).toContain('browserOpen')
expect(result.tools).toContain('chromeLaunch')
expect(result.tools).not.toContain('fileRead')
})
it('matches against description', () => {
const tools = [
createMockTool({
toolName: 'launch',
functionName: 'launch',
description: 'Launch a browser instance'
}),
createMockTool({
toolName: 'close',
functionName: 'close',
description: 'Close a window'
})
]
const result = searchTools(tools, { query: 'browser' })
expect(result.total).toBe(1)
expect(result.tools).toContain('launch')
})
it('respects limit parameter', () => {
const tools = Array.from({ length: 20 }, (_, i) =>
createMockTool({ toolName: `tool${i}`, functionName: `server1_tool${i}` })
)
const result = searchTools(tools, { query: 'tool', limit: 5 })
expect(result.total).toBe(20)
const matches = (result.tools.match(/async function server1_tool\d+/g) || []).length
expect(matches).toBe(5)
})
it('is case insensitive', () => {
const tools = [createMockTool({ toolName: 'SearchRepos', functionName: 'searchRepos' })]
const result = searchTools(tools, { query: 'SEARCH' })
expect(result.total).toBe(1)
})
it('ranks exact matches higher', () => {
const tools = [
createMockTool({ toolName: 'searching', functionName: 'searching' }),
createMockTool({ toolName: 'search', functionName: 'search' }),
createMockTool({ toolName: 'search_more', functionName: 'searchMore' })
]
const result = searchTools(tools, { query: 'search', limit: 1 })
expect(result.tools).toContain('function search(')
})
})
})

View File

@ -0,0 +1,152 @@
import { generateMcpToolFunctionName } from '@shared/mcp'
import type { MCPTool } from '@types'
import type { GeneratedTool } from './types'
type PropertySchema = Record<string, unknown>
type InputSchema = {
type?: string
properties?: Record<string, PropertySchema>
required?: string[]
}
function schemaTypeToTS(prop: Record<string, unknown>): string {
const type = prop.type as string | string[] | undefined
const enumValues = prop.enum as unknown[] | undefined
if (enumValues && Array.isArray(enumValues)) {
return enumValues.map((v) => (typeof v === 'string' ? `"${v}"` : String(v))).join(' | ')
}
if (Array.isArray(type)) {
return type.map((t) => primitiveTypeToTS(t)).join(' | ')
}
if (type === 'array') {
const items = prop.items as Record<string, unknown> | undefined
if (items) {
return `${schemaTypeToTS(items)}[]`
}
return 'unknown[]'
}
if (type === 'object') {
return 'object'
}
return primitiveTypeToTS(type)
}
function primitiveTypeToTS(type: string | undefined): string {
switch (type) {
case 'string':
return 'string'
case 'number':
case 'integer':
return 'number'
case 'boolean':
return 'boolean'
case 'null':
return 'null'
default:
return 'unknown'
}
}
function jsonSchemaToSignature(schema: Record<string, unknown> | undefined): string {
if (!schema || typeof schema !== 'object') {
return '{}'
}
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
if (!properties) {
return '{}'
}
const required = (schema.required as string[]) || []
const parts: string[] = []
for (const [key, prop] of Object.entries(properties)) {
const isRequired = required.includes(key)
const typeStr = schemaTypeToTS(prop)
parts.push(`${key}${isRequired ? '' : '?'}: ${typeStr}`)
}
return `{ ${parts.join(', ')} }`
}
function generateJSDoc(tool: MCPTool, inputSchema: InputSchema | undefined, returns: string): string {
const lines: string[] = ['/**']
if (tool.description) {
const desc = tool.description.split('\n')[0]
lines.push(` * ${desc}`)
}
const properties = inputSchema?.properties || {}
const required = inputSchema?.required || []
if (Object.keys(properties).length > 0) {
lines.push(` * @param {Object} params`)
for (const [name, prop] of Object.entries(properties)) {
const isReq = required.includes(name)
const type = schemaTypeToTS(prop)
const paramName = isReq ? `params.${name}` : `[params.${name}]`
const desc = (prop.description as string)?.split('\n')[0] || ''
lines.push(` * @param {${type}} ${paramName} ${desc}`)
}
}
lines.push(` * @returns {Promise<${returns}>}`)
lines.push(` */`)
return lines.join('\n')
}
export function generateToolFunction(
tool: MCPTool,
existingNames: Set<string>,
callToolFn: (functionName: string, params: unknown) => Promise<unknown>
): GeneratedTool {
const functionName = generateMcpToolFunctionName(tool.serverName, tool.name, existingNames)
const inputSchema = tool.inputSchema as InputSchema | undefined
const outputSchema = tool.outputSchema as Record<string, unknown> | undefined
const signature = jsonSchemaToSignature(inputSchema)
const returns = outputSchema ? jsonSchemaToSignature(outputSchema) : 'unknown'
const jsDoc = generateJSDoc(tool, inputSchema, returns)
const jsCode = `${jsDoc}
async function ${functionName}(params) {
return await __callTool("${functionName}", params);
}`
const fn = async (params: unknown): Promise<unknown> => {
return await callToolFn(functionName, params)
}
return {
serverId: tool.serverId,
serverName: tool.serverName,
toolName: tool.name,
functionName,
jsCode,
fn,
signature,
returns,
description: tool.description
}
}
export function generateToolsCode(tools: GeneratedTool[]): string {
if (tools.length === 0) {
return '// No tools available'
}
const header = `// ${tools.length} tool(s). ALWAYS use: const r = await ToolName({...}); return r;`
const code = tools.map((t) => t.jsCode).join('\n\n')
return header + '\n\n' + code
}

View File

@ -0,0 +1,184 @@
import { loggerService } from '@logger'
import { CacheService } from '@main/services/CacheService'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
import { generateToolFunction } from './generator'
import { callMcpTool, clearToolMap, listAllTools, syncToolMapFromGeneratedTools } from './mcp-bridge'
import { Runtime } from './runtime'
import { searchTools } from './search'
import type { ExecInput, GeneratedTool, SearchQuery } from './types'
const logger = loggerService.withContext('MCPServer:Hub')
const TOOLS_CACHE_KEY = 'hub:tools'
const TOOLS_CACHE_TTL = 60 * 1000 // 1 minute
/**
* Hub MCP Server - A meta-server that aggregates all active MCP servers.
*
* This server is NOT included in builtinMCPServers because:
* 1. It aggregates tools from all other MCP servers, not a standalone tool provider
* 2. It's designed for LLM "code mode" - enabling AI to discover and call tools programmatically
* 3. It should be auto-enabled when code mode features are used, not manually installed by users
*
* The server exposes two tools:
* - `search`: Find available tools by keywords, returns JS function signatures
* - `exec`: Execute JavaScript code that calls discovered tools
*/
export class HubServer {
public server: Server
private runtime: Runtime
constructor() {
this.runtime = new Runtime()
this.server = new Server(
{
name: 'hub-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
)
this.setupRequestHandlers()
}
private setupRequestHandlers(): void {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'search',
description:
'Search for available MCP tools by keywords. Use this FIRST to discover tools. Returns JavaScript async function declarations with JSDoc showing exact function names, parameters, and return types for use in `exec`.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description:
'Comma-separated search keywords. A tool matches if ANY keyword appears in its name, description, or server name. Example: "chrome,browser,tab" matches tools related to Chrome OR browser OR tabs.'
},
limit: {
type: 'number',
description: 'Maximum number of tools to return (default: 10, max: 50)'
}
},
required: ['query']
}
},
{
name: 'exec',
description:
'Execute JavaScript that calls MCP tools discovered via `search`. IMPORTANT: You MUST explicitly `return` the final value, or the result will be `undefined`.',
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description:
'JavaScript code to execute. The code runs inside an async context, so use `await` directly. Do NOT wrap your code in `(async () => { ... })()` - this causes double-wrapping and returns undefined. All discovered tools are async functions (call as `await ToolName(params)`). Helpers: `parallel(...promises)`, `settle(...promises)`, `console.*`. You MUST `return` the final value. Examples: `const r = await Tool({ id: "1" }); return r` or `return await Tool({ x: 1 })`'
}
},
required: ['code']
}
}
]
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
if (!args) {
throw new McpError(ErrorCode.InvalidParams, 'No arguments provided')
}
try {
switch (name) {
case 'search':
return await this.handleSearch(args as unknown as SearchQuery)
case 'exec':
return await this.handleExec(args as unknown as ExecInput)
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`)
}
} catch (error) {
if (error instanceof McpError) {
throw error
}
logger.error(`Error executing tool ${name}:`, error as Error)
throw new McpError(
ErrorCode.InternalError,
`Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}`
)
}
})
}
private async fetchTools(): Promise<GeneratedTool[]> {
const cached = CacheService.get<GeneratedTool[]>(TOOLS_CACHE_KEY)
if (cached) {
logger.debug('Returning cached tools')
syncToolMapFromGeneratedTools(cached)
return cached
}
logger.debug('Fetching fresh tools')
const allTools = await listAllTools()
const existingNames = new Set<string>()
const tools = allTools.map((tool) => generateToolFunction(tool, existingNames, callMcpTool))
CacheService.set(TOOLS_CACHE_KEY, tools, TOOLS_CACHE_TTL)
syncToolMapFromGeneratedTools(tools)
return tools
}
invalidateCache(): void {
CacheService.remove(TOOLS_CACHE_KEY)
clearToolMap()
logger.debug('Tools cache invalidated')
}
private async handleSearch(query: SearchQuery) {
if (!query.query || typeof query.query !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'query parameter is required and must be a string')
}
const tools = await this.fetchTools()
const result = searchTools(tools, query)
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2)
}
]
}
}
private async handleExec(input: ExecInput) {
if (!input.code || typeof input.code !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'code parameter is required and must be a string')
}
const tools = await this.fetchTools()
const result = await this.runtime.execute(input.code, tools)
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2)
}
],
isError: result.isError
}
}
}
export default HubServer

View File

@ -0,0 +1,96 @@
/**
* Bridge module for Hub server to access MCPService.
* Re-exports the methods needed by tool-registry and runtime.
*/
import mcpService from '@main/services/MCPService'
import { generateMcpToolFunctionName } from '@shared/mcp'
import type { MCPCallToolResponse, MCPTool, MCPToolResultContent } from '@types'
import type { GeneratedTool } from './types'
export const listAllTools = () => mcpService.listAllActiveServerTools()
const toolFunctionNameToIdMap = new Map<string, { serverId: string; toolName: string }>()
export async function refreshToolMap(): Promise<void> {
const tools = await listAllTools()
syncToolMapFromTools(tools)
}
export function syncToolMapFromTools(tools: MCPTool[]): void {
toolFunctionNameToIdMap.clear()
const existingNames = new Set<string>()
for (const tool of tools) {
const functionName = generateMcpToolFunctionName(tool.serverName, tool.name, existingNames)
toolFunctionNameToIdMap.set(functionName, { serverId: tool.serverId, toolName: tool.name })
}
}
export function syncToolMapFromGeneratedTools(tools: GeneratedTool[]): void {
toolFunctionNameToIdMap.clear()
for (const tool of tools) {
toolFunctionNameToIdMap.set(tool.functionName, { serverId: tool.serverId, toolName: tool.toolName })
}
}
export function clearToolMap(): void {
toolFunctionNameToIdMap.clear()
}
export const callMcpTool = async (functionName: string, params: unknown, callId?: string): Promise<unknown> => {
const toolInfo = toolFunctionNameToIdMap.get(functionName)
if (!toolInfo) {
await refreshToolMap()
const retryToolInfo = toolFunctionNameToIdMap.get(functionName)
if (!retryToolInfo) {
throw new Error(`Tool not found: ${functionName}`)
}
const toolId = `${retryToolInfo.serverId}__${retryToolInfo.toolName}`
const result = await mcpService.callToolById(toolId, params, callId)
throwIfToolError(result)
return extractToolResult(result)
}
const toolId = `${toolInfo.serverId}__${toolInfo.toolName}`
const result = await mcpService.callToolById(toolId, params, callId)
throwIfToolError(result)
return extractToolResult(result)
}
export const abortMcpTool = async (callId: string): Promise<boolean> => {
return mcpService.abortTool(null as unknown as Electron.IpcMainInvokeEvent, callId)
}
function extractToolResult(result: MCPCallToolResponse): unknown {
if (!result.content || result.content.length === 0) {
return null
}
const textContent = result.content.find((c) => c.type === 'text')
if (textContent?.text) {
try {
return JSON.parse(textContent.text)
} catch {
return textContent.text
}
}
return result.content
}
function throwIfToolError(result: MCPCallToolResponse): void {
if (!result.isError) {
return
}
const textContent = extractTextContent(result.content)
throw new Error(textContent ?? 'Tool execution failed')
}
function extractTextContent(content: MCPToolResultContent[] | undefined): string | undefined {
if (!content || content.length === 0) {
return undefined
}
const textBlock = content.find((item) => item.type === 'text' && item.text)
return textBlock?.text
}

View File

@ -0,0 +1,170 @@
import crypto from 'node:crypto'
import { Worker } from 'node:worker_threads'
import { loggerService } from '@logger'
import { abortMcpTool, callMcpTool } from './mcp-bridge'
import type {
ExecOutput,
GeneratedTool,
HubWorkerCallToolMessage,
HubWorkerExecMessage,
HubWorkerMessage,
HubWorkerResultMessage
} from './types'
import { hubWorkerSource } from './worker'
const logger = loggerService.withContext('MCPServer:Hub:Runtime')
const MAX_LOGS = 1000
const EXECUTION_TIMEOUT = 60000
export class Runtime {
async execute(code: string, tools: GeneratedTool[]): Promise<ExecOutput> {
return await new Promise<ExecOutput>((resolve) => {
const logs: string[] = []
const activeCallIds = new Map<string, string>()
let finished = false
let timedOut = false
let timeoutId: NodeJS.Timeout | null = null
const worker = new Worker(hubWorkerSource, { eval: true })
const addLog = (entry: string) => {
if (logs.length >= MAX_LOGS) {
return
}
logs.push(entry)
}
const finalize = async (output: ExecOutput, terminateWorker = true) => {
if (finished) {
return
}
finished = true
if (timeoutId) {
clearTimeout(timeoutId)
}
worker.removeAllListeners()
if (terminateWorker) {
try {
await worker.terminate()
} catch (error) {
logger.warn('Failed to terminate exec worker', error as Error)
}
}
resolve(output)
}
const abortActiveTools = async () => {
const callIds = Array.from(activeCallIds.values())
activeCallIds.clear()
if (callIds.length === 0) {
return
}
await Promise.allSettled(callIds.map((callId) => abortMcpTool(callId)))
}
const handleToolCall = async (message: HubWorkerCallToolMessage) => {
if (finished || timedOut) {
return
}
const callId = crypto.randomUUID()
activeCallIds.set(message.requestId, callId)
try {
const result = await callMcpTool(message.functionName, message.params, callId)
if (finished || timedOut) {
return
}
worker.postMessage({ type: 'toolResult', requestId: message.requestId, result })
} catch (error) {
if (finished || timedOut) {
return
}
const errorMessage = error instanceof Error ? error.message : String(error)
worker.postMessage({ type: 'toolError', requestId: message.requestId, error: errorMessage })
} finally {
activeCallIds.delete(message.requestId)
}
}
const handleResult = (message: HubWorkerResultMessage) => {
const resolvedLogs = message.logs && message.logs.length > 0 ? message.logs : logs
void finalize({
result: message.result,
logs: resolvedLogs.length > 0 ? resolvedLogs : undefined
})
}
const handleError = (errorMessage: string, messageLogs?: string[], terminateWorker = true) => {
const resolvedLogs = messageLogs && messageLogs.length > 0 ? messageLogs : logs
void finalize(
{
result: undefined,
logs: resolvedLogs.length > 0 ? resolvedLogs : undefined,
error: errorMessage,
isError: true
},
terminateWorker
)
}
const handleMessage = (message: HubWorkerMessage) => {
if (!message || typeof message !== 'object') {
return
}
switch (message.type) {
case 'log':
addLog(message.entry)
break
case 'callTool':
void handleToolCall(message)
break
case 'result':
handleResult(message)
break
case 'error':
handleError(message.error, message.logs)
break
default:
break
}
}
timeoutId = setTimeout(() => {
timedOut = true
void (async () => {
await abortActiveTools()
try {
await worker.terminate()
} catch (error) {
logger.warn('Failed to terminate exec worker after timeout', error as Error)
}
handleError(`Execution timed out after ${EXECUTION_TIMEOUT}ms`, undefined, false)
})()
}, EXECUTION_TIMEOUT)
worker.on('message', handleMessage)
worker.on('error', (error) => {
logger.error('Worker execution error', error)
handleError(error instanceof Error ? error.message : String(error))
})
worker.on('exit', (code) => {
if (finished || timedOut) {
return
}
const message = code === 0 ? 'Exec worker exited unexpectedly' : `Exec worker exited with code ${code}`
logger.error(message)
handleError(message, undefined, false)
})
const execMessage: HubWorkerExecMessage = {
type: 'exec',
code,
tools: tools.map((tool) => ({ functionName: tool.functionName }))
}
worker.postMessage(execMessage)
})
}
}

View File

@ -0,0 +1,109 @@
import { generateToolsCode } from './generator'
import type { GeneratedTool, SearchQuery, SearchResult } from './types'
const DEFAULT_LIMIT = 10
const MAX_LIMIT = 50
export function searchTools(tools: GeneratedTool[], query: SearchQuery): SearchResult {
const { query: queryStr, limit = DEFAULT_LIMIT } = query
const effectiveLimit = Math.min(Math.max(1, limit), MAX_LIMIT)
const keywords = queryStr
.toLowerCase()
.split(',')
.map((k) => k.trim())
.filter((k) => k.length > 0)
if (keywords.length === 0) {
const sliced = tools.slice(0, effectiveLimit)
return {
tools: generateToolsCode(sliced),
total: tools.length
}
}
const matchedTools = tools.filter((tool) => {
const searchText = buildSearchText(tool).toLowerCase()
return keywords.some((keyword) => searchText.includes(keyword))
})
const rankedTools = rankTools(matchedTools, keywords)
const sliced = rankedTools.slice(0, effectiveLimit)
return {
tools: generateToolsCode(sliced),
total: matchedTools.length
}
}
function buildSearchText(tool: GeneratedTool): string {
const combinedName = tool.serverName ? `${tool.serverName}_${tool.toolName}` : tool.toolName
const parts = [
tool.toolName,
tool.functionName,
tool.serverName,
combinedName,
tool.description || '',
tool.signature
]
return parts.join(' ')
}
function rankTools(tools: GeneratedTool[], keywords: string[]): GeneratedTool[] {
const scored = tools.map((tool) => ({
tool,
score: calculateScore(tool, keywords)
}))
scored.sort((a, b) => b.score - a.score)
return scored.map((s) => s.tool)
}
function calculateScore(tool: GeneratedTool, keywords: string[]): number {
let score = 0
const toolName = tool.toolName.toLowerCase()
const serverName = (tool.serverName || '').toLowerCase()
const functionName = tool.functionName.toLowerCase()
const description = (tool.description || '').toLowerCase()
for (const keyword of keywords) {
// Match tool name
if (toolName === keyword) {
score += 10
} else if (toolName.startsWith(keyword)) {
score += 5
} else if (toolName.includes(keyword)) {
score += 3
}
// Match server name
if (serverName === keyword) {
score += 8
} else if (serverName.startsWith(keyword)) {
score += 4
} else if (serverName.includes(keyword)) {
score += 2
}
// Match function name (serverName_toolName format)
if (functionName === keyword) {
score += 10
} else if (functionName.startsWith(keyword)) {
score += 5
} else if (functionName.includes(keyword)) {
score += 3
}
if (description.includes(keyword)) {
const count = (description.match(new RegExp(escapeRegex(keyword), 'g')) || []).length
score += Math.min(count, 3)
}
}
return score
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

View File

@ -0,0 +1,113 @@
import type { MCPServer, MCPTool } from '@types'
export interface GeneratedTool {
serverId: string
serverName: string
toolName: string
functionName: string
jsCode: string
fn: (params: unknown) => Promise<unknown>
signature: string
returns: string
description?: string
}
export interface SearchQuery {
query: string
limit?: number
}
export interface SearchResult {
tools: string
total: number
}
export interface ExecInput {
code: string
}
export type ExecOutput = {
result: unknown
logs?: string[]
error?: string
isError?: boolean
}
export interface ToolRegistryOptions {
ttl?: number
}
export interface MCPToolWithServer extends MCPTool {
server: MCPServer
}
export interface ExecutionContext {
__callTool: (functionName: string, params: unknown) => Promise<unknown>
parallel: <T>(...promises: Promise<T>[]) => Promise<T[]>
settle: <T>(...promises: Promise<T>[]) => Promise<PromiseSettledResult<T>[]>
console: ConsoleMethods
[functionName: string]: unknown
}
export interface ConsoleMethods {
log: (...args: unknown[]) => void
warn: (...args: unknown[]) => void
error: (...args: unknown[]) => void
info: (...args: unknown[]) => void
debug: (...args: unknown[]) => void
}
export type HubWorkerTool = {
functionName: string
}
export type HubWorkerExecMessage = {
type: 'exec'
code: string
tools: HubWorkerTool[]
}
export type HubWorkerCallToolMessage = {
type: 'callTool'
requestId: string
functionName: string
params: unknown
}
export type HubWorkerToolResultMessage = {
type: 'toolResult'
requestId: string
result: unknown
}
export type HubWorkerToolErrorMessage = {
type: 'toolError'
requestId: string
error: string
}
export type HubWorkerResultMessage = {
type: 'result'
result: unknown
logs?: string[]
}
export type HubWorkerErrorMessage = {
type: 'error'
error: string
logs?: string[]
}
export type HubWorkerLogMessage = {
type: 'log'
entry: string
}
export type HubWorkerMessage =
| HubWorkerExecMessage
| HubWorkerCallToolMessage
| HubWorkerToolResultMessage
| HubWorkerToolErrorMessage
| HubWorkerResultMessage
| HubWorkerErrorMessage
| HubWorkerLogMessage

View File

@ -0,0 +1,133 @@
export const hubWorkerSource = `
const crypto = require('node:crypto')
const { parentPort } = require('node:worker_threads')
const MAX_LOGS = 1000
const logs = []
const pendingCalls = new Map()
let isExecuting = false
const stringify = (value) => {
if (value === undefined) return 'undefined'
if (value === null) return 'null'
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (value instanceof Error) return value.message
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
const pushLog = (level, args) => {
if (logs.length >= MAX_LOGS) {
return
}
const message = args.map((arg) => stringify(arg)).join(' ')
const entry = \`[\${level}] \${message}\`
logs.push(entry)
parentPort?.postMessage({ type: 'log', entry })
}
const capturedConsole = {
log: (...args) => pushLog('log', args),
warn: (...args) => pushLog('warn', args),
error: (...args) => pushLog('error', args),
info: (...args) => pushLog('info', args),
debug: (...args) => pushLog('debug', args)
}
const callTool = (functionName, params) =>
new Promise((resolve, reject) => {
const requestId = crypto.randomUUID()
pendingCalls.set(requestId, { resolve, reject })
parentPort?.postMessage({ type: 'callTool', requestId, functionName, params })
})
const buildContext = (tools) => {
const context = {
__callTool: callTool,
parallel: (...promises) => Promise.all(promises),
settle: (...promises) => Promise.allSettled(promises),
console: capturedConsole
}
for (const tool of tools) {
context[tool.functionName] = (params) => callTool(tool.functionName, params)
}
return context
}
const runCode = async (code, context) => {
const contextKeys = Object.keys(context)
const contextValues = contextKeys.map((key) => context[key])
const wrappedCode = \`
return (async () => {
\${code}
})()
\`
const fn = new Function(...contextKeys, wrappedCode)
return await fn(...contextValues)
}
const handleExec = async (code, tools) => {
if (isExecuting) {
return
}
isExecuting = true
try {
const context = buildContext(tools)
const result = await runCode(code, context)
parentPort?.postMessage({ type: 'result', result, logs: logs.length > 0 ? logs : undefined })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
parentPort?.postMessage({ type: 'error', error: errorMessage, logs: logs.length > 0 ? logs : undefined })
} finally {
pendingCalls.clear()
}
}
const handleToolResult = (message) => {
const pending = pendingCalls.get(message.requestId)
if (!pending) {
return
}
pendingCalls.delete(message.requestId)
pending.resolve(message.result)
}
const handleToolError = (message) => {
const pending = pendingCalls.get(message.requestId)
if (!pending) {
return
}
pendingCalls.delete(message.requestId)
pending.reject(new Error(message.error))
}
parentPort?.on('message', (message) => {
if (!message || typeof message !== 'object') {
return
}
switch (message.type) {
case 'exec':
handleExec(message.code, message.tools ?? [])
break
case 'toolResult':
handleToolResult(message)
break
case 'toolError':
handleToolError(message)
break
default:
break
}
})
`

View File

@ -3,9 +3,9 @@ import os from 'node:os'
import path from 'node:path'
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'
@ -35,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,
@ -165,6 +166,67 @@ class McpService {
this.getServerLogs = this.getServerLogs.bind(this)
}
/**
* List all tools from all active MCP servers (excluding hub).
* Used by Hub server's tool registry.
*/
public async listAllActiveServerTools(): Promise<MCPTool[]> {
const servers = await getMCPServersFromRedux()
const activeServers = servers.filter((server) => server.isActive)
const results = await Promise.allSettled(
activeServers.map(async (server) => {
const tools = await this.listToolsImpl(server)
const disabledTools = new Set(server.disabledTools ?? [])
return disabledTools.size > 0 ? tools.filter((tool) => !disabledTools.has(tool.name)) : tools
})
)
const allTools: MCPTool[] = []
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
allTools.push(...result.value)
} else {
logger.error(
`[listAllActiveServerTools] Failed to list tools from ${activeServers[index].name}:`,
result.reason as Error
)
}
})
return allTools
}
/**
* Call a tool by its full ID (serverId__toolName format).
* Used by Hub server's runtime.
*/
public async callToolById(toolId: string, params: unknown, callId?: string): Promise<MCPCallToolResponse> {
const parts = toolId.split('__')
if (parts.length < 2) {
throw new Error(`Invalid tool ID format: ${toolId}`)
}
const serverId = parts[0]
const toolName = parts.slice(1).join('__')
const servers = await getMCPServersFromRedux()
const server = servers.find((s) => s.id === serverId)
if (!server) {
throw new Error(`Server not found: ${serverId}`)
}
logger.debug(`[callToolById] Calling tool ${toolName} on server ${server.name}`)
return this.callTool(null as unknown as Electron.IpcMainInvokeEvent, {
server,
name: toolName,
args: params,
callId
})
}
private getServerKey(server: MCPServer): string {
return JSON.stringify({
baseUrl: server.baseUrl,

View File

@ -0,0 +1,75 @@
import type { MCPServer, MCPTool } from '@types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@main/apiServer/utils/mcp', () => ({
getMCPServersFromRedux: vi.fn()
}))
vi.mock('@main/services/WindowService', () => ({
windowService: {
getMainWindow: vi.fn(() => null)
}
}))
import { getMCPServersFromRedux } from '@main/apiServer/utils/mcp'
import mcpService from '@main/services/MCPService'
const baseInputSchema: { type: 'object'; properties: Record<string, unknown>; required: string[] } = {
type: 'object',
properties: {},
required: []
}
const createTool = (overrides: Partial<MCPTool>): MCPTool => ({
id: `${overrides.serverId}__${overrides.name}`,
name: overrides.name ?? 'tool',
description: overrides.description,
serverId: overrides.serverId ?? 'server',
serverName: overrides.serverName ?? 'server',
inputSchema: baseInputSchema,
type: 'mcp',
...overrides
})
describe('MCPService.listAllActiveServerTools', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('filters disabled tools per server', async () => {
const servers: MCPServer[] = [
{
id: 'alpha',
name: 'Alpha',
isActive: true,
disabledTools: ['disabled_tool']
},
{
id: 'beta',
name: 'Beta',
isActive: true
}
]
vi.mocked(getMCPServersFromRedux).mockResolvedValue(servers)
const listToolsSpy = vi.spyOn(mcpService as any, 'listToolsImpl').mockImplementation(async (server: any) => {
if (server.id === 'alpha') {
return [
createTool({ name: 'enabled_tool', serverId: server.id, serverName: server.name }),
createTool({ name: 'disabled_tool', serverId: server.id, serverName: server.name })
]
}
return [createTool({ name: 'beta_tool', serverId: server.id, serverName: server.name })]
})
const tools = await mcpService.listAllActiveServerTools()
expect(listToolsSpy).toHaveBeenCalledTimes(2)
expect(tools.map((tool) => tool.name)).toEqual(['enabled_tool', 'beta_tool'])
})
})

View File

@ -2,7 +2,7 @@ import { loggerService } from '@logger'
import { mcpApiService } from '@main/apiServer/services/mcp'
import type { ModelValidationError } from '@main/apiServer/utils'
import { validateModelId } from '@main/apiServer/utils'
import { buildFunctionCallToolName } from '@main/utils/mcp'
import { buildFunctionCallToolName } from '@shared/mcp'
import type { AgentType, MCPTool, SlashCommand, Tool } from '@types'
import { objectKeys } from '@types'
import fs from 'fs'

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
}

View File

@ -1,7 +1,7 @@
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
import { loggerService } from '@logger'
import { isGemini3Model, isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
import type { MCPTool } from '@renderer/types'
import type { McpMode, MCPTool } from '@renderer/types'
import { type Assistant, type Message, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { isOllamaProvider, isSupportEnableThinkingProvider } from '@renderer/utils/provider'
@ -38,6 +38,7 @@ export interface AiSdkMiddlewareConfig {
enableWebSearch: boolean
enableGenerateImage: boolean
enableUrlContext: boolean
mcpMode?: McpMode
mcpTools?: MCPTool[]
uiMessages?: Message[]
// 内置搜索配置

View File

@ -47,6 +47,7 @@ export function buildPlugins(
plugins.push(
createPromptToolUsePlugin({
enabled: true,
mcpMode: middlewareConfig.mcpMode,
createSystemMessage: (systemPrompt, params, context) => {
const modelId = typeof context.model === 'string' ? context.model : context.model.modelId
if (modelId.includes('o1-mini') || modelId.includes('o1-preview')) {

View File

@ -26,11 +26,13 @@ import {
isSupportedThinkingTokenModel,
isWebSearchModel
} from '@renderer/config/models'
import { getHubModeSystemPrompt } from '@renderer/config/prompts-code-mode'
import { fetchAllActiveServerTools } from '@renderer/services/ApiService'
import { getDefaultModel } from '@renderer/services/AssistantService'
import store from '@renderer/store'
import type { CherryWebSearchConfig } from '@renderer/store/websearch'
import type { Model } from '@renderer/types'
import { type Assistant, type MCPTool, type Provider, SystemProviderIds } from '@renderer/types'
import { type Assistant, getEffectiveMcpMode, type MCPTool, type Provider, SystemProviderIds } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
import { replacePromptVariables } from '@renderer/utils/prompt'
@ -243,8 +245,18 @@ export async function buildStreamTextParams(
params.tools = tools
}
if (assistant.prompt) {
params.system = await replacePromptVariables(assistant.prompt, model.name)
let systemPrompt = assistant.prompt ? await replacePromptVariables(assistant.prompt, model.name) : ''
if (getEffectiveMcpMode(assistant) === 'auto') {
const allActiveTools = await fetchAllActiveServerTools()
const autoModePrompt = getHubModeSystemPrompt(allActiveTools)
if (autoModePrompt) {
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${autoModePrompt}` : autoModePrompt
}
}
if (systemPrompt) {
params.system = systemPrompt
}
logger.debug('params', params)

View File

@ -0,0 +1,174 @@
import { generateMcpToolFunctionName } from '@shared/mcp'
export interface ToolInfo {
name: string
serverName?: string
description?: string
}
/**
* Hub Mode System Prompt - For native MCP tool calling
* Used when model supports native function calling via MCP protocol
*/
const HUB_MODE_SYSTEM_PROMPT_BASE = `
## Hub MCP Tools Code Execution Mode
You can discover and call MCP tools through the hub server using **ONLY two meta-tools**: **search** and **exec**.
### IMPORTANT: You can ONLY call these two tools directly
| Tool | Purpose |
|------|---------|
| \`search\` | Discover available tools and their signatures |
| \`exec\` | Execute JavaScript code that calls the discovered tools |
**All other tools (listed in "Discoverable Tools" below) can ONLY be called from INSIDE \`exec\` code.**
You CANNOT call them directly as tool calls. They are async functions available within the \`exec\` runtime.
### Critical Rules (Read First)
1. **ONLY \`search\` and \`exec\` are callable as tools.** All other tools must be used inside \`exec\` code.
2. You MUST explicitly \`return\` the final value from your \`exec\` code. If you do not return a value, the result will be \`undefined\`.
3. All MCP tools inside \`exec\` are async functions. Always call them as \`await ToolName(params)\`.
4. Use the exact function names and parameter shapes returned by \`search\`.
5. You CANNOT call \`search\` or \`exec\` from inside \`exec\` code—use them only as direct tool calls.
6. \`console.log\` output is NOT the result. Logs are separate; the final answer must come from \`return\`.
### Workflow
1. Call \`search\` with relevant keywords to discover tools.
2. Read the returned JavaScript function declarations and JSDoc to understand names and parameters.
3. Call \`exec\` with JavaScript code that uses the discovered tools and ends with an explicit \`return\`.
4. Use the \`exec\` result as your answer.
### What \`search\` Does
- Input: keyword string (comma-separated for OR-matching), plus optional \`limit\`.
- Output: JavaScript async function declarations with JSDoc showing exact function names, parameters, and return types.
### What \`exec\` Does
- Runs JavaScript code in an isolated async context (wrapped as \`(async () => { your code })())\`.
- All discovered tools are exposed as async functions: \`await ToolName(params)\`.
- Available helpers:
- \`parallel(...promises)\`\`Promise.all(promises)\`
- \`settle(...promises)\`\`Promise.allSettled(promises)\`
- \`console.log/info/warn/error/debug\`
- Returns JSON with: \`result\` (your returned value), \`logs\` (optional), \`error\` (optional), \`isError\` (optional).
### Example: Single Tool Call
\`\`\`javascript
// Step 1: search({ query: "browser,fetch" })
// Step 2: exec with:
const page = await CherryBrowser_fetch({ url: "https://example.com" })
return page
\`\`\`
### Example: Multiple Tools with Parallel
\`\`\`javascript
const [forecast, time] = await parallel(
Weather_getForecast({ city: "Paris" }),
Time_getLocalTime({ city: "Paris" })
)
return { city: "Paris", forecast, time }
\`\`\`
### Example: Handle Partial Failures with Settle
\`\`\`javascript
const results = await settle(
Weather_getForecast({ city: "Paris" }),
Weather_getForecast({ city: "Tokyo" })
)
const successful = results.filter(r => r.status === "fulfilled").map(r => r.value)
return { results, successful }
\`\`\`
### Example: Error Handling
\`\`\`javascript
try {
const user = await User_lookup({ email: "user@example.com" })
return { found: true, user }
} catch (error) {
return { found: false, error: String(error) }
}
\`\`\`
### Common Mistakes to Avoid
**Forgetting to return** (result will be \`undefined\`):
\`\`\`javascript
const data = await SomeTool({ id: "123" })
// Missing return!
\`\`\`
**Always return**:
\`\`\`javascript
const data = await SomeTool({ id: "123" })
return data
\`\`\`
**Only logging, not returning**:
\`\`\`javascript
const data = await SomeTool({ id: "123" })
console.log(data) // Logs are NOT the result!
\`\`\`
**Missing await**:
\`\`\`javascript
const data = SomeTool({ id: "123" }) // Returns Promise, not value!
return data
\`\`\`
**Awaiting before parallel**:
\`\`\`javascript
await parallel(await ToolA(), await ToolB()) // Wrong: runs sequentially
\`\`\`
**Pass promises directly to parallel**:
\`\`\`javascript
await parallel(ToolA(), ToolB()) // Correct: runs in parallel
\`\`\`
### Best Practices
- Always call \`search\` first to discover tools and confirm signatures.
- Always use an explicit \`return\` at the end of \`exec\` code.
- Use \`parallel\` for independent operations that can run at the same time.
- Use \`settle\` when some calls may fail but you still want partial results.
- Prefer a single \`exec\` call for multi-step flows.
- Treat \`console.*\` as debugging only, never as the primary result.
`
function buildToolsSection(tools: ToolInfo[]): string {
const existingNames = new Set<string>()
return tools
.map((t) => {
const functionName = generateMcpToolFunctionName(t.serverName, t.name, existingNames)
const desc = t.description || ''
const normalizedDesc = desc.replace(/\s+/g, ' ').trim()
const truncatedDesc = normalizedDesc.length > 50 ? `${normalizedDesc.slice(0, 50)}...` : normalizedDesc
return `- ${functionName}: ${truncatedDesc}`
})
.join('\n')
}
export function getHubModeSystemPrompt(tools: ToolInfo[] = []): string {
if (tools.length === 0) {
return ''
}
const toolsSection = buildToolsSection(tools)
return `${HUB_MODE_SYSTEM_PROMPT_BASE}
## Discoverable Tools (ONLY usable inside \`exec\` code, NOT as direct tool calls)
The following tools are available inside \`exec\`. Use \`search\` to get their full signatures.
Do NOT call these directlywrap them in \`exec\` code.
${toolsSection}
`
}

View File

@ -332,7 +332,8 @@ const builtInMcpDescriptionKeyMap: Record<BuiltinMCPServerName, string> = {
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python',
[BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp',
[BuiltinMCPServerNames.browser]: 'settings.mcp.builtinServersDescriptions.browser',
[BuiltinMCPServerNames.nowledgeMem]: 'settings.mcp.builtinServersDescriptions.nowledge_mem'
[BuiltinMCPServerNames.nowledgeMem]: 'settings.mcp.builtinServersDescriptions.nowledge_mem',
[BuiltinMCPServerNames.hub]: 'settings.mcp.builtinServersDescriptions.hub'
} as const
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {

View File

@ -544,6 +544,20 @@
"description": "Default enabled MCP servers",
"enableFirst": "Enable this server in MCP settings first",
"label": "MCP Servers",
"mode": {
"auto": {
"description": "AI discovers and uses tools automatically",
"label": "Auto"
},
"disabled": {
"description": "No MCP tools",
"label": "Disabled"
},
"manual": {
"description": "Select specific MCP servers",
"label": "Manual"
}
},
"noServersAvailable": "No MCP servers available. Add servers in settings",
"title": "MCP Settings"
},

View File

@ -544,6 +544,20 @@
"description": "默认启用的 MCP 服务器",
"enableFirst": "请先在 MCP 设置中启用此服务器",
"label": "MCP 服务器",
"mode": {
"auto": {
"description": "AI 自动发现和使用工具",
"label": "自动"
},
"disabled": {
"description": "不使用 MCP 工具",
"label": "禁用"
},
"manual": {
"description": "选择特定的 MCP 服务器",
"label": "手动"
}
},
"noServersAvailable": "无可用 MCP 服务器。请在设置中添加服务器",
"title": "MCP 服务器"
},

View File

@ -544,6 +544,20 @@
"description": "預設啟用的 MCP 伺服器",
"enableFirst": "請先在 MCP 設定中啟用此伺服器",
"label": "MCP 伺服器",
"mode": {
"auto": {
"description": "AI 自動發現和使用工具",
"label": "自動"
},
"disabled": {
"description": "不使用 MCP 工具",
"label": "停用"
},
"manual": {
"description": "選擇特定的 MCP 伺服器",
"label": "手動"
}
},
"noServersAvailable": "無可用 MCP 伺服器。請在設定中新增伺服器",
"title": "MCP 設定"
},

View File

@ -544,6 +544,20 @@
"description": "Standardmäßig aktivierte MCP-Server",
"enableFirst": "Bitte aktivieren Sie diesen Server zuerst in den MCP-Einstellungen",
"label": "MCP-Server",
"mode": {
"auto": {
"description": "KI entdeckt und nutzt Werkzeuge automatisch",
"label": "Auto"
},
"disabled": {
"description": "Keine MCP-Tools",
"label": "Deaktiviert"
},
"manual": {
"description": "Wählen Sie spezifische MCP-Server",
"label": "Handbuch"
}
},
"noServersAvailable": "Keine MCP-Server verfügbar. Bitte fügen Sie Server in den Einstellungen hinzu",
"title": "MCP-Server"
},
@ -1297,6 +1311,7 @@
"backup": {
"file_format": "Backup-Dateiformat fehlerhaft"
},
"base64DataTruncated": "Base64-Bilddaten abgeschnitten, Größe",
"boundary": {
"default": {
"devtools": "Debug-Panel öffnen",
@ -1377,6 +1392,8 @@
"text": "Text",
"toolInput": "Tool-Eingabe",
"toolName": "Tool-Name",
"truncated": "Daten wurden gekürzt, Originalgröße",
"truncatedBadge": "Abgeschnitten",
"unknown": "Unbekannter Fehler",
"usage": "Nutzung",
"user_message_not_found": "Ursprüngliche Benutzernachricht nicht gefunden",

View File

@ -544,6 +544,20 @@
"description": "Διακομιστής MCP που είναι ενεργοποιημένος εξ ορισμού",
"enableFirst": "Πρώτα ενεργοποιήστε αυτόν τον διακομιστή στις ρυθμίσεις MCP",
"label": "Διακομιστής MCP",
"mode": {
"auto": {
"description": "Η τεχνητή νοημοσύνη ανακαλύπτει και χρησιμοποιεί εργαλεία αυτόματα",
"label": "Αυτόματο"
},
"disabled": {
"description": "Χωρίς εργαλεία MCP",
"label": "Ανάπηρος"
},
"manual": {
"description": "Επιλέξτε συγκεκριμένους διακομιστές MCP",
"label": "Εγχειρίδιο"
}
},
"noServersAvailable": "Δεν υπάρχουν διαθέσιμοι διακομιστές MCP. Προσθέστε ένα διακομιστή στις ρυθμίσεις",
"title": "Ρυθμίσεις MCP"
},
@ -1297,6 +1311,7 @@
"backup": {
"file_format": "Λάθος μορφή αρχείου που επιστρέφεται"
},
"base64DataTruncated": "Τα δεδομένα εικόνας Base64 έχουν περικοπεί, μέγεθος",
"boundary": {
"default": {
"devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης",
@ -1377,6 +1392,8 @@
"text": "κείμενο",
"toolInput": "εισαγωγή εργαλείου",
"toolName": "Όνομα εργαλείου",
"truncated": "Δεδομένα περικόπηκαν, αρχικό μέγεθος",
"truncatedBadge": "Αποκομμένο",
"unknown": "Άγνωστο σφάλμα",
"usage": "δοσολογία",
"user_message_not_found": "Αδυναμία εύρεσης της αρχικής μηνύματος χρήστη",

View File

@ -544,6 +544,20 @@
"description": "Servidor MCP habilitado por defecto",
"enableFirst": "Habilite este servidor en la configuración de MCP primero",
"label": "Servidor MCP",
"mode": {
"auto": {
"description": "La IA descubre y utiliza herramientas automáticamente",
"label": "Auto"
},
"disabled": {
"description": "Sin herramientas MCP",
"label": "Discapacitado"
},
"manual": {
"description": "Seleccionar servidores MCP específicos",
"label": "Manual"
}
},
"noServersAvailable": "No hay servidores MCP disponibles. Agregue un servidor en la configuración",
"title": "Configuración MCP"
},
@ -1297,6 +1311,7 @@
"backup": {
"file_format": "Formato de archivo de copia de seguridad incorrecto"
},
"base64DataTruncated": "Datos de imagen Base64 truncados, tamaño",
"boundary": {
"default": {
"devtools": "Abrir el panel de depuración",
@ -1377,6 +1392,8 @@
"text": "Texto",
"toolInput": "Herramienta de entrada",
"toolName": "Nombre de la herramienta",
"truncated": "Datos truncados, tamaño original",
"truncatedBadge": "Truncado",
"unknown": "Error desconocido",
"usage": "Cantidad de uso",
"user_message_not_found": "No se pudo encontrar el mensaje original del usuario",

View File

@ -544,6 +544,20 @@
"description": "Serveur MCP activé par défaut",
"enableFirst": "Veuillez d'abord activer ce serveur dans les paramètres MCP",
"label": "Serveur MCP",
"mode": {
"auto": {
"description": "L'IA découvre et utilise des outils automatiquement",
"label": "Auto"
},
"disabled": {
"description": "Aucun outil MCP",
"label": "Désactivé"
},
"manual": {
"description": "Sélectionner des serveurs MCP spécifiques",
"label": "Manuel"
}
},
"noServersAvailable": "Aucun serveur MCP disponible. Veuillez ajouter un serveur dans les paramètres",
"title": "Paramètres MCP"
},
@ -1297,6 +1311,7 @@
"backup": {
"file_format": "Le format du fichier de sauvegarde est incorrect"
},
"base64DataTruncated": "Données d'image Base64 tronquées, taille",
"boundary": {
"default": {
"devtools": "Ouvrir le panneau de débogage",
@ -1377,6 +1392,8 @@
"text": "texte",
"toolInput": "entrée de l'outil",
"toolName": "Nom de l'outil",
"truncated": "Données tronquées, taille d'origine",
"truncatedBadge": "Tronqué",
"unknown": "Неизвестная ошибка",
"usage": "Quantité",
"user_message_not_found": "Impossible de trouver le message d'utilisateur original",

View File

@ -544,6 +544,20 @@
"description": "デフォルトで有効な MCP サーバー",
"enableFirst": "まず MCP 設定でこのサーバーを有効にしてください",
"label": "MCP サーバー",
"mode": {
"auto": {
"description": "AIはツールを自動的に発見し、使用する",
"label": "オート"
},
"disabled": {
"description": "MCPツールなし",
"label": "無効"
},
"manual": {
"description": "特定のMCPサーバーを選択",
"label": "マニュアル"
}
},
"noServersAvailable": "利用可能な MCP サーバーがありません。設定でサーバーを追加してください",
"title": "MCP 設定"
},
@ -1297,6 +1311,7 @@
"backup": {
"file_format": "バックアップファイルの形式エラー"
},
"base64DataTruncated": "Base64画像データが切り捨てられています、サイズ",
"boundary": {
"default": {
"devtools": "デバッグパネルを開く",
@ -1377,6 +1392,8 @@
"text": "テキスト",
"toolInput": "ツール入力",
"toolName": "ツール名",
"truncated": "データが切り捨てられました、元のサイズ",
"truncatedBadge": "切り捨て",
"unknown": "不明なエラー",
"usage": "用量",
"user_message_not_found": "元のユーザーメッセージを見つけることができませんでした",

View File

@ -544,6 +544,20 @@
"description": "Servidor MCP ativado por padrão",
"enableFirst": "Por favor, ative este servidor nas configurações do MCP primeiro",
"label": "Servidor MCP",
"mode": {
"auto": {
"description": "IA descobre e usa ferramentas automaticamente",
"label": "Auto"
},
"disabled": {
"description": "Sem ferramentas MCP",
"label": "Desativado"
},
"manual": {
"description": "Selecione servidores MCP específicos",
"label": "Manual"
}
},
"noServersAvailable": "Nenhum servidor MCP disponível. Adicione um servidor nas configurações",
"title": "Configurações do MCP"
},
@ -1297,6 +1311,7 @@
"backup": {
"file_format": "Formato do arquivo de backup está incorreto"
},
"base64DataTruncated": "Dados da imagem em Base64 truncados, tamanho",
"boundary": {
"default": {
"devtools": "Abrir o painel de depuração",
@ -1377,6 +1392,8 @@
"text": "texto",
"toolInput": "ferramenta de entrada",
"toolName": "Nome da ferramenta",
"truncated": "Dados truncados, tamanho original",
"truncatedBadge": "Truncado",
"unknown": "Erro desconhecido",
"usage": "dosagem",
"user_message_not_found": "Não foi possível encontrar a mensagem original do usuário",

View File

@ -544,6 +544,20 @@
"description": "Servere MCP activate implicit",
"enableFirst": "Activează mai întâi acest server în setările MCP",
"label": "Servere MCP",
"mode": {
"auto": {
"description": "AI descoperă și folosește instrumente automat",
"label": "Auto"
},
"disabled": {
"description": "Niciun instrument MCP",
"label": "Dezactivat"
},
"manual": {
"description": "Selectați servere MCP specifice",
"label": "Manual"
}
},
"noServersAvailable": "Nu există servere MCP disponibile. Adaugă servere în setări",
"title": "Setări MCP"
},
@ -1297,6 +1311,7 @@
"backup": {
"file_format": "Eroare format fișier backup"
},
"base64DataTruncated": "Datele imagine Base64 sunt trunchiate, dimensiunea",
"boundary": {
"default": {
"devtools": "Deschide panoul de depanare",
@ -1377,6 +1392,8 @@
"text": "Text",
"toolInput": "Intrare instrument",
"toolName": "Nume instrument",
"truncated": "Date trunchiate, dimensiunea originală",
"truncatedBadge": "Trunchiat",
"unknown": "Eroare necunoscută",
"usage": "Utilizare",
"user_message_not_found": "Nu se poate găsi mesajul original al utilizatorului pentru a retrimite",

View File

@ -544,6 +544,20 @@
"description": "Серверы MCP, включенные по умолчанию",
"enableFirst": "Сначала включите этот сервер в настройках MCP",
"label": "Серверы MCP",
"mode": {
"auto": {
"description": "ИИ самостоятельно обнаруживает и использует инструменты",
"label": "Авто"
},
"disabled": {
"description": "Нет инструментов MCP",
"label": "Отключено"
},
"manual": {
"description": "Выберите конкретные MCP-серверы",
"label": "Руководство"
}
},
"noServersAvailable": "Нет доступных серверов MCP. Добавьте серверы в настройках",
"title": "Настройки MCP"
},
@ -1297,6 +1311,7 @@
"backup": {
"file_format": "Ошибка формата файла резервной копии"
},
"base64DataTruncated": "Данные изображения в формате Base64 усечены, размер",
"boundary": {
"default": {
"devtools": "Открыть панель отладки",
@ -1377,6 +1392,8 @@
"text": "текст",
"toolInput": "ввод инструмента",
"toolName": "имя инструмента",
"truncated": "Данные усечены, исходный размер",
"truncatedBadge": "Усечённый",
"unknown": "Неизвестная ошибка",
"usage": "Дозировка",
"user_message_not_found": "Не удалось найти исходное сообщение пользователя",

View File

@ -8,11 +8,12 @@ import { useTimer } from '@renderer/hooks/useTimer'
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { EventEmitter } from '@renderer/services/EventService'
import type { MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
import type { McpMode, MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
import { getEffectiveMcpMode } from '@renderer/types'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/utils/provider'
import { Form, Input, Tooltip } from 'antd'
import { CircleX, Hammer, Plus } from 'lucide-react'
import { CircleX, Hammer, Plus, Sparkles } from 'lucide-react'
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -25,7 +26,6 @@ interface Props {
resizeTextArea: () => void
}
// 添加类型定义
interface PromptArgument {
name: string
description?: string
@ -44,24 +44,19 @@ interface ResourceData {
uri?: string
}
// 提取到组件外的工具函数
const extractPromptContent = (response: any): string | null => {
// Handle string response (backward compatibility)
if (typeof response === 'string') {
return response
}
// Handle GetMCPPromptResponse format
if (response && Array.isArray(response.messages)) {
let formattedContent = ''
for (const message of response.messages) {
if (!message.content) continue
// Add role prefix if available
const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : ''
// Process different content types
switch (message.content.type) {
case 'text':
formattedContent += `${rolePrefix}${message.content.text}\n\n`
@ -98,7 +93,6 @@ const extractPromptContent = (response: any): string | null => {
return formattedContent.trim()
}
// Fallback handling for single message format
if (response && response.messages && response.messages.length > 0) {
const message = response.messages[0]
if (message.content && message.content.text) {
@ -121,7 +115,6 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
const model = assistant.model
const { setTimeoutTimer } = useTimer()
// 使用 useRef 存储不需要触发重渲染的值
const isMountedRef = useRef(true)
useEffect(() => {
@ -130,11 +123,30 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
}
}, [])
const currentMode = useMemo(() => getEffectiveMcpMode(assistant), [assistant])
const mcpServers = useMemo(() => assistant.mcpServers || [], [assistant.mcpServers])
const assistantMcpServers = useMemo(
() => activedMcpServers.filter((server) => mcpServers.some((s) => s.id === server.id)),
[activedMcpServers, mcpServers]
)
const handleModeChange = useCallback(
(mode: McpMode) => {
setTimeoutTimer(
'updateMcpMode',
() => {
updateAssistant({
...assistant,
mcpMode: mode
})
},
200
)
},
[assistant, setTimeoutTimer, updateAssistant]
)
const handleMcpServerSelect = useCallback(
(server: MCPServer) => {
const update = { ...assistant }
@ -144,29 +156,24 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
update.mcpServers = [...mcpServers, server]
}
// only for gemini
if (update.mcpServers.length > 0 && isGeminiModel(model) && isToolUseModeFunction(assistant)) {
const provider = getProviderByModel(model)
if (isSupportUrlContextProvider(provider) && assistant.enableUrlContext) {
window.toast.warning(t('chat.mcp.warning.url_context'))
update.enableUrlContext = false
}
if (
// 非官方 API (openrouter etc.) 可能支持同时启用内置搜索和函数调用
// 这里先假设 gemini type 和 vertexai type 不支持
isGeminiWebSearchProvider(provider) &&
assistant.enableWebSearch
) {
if (isGeminiWebSearchProvider(provider) && assistant.enableWebSearch) {
window.toast.warning(t('chat.mcp.warning.gemini_web_search'))
update.enableWebSearch = false
}
}
update.mcpMode = 'manual'
updateAssistant(update)
},
[assistant, assistantMcpServers, mcpServers, model, t, updateAssistant]
)
// 使用 useRef 缓存事件处理函数
const handleMcpServerSelectRef = useRef(handleMcpServerSelect)
handleMcpServerSelectRef.current = handleMcpServerSelect
@ -176,23 +183,7 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
return () => EventEmitter.off('mcp-server-select', handler)
}, [])
const updateMcpEnabled = useCallback(
(enabled: boolean) => {
setTimeoutTimer(
'updateMcpEnabled',
() => {
updateAssistant({
...assistant,
mcpServers: enabled ? assistant.mcpServers || [] : []
})
},
200
)
},
[assistant, setTimeoutTimer, updateAssistant]
)
const menuItems = useMemo(() => {
const manualModeMenuItems = useMemo(() => {
const newList: QuickPanelListItem[] = activedMcpServers.map((server) => ({
label: server.name,
description: server.description || server.baseUrl,
@ -207,33 +198,70 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
action: () => navigate('/settings/mcp')
})
newList.unshift({
label: t('settings.input.clear.all'),
description: t('settings.mcp.disable.description'),
icon: <CircleX />,
isSelected: false,
action: () => {
updateMcpEnabled(false)
quickPanelHook.close()
}
})
return newList
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanelHook])
}, [activedMcpServers, t, assistantMcpServers, navigate])
const openQuickPanel = useCallback(() => {
const openManualModePanel = useCallback(() => {
quickPanelHook.open({
title: t('settings.mcp.title'),
list: menuItems,
title: t('assistants.settings.mcp.mode.manual.label'),
list: manualModeMenuItems,
symbol: QuickPanelReservedSymbol.Mcp,
multiple: true,
afterAction({ item }) {
item.isSelected = !item.isSelected
}
})
}, [manualModeMenuItems, quickPanelHook, t])
const menuItems = useMemo(() => {
const newList: QuickPanelListItem[] = []
newList.push({
label: t('assistants.settings.mcp.mode.disabled.label'),
description: t('assistants.settings.mcp.mode.disabled.description'),
icon: <CircleX />,
isSelected: currentMode === 'disabled',
action: () => {
handleModeChange('disabled')
quickPanelHook.close()
}
})
newList.push({
label: t('assistants.settings.mcp.mode.auto.label'),
description: t('assistants.settings.mcp.mode.auto.description'),
icon: <Sparkles />,
isSelected: currentMode === 'auto',
action: () => {
handleModeChange('auto')
quickPanelHook.close()
}
})
newList.push({
label: t('assistants.settings.mcp.mode.manual.label'),
description: t('assistants.settings.mcp.mode.manual.description'),
icon: <Hammer />,
isSelected: currentMode === 'manual',
isMenu: true,
action: () => {
handleModeChange('manual')
openManualModePanel()
}
})
return newList
}, [t, currentMode, handleModeChange, quickPanelHook, openManualModePanel])
const openQuickPanel = useCallback(() => {
quickPanelHook.open({
title: t('settings.mcp.title'),
list: menuItems,
symbol: QuickPanelReservedSymbol.Mcp,
multiple: false
})
}, [menuItems, quickPanelHook, t])
// 使用 useCallback 优化 insertPromptIntoTextArea
const insertPromptIntoTextArea = useCallback(
(promptText: string) => {
setInputValue((prev) => {
@ -245,7 +273,6 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
const selectionEndPosition = cursorPosition + promptText.length
const newText = prev.slice(0, cursorPosition) + promptText + prev.slice(cursorPosition)
// 使用 requestAnimationFrame 优化 DOM 操作
requestAnimationFrame(() => {
textArea.focus()
textArea.setSelectionRange(selectionStart, selectionEndPosition)
@ -424,7 +451,6 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
[activedMcpServers, t, insertPromptIntoTextArea]
)
// 优化 resourcesList 的状态更新
const [resourcesList, setResourcesList] = useState<QuickPanelListItem[]>([])
useEffect(() => {
@ -514,17 +540,26 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
}
}, [openPromptList, openQuickPanel, openResourcesList, quickPanel, t])
const isActive = currentMode !== 'disabled'
const getButtonIcon = () => {
switch (currentMode) {
case 'auto':
return <Sparkles size={18} />
case 'disabled':
case 'manual':
default:
return <Hammer size={18} />
}
}
return (
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
<ActionIconButton
onClick={handleOpenQuickPanel}
active={assistant.mcpServers && assistant.mcpServers.length > 0}
aria-label={t('settings.mcp.title')}>
<Hammer size={18} />
<ActionIconButton onClick={handleOpenQuickPanel} active={isActive} aria-label={t('settings.mcp.title')}>
{getButtonIcon()}
</ActionIconButton>
</Tooltip>
)
}
// 使用 React.memo 包装组件
export default React.memo(MCPToolsButton)

View File

@ -1,6 +1,7 @@
import { ActionIconButton } from '@renderer/components/Buttons'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import { getEffectiveMcpMode } from '@renderer/types'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { Tooltip } from 'antd'
import { Link } from 'lucide-react'
@ -30,8 +31,7 @@ const UrlContextButton: FC<Props> = ({ assistantId }) => {
() => {
const update = { ...assistant }
if (
assistant.mcpServers &&
assistant.mcpServers.length > 0 &&
getEffectiveMcpMode(assistant) !== 'disabled' &&
urlContentNewState === true &&
isToolUseModeFunction(assistant)
) {

View File

@ -16,7 +16,7 @@ import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders'
import type { ToolQuickPanelController, ToolRenderContext } from '@renderer/pages/home/Inputbar/types'
import { getProviderByModel } from '@renderer/services/AssistantService'
import WebSearchService from '@renderer/services/WebSearchService'
import type { WebSearchProvider, WebSearchProviderId } from '@renderer/types'
import { getEffectiveMcpMode, type WebSearchProvider, type WebSearchProviderId } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { isPromptToolUse } from '@renderer/utils/mcp-tools'
@ -108,8 +108,7 @@ export const useWebSearchPanelController = (assistantId: string, quickPanelContr
isGeminiModel(model) &&
isToolUseModeFunction(assistant) &&
update.enableWebSearch &&
assistant.mcpServers &&
assistant.mcpServers.length > 0
getEffectiveMcpMode(assistant) !== 'disabled'
) {
update.enableWebSearch = false
window.toast.warning(t('chat.mcp.warning.gemini_web_search'))

View File

@ -1,8 +1,9 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { Box } from '@renderer/components/Layout'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import type { Assistant, AssistantSettings } from '@renderer/types'
import { Empty, Switch, Tooltip } from 'antd'
import type { Assistant, AssistantSettings, McpMode } from '@renderer/types'
import { getEffectiveMcpMode } from '@renderer/types'
import { Empty, Radio, Switch, Tooltip } from 'antd'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -27,22 +28,26 @@ const AssistantMCPSettings: React.FC<Props> = ({ assistant, updateAssistant }) =
const { t } = useTranslation()
const { mcpServers: allMcpServers } = useMCPServers()
const currentMode = getEffectiveMcpMode(assistant)
const handleModeChange = (mode: McpMode) => {
updateAssistant({ ...assistant, mcpMode: mode })
}
const onUpdate = (ids: string[]) => {
const mcpServers = ids
.map((id) => allMcpServers.find((server) => server.id === id))
.filter((server): server is MCPServer => server !== undefined && server.isActive)
updateAssistant({ ...assistant, mcpServers })
updateAssistant({ ...assistant, mcpServers, mcpMode: 'manual' })
}
const handleServerToggle = (serverId: string) => {
const currentServerIds = assistant.mcpServers?.map((server) => server.id) || []
if (currentServerIds.includes(serverId)) {
// Remove server if it's already enabled
onUpdate(currentServerIds.filter((id) => id !== serverId))
} else {
// Add server if it's not enabled
onUpdate([...currentServerIds, serverId])
}
}
@ -58,49 +63,77 @@ const AssistantMCPSettings: React.FC<Props> = ({ assistant, updateAssistant }) =
<InfoIcon />
</Tooltip>
</Box>
{allMcpServers.length > 0 && (
<EnabledCount>
{enabledCount} / {allMcpServers.length} {t('settings.mcp.active')}
</EnabledCount>
)}
</HeaderContainer>
{allMcpServers.length > 0 ? (
<ServerList>
{allMcpServers.map((server) => {
const isEnabled = assistant.mcpServers?.some((s) => s.id === server.id) || false
<ModeSelector>
<Radio.Group value={currentMode} onChange={(e) => handleModeChange(e.target.value)}>
<Radio.Button value="disabled">
<ModeOption>
<ModeLabel>{t('assistants.settings.mcp.mode.disabled.label')}</ModeLabel>
<ModeDescription>{t('assistants.settings.mcp.mode.disabled.description')}</ModeDescription>
</ModeOption>
</Radio.Button>
<Radio.Button value="auto">
<ModeOption>
<ModeLabel>{t('assistants.settings.mcp.mode.auto.label')}</ModeLabel>
<ModeDescription>{t('assistants.settings.mcp.mode.auto.description')}</ModeDescription>
</ModeOption>
</Radio.Button>
<Radio.Button value="manual">
<ModeOption>
<ModeLabel>{t('assistants.settings.mcp.mode.manual.label')}</ModeLabel>
<ModeDescription>{t('assistants.settings.mcp.mode.manual.description')}</ModeDescription>
</ModeOption>
</Radio.Button>
</Radio.Group>
</ModeSelector>
return (
<ServerItem key={server.id} isEnabled={isEnabled}>
<ServerInfo>
<ServerName>{server.name}</ServerName>
{server.description && <ServerDescription>{server.description}</ServerDescription>}
{server.baseUrl && <ServerUrl>{server.baseUrl}</ServerUrl>}
</ServerInfo>
<Tooltip
title={
!server.isActive
? t('assistants.settings.mcp.enableFirst', 'Enable this server in MCP settings first')
: undefined
}>
<Switch
checked={isEnabled}
disabled={!server.isActive}
onChange={() => handleServerToggle(server.id)}
size="small"
/>
</Tooltip>
</ServerItem>
)
})}
</ServerList>
) : (
<EmptyContainer>
<Empty
description={t('assistants.settings.mcp.noServersAvailable', 'No MCP servers available')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</EmptyContainer>
{currentMode === 'manual' && (
<>
{allMcpServers.length > 0 && (
<EnabledCount>
{enabledCount} / {allMcpServers.length} {t('settings.mcp.active')}
</EnabledCount>
)}
{allMcpServers.length > 0 ? (
<ServerList>
{allMcpServers.map((server) => {
const isEnabled = assistant.mcpServers?.some((s) => s.id === server.id) || false
return (
<ServerItem key={server.id} isEnabled={isEnabled}>
<ServerInfo>
<ServerName>{server.name}</ServerName>
{server.description && <ServerDescription>{server.description}</ServerDescription>}
{server.baseUrl && <ServerUrl>{server.baseUrl}</ServerUrl>}
</ServerInfo>
<Tooltip
title={
!server.isActive
? t('assistants.settings.mcp.enableFirst', 'Enable this server in MCP settings first')
: undefined
}>
<Switch
checked={isEnabled}
disabled={!server.isActive}
onChange={() => handleServerToggle(server.id)}
size="small"
/>
</Tooltip>
</ServerItem>
)
})}
</ServerList>
) : (
<EmptyContainer>
<Empty
description={t('assistants.settings.mcp.noServersAvailable', 'No MCP servers available')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</EmptyContainer>
)}
</>
)}
</Container>
)
@ -110,7 +143,7 @@ const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
min-height: 0;
`
const HeaderContainer = styled.div`
@ -127,9 +160,54 @@ const InfoIcon = styled(InfoCircleOutlined)`
cursor: help;
`
const ModeSelector = styled.div`
margin-bottom: 16px;
.ant-radio-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.ant-radio-button-wrapper {
height: auto;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid var(--color-border);
&:not(:first-child)::before {
display: none;
}
&:first-child {
border-radius: 8px;
}
&:last-child {
border-radius: 8px;
}
}
`
const ModeOption = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
`
const ModeLabel = styled.span`
font-weight: 600;
`
const ModeDescription = styled.span`
font-size: 12px;
color: var(--color-text-2);
`
const EnabledCount = styled.span`
font-size: 12px;
color: var(--color-text-2);
margin-bottom: 8px;
`
const EmptyContainer = styled.div`

View File

@ -8,8 +8,9 @@ import { isDedicatedImageGenerationModel, isEmbeddingModel, isFunctionCallingMod
import { getStoreSetting } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { hubMCPServer } from '@renderer/store/mcp'
import type { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types'
import { type FetchChatCompletionParams, isSystemProvider } from '@renderer/types'
import { type FetchChatCompletionParams, getEffectiveMcpMode, isSystemProvider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { type Chunk, ChunkType } from '@renderer/types/chunk'
import type { Message, ResponseError } from '@renderer/types/newMessage'
@ -51,14 +52,60 @@ import type { StreamProcessorCallbacks } from './StreamProcessingService'
const logger = loggerService.withContext('ApiService')
export async function fetchMcpTools(assistant: Assistant) {
// Get MCP tools (Fix duplicate declaration)
let mcpTools: MCPTool[] = [] // Initialize as empty array
/**
* Get the MCP servers to use based on the assistant's MCP mode.
*/
export function getMcpServersForAssistant(assistant: Assistant): MCPServer[] {
const mode = getEffectiveMcpMode(assistant)
const allMcpServers = store.getState().mcp.servers || []
const activedMcpServers = allMcpServers.filter((s) => s.isActive)
const assistantMcpServers = assistant.mcpServers || []
const enabledMCPs = activedMcpServers.filter((server) => assistantMcpServers.some((s) => s.id === server.id))
switch (mode) {
case 'disabled':
return []
case 'auto':
return [hubMCPServer]
case 'manual': {
const assistantMcpServers = assistant.mcpServers || []
return activedMcpServers.filter((server) => assistantMcpServers.some((s) => s.id === server.id))
}
default:
return []
}
}
export async function fetchAllActiveServerTools(): Promise<MCPTool[]> {
const allMcpServers = store.getState().mcp.servers || []
const activedMcpServers = allMcpServers.filter((s) => s.isActive)
if (activedMcpServers.length === 0) {
return []
}
try {
const toolPromises = activedMcpServers.map(async (mcpServer: MCPServer) => {
try {
const tools = await window.api.mcp.listTools(mcpServer)
return tools.filter((tool: any) => !mcpServer.disabledTools?.includes(tool.name))
} catch (error) {
logger.error(`Error fetching tools from MCP server ${mcpServer.name}:`, error as Error)
return []
}
})
const results = await Promise.allSettled(toolPromises)
return results
.filter((result): result is PromiseFulfilledResult<MCPTool[]> => result.status === 'fulfilled')
.map((result) => result.value)
.flat()
} catch (toolError) {
logger.error('Error fetching all active server tools:', toolError as Error)
return []
}
}
export async function fetchMcpTools(assistant: Assistant) {
let mcpTools: MCPTool[] = []
const enabledMCPs = getMcpServersForAssistant(assistant)
if (enabledMCPs && enabledMCPs.length > 0) {
try {
@ -198,6 +245,7 @@ export async function fetchChatCompletion({
const usePromptToolUse =
isPromptToolUse(assistant) || (isToolUseModeFunction(assistant) && !isFunctionCallingModel(assistant.model))
const mcpMode = getEffectiveMcpMode(assistant)
const middlewareConfig: AiSdkMiddlewareConfig = {
streamOutput: assistant.settings?.streamOutput ?? true,
onChunk: onChunkReceived,
@ -210,6 +258,7 @@ export async function fetchChatCompletion({
enableWebSearch: capabilities.enableWebSearch,
enableGenerateImage: capabilities.enableGenerateImage,
enableUrlContext: capabilities.enableUrlContext,
mcpMode,
mcpTools,
uiMessages,
knowledgeRecognition: assistant.knowledgeRecognition

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import type { Assistant, MCPServer } from '../../types'
import { getEffectiveMcpMode } from '../../types'
describe('getEffectiveMcpMode', () => {
it('should return mcpMode when explicitly set to auto', () => {
const assistant: Partial<Assistant> = { mcpMode: 'auto' }
expect(getEffectiveMcpMode(assistant as Assistant)).toBe('auto')
})
it('should return disabled when mcpMode is explicitly disabled', () => {
const assistant: Partial<Assistant> = { mcpMode: 'disabled' }
expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled')
})
it('should return manual when mcpMode is explicitly manual', () => {
const assistant: Partial<Assistant> = { mcpMode: 'manual' }
expect(getEffectiveMcpMode(assistant as Assistant)).toBe('manual')
})
it('should return manual when no mcpMode but mcpServers has items (backward compatibility)', () => {
const assistant: Partial<Assistant> = {
mcpServers: [{ id: 'test', name: 'Test Server', isActive: true }] as MCPServer[]
}
expect(getEffectiveMcpMode(assistant as Assistant)).toBe('manual')
})
it('should return disabled when no mcpMode and no mcpServers (backward compatibility)', () => {
const assistant: Partial<Assistant> = {}
expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled')
})
it('should return disabled when no mcpMode and empty mcpServers (backward compatibility)', () => {
const assistant: Partial<Assistant> = { mcpServers: [] }
expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled')
})
it('should prioritize explicit mcpMode over mcpServers presence', () => {
const assistant: Partial<Assistant> = {
mcpMode: 'disabled',
mcpServers: [{ id: 'test', name: 'Test Server', isActive: true }] as MCPServer[]
}
expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled')
})
it('should return auto when mcpMode is auto regardless of mcpServers', () => {
const assistant: Partial<Assistant> = {
mcpMode: 'auto',
mcpServers: [{ id: 'test', name: 'Test Server', isActive: true }] as MCPServer[]
}
expect(getEffectiveMcpMode(assistant as Assistant)).toBe('auto')
})
})

View File

@ -86,6 +86,28 @@ export { mcpSlice }
// Export the reducer as default export
export default mcpSlice.reducer
/**
* Hub MCP server for auto mode - aggregates all MCP servers for LLM code mode.
* This server is injected automatically when mcpMode === 'auto'.
*/
export const hubMCPServer: BuiltinMCPServer = {
id: 'hub',
name: BuiltinMCPServerNames.hub,
type: 'inMemory',
isActive: true,
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
}
/**
* User-installable built-in MCP servers shown in the UI.
*
* Note: The `hub` server (@cherry/hub) is intentionally excluded because:
* - It's a meta-server that aggregates all other MCP servers
* - It's designed for LLM code mode, not direct user interaction
* - It should be auto-enabled internally when needed, not manually installed
*/
export const builtinMCPServers: BuiltinMCPServer[] = [
{
id: nanoid(),

View File

@ -27,6 +27,8 @@ export * from './ocr'
export * from './plugin'
export * from './provider'
export type McpMode = 'disabled' | 'auto' | 'manual'
export type Assistant = {
id: string
name: string
@ -47,6 +49,8 @@ export type Assistant = {
// enableUrlContext 是 Gemini/Anthropic 的特有功能
enableUrlContext?: boolean
enableGenerateImage?: boolean
/** MCP mode: 'disabled' (no MCP), 'auto' (hub server only), 'manual' (user selects servers) */
mcpMode?: McpMode
mcpServers?: MCPServer[]
knowledgeRecognition?: 'off' | 'on'
regularPhrases?: QuickPhrase[] // Added for regular phrase
@ -57,6 +61,15 @@ export type Assistant = {
targetLanguage?: TranslateLanguage
}
/**
* Get the effective MCP mode for an assistant with backward compatibility.
* Legacy assistants without mcpMode default based on mcpServers presence.
*/
export function getEffectiveMcpMode(assistant: Assistant): McpMode {
if (assistant.mcpMode) return assistant.mcpMode
return (assistant.mcpServers?.length ?? 0) > 0 ? 'manual' : 'disabled'
}
export type TranslateAssistant = Assistant & {
model: Model
content: string
@ -757,7 +770,8 @@ export const BuiltinMCPServerNames = {
python: '@cherry/python',
didiMCP: '@cherry/didi-mcp',
browser: '@cherry/browser',
nowledgeMem: '@cherry/nowledge-mem'
nowledgeMem: '@cherry/nowledge-mem',
hub: '@cherry/hub'
} as const
export type BuiltinMCPServerName = (typeof BuiltinMCPServerNames)[keyof typeof BuiltinMCPServerNames]

View File

@ -13,7 +13,7 @@ import { isFunctionCallingModel, isVisionModel } from '@renderer/config/models'
import i18n from '@renderer/i18n'
import { currentSpan } from '@renderer/services/SpanManagerService'
import store from '@renderer/store'
import { addMCPServer } from '@renderer/store/mcp'
import { addMCPServer, hubMCPServer } from '@renderer/store/mcp'
import type {
Assistant,
MCPCallToolResponse,
@ -325,7 +325,16 @@ export function filterMCPTools(
export function getMcpServerByTool(tool: MCPTool) {
const servers = store.getState().mcp.servers
return servers.find((s) => s.id === tool.serverId)
const server = servers.find((s) => s.id === tool.serverId)
if (server) {
return server
}
// For hub server (auto mode), the server isn't in the store
// Return the hub server constant if the tool's serverId matches
if (tool.serverId === 'hub') {
return hubMCPServer
}
return undefined
}
export function isToolAutoApproved(tool: MCPTool, server?: MCPServer): boolean {

View File

@ -10,59 +10,69 @@ vi.mock('@logger', async () => {
})
// Mock electron modules that are commonly used in main process
vi.mock('electron', () => ({
app: {
getPath: vi.fn((key: string) => {
switch (key) {
case 'userData':
return '/mock/userData'
case 'temp':
return '/mock/temp'
case 'logs':
return '/mock/logs'
default:
return '/mock/unknown'
vi.mock('electron', () => {
const mock = {
app: {
getPath: vi.fn((key: string) => {
switch (key) {
case 'userData':
return '/mock/userData'
case 'temp':
return '/mock/temp'
case 'logs':
return '/mock/logs'
default:
return '/mock/unknown'
}
}),
getVersion: vi.fn(() => '1.0.0')
},
ipcMain: {
handle: vi.fn(),
on: vi.fn(),
once: vi.fn(),
removeHandler: vi.fn(),
removeAllListeners: vi.fn()
},
BrowserWindow: vi.fn(),
dialog: {
showErrorBox: vi.fn(),
showMessageBox: vi.fn(),
showOpenDialog: vi.fn(),
showSaveDialog: vi.fn()
},
shell: {
openExternal: vi.fn(),
showItemInFolder: vi.fn()
},
session: {
defaultSession: {
clearCache: vi.fn(),
clearStorageData: vi.fn()
}
}),
getVersion: vi.fn(() => '1.0.0')
},
ipcMain: {
handle: vi.fn(),
on: vi.fn(),
once: vi.fn(),
removeHandler: vi.fn(),
removeAllListeners: vi.fn()
},
BrowserWindow: vi.fn(),
dialog: {
showErrorBox: vi.fn(),
showMessageBox: vi.fn(),
showOpenDialog: vi.fn(),
showSaveDialog: vi.fn()
},
shell: {
openExternal: vi.fn(),
showItemInFolder: vi.fn()
},
session: {
defaultSession: {
clearCache: vi.fn(),
clearStorageData: vi.fn()
}
},
webContents: {
getAllWebContents: vi.fn(() => [])
},
systemPreferences: {
getMediaAccessStatus: vi.fn(),
askForMediaAccess: vi.fn()
},
screen: {
getPrimaryDisplay: vi.fn(),
getAllDisplays: vi.fn()
},
Notification: vi.fn()
}))
},
webContents: {
getAllWebContents: vi.fn(() => [])
},
systemPreferences: {
getMediaAccessStatus: vi.fn(),
askForMediaAccess: vi.fn()
},
nativeTheme: {
themeSource: 'system',
shouldUseDarkColors: false,
on: vi.fn(),
removeListener: vi.fn()
},
screen: {
getPrimaryDisplay: vi.fn(),
getAllDisplays: vi.fn()
},
Notification: vi.fn()
}
return { __esModule: true, ...mock, default: mock }
})
// Mock Winston for LoggerService dependencies
vi.mock('winston', () => ({
@ -98,13 +108,17 @@ vi.mock('winston-daily-rotate-file', () => {
})
// Mock Node.js modules
vi.mock('node:os', () => ({
platform: vi.fn(() => 'darwin'),
arch: vi.fn(() => 'x64'),
version: vi.fn(() => '20.0.0'),
cpus: vi.fn(() => [{ model: 'Mock CPU' }]),
totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB
}))
vi.mock('node:os', () => {
const mock = {
platform: vi.fn(() => 'darwin'),
arch: vi.fn(() => 'x64'),
version: vi.fn(() => '20.0.0'),
cpus: vi.fn(() => [{ model: 'Mock CPU' }]),
homedir: vi.fn(() => '/mock/home'),
totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB
}
return { ...mock, default: mock }
})
vi.mock('node:path', async () => {
const actual = await vi.importActual('node:path')
@ -115,25 +129,29 @@ vi.mock('node:path', async () => {
}
})
vi.mock('node:fs', () => ({
promises: {
access: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
unlink: vi.fn(),
rmdir: vi.fn()
},
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
readdirSync: vi.fn(),
statSync: vi.fn(),
unlinkSync: vi.fn(),
rmdirSync: vi.fn(),
createReadStream: vi.fn(),
createWriteStream: vi.fn()
}))
vi.mock('node:fs', () => {
const mock = {
promises: {
access: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
unlink: vi.fn(),
rmdir: vi.fn()
},
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
readdirSync: vi.fn(),
statSync: vi.fn(),
unlinkSync: vi.fn(),
rmdirSync: vi.fn(),
createReadStream: vi.fn(),
createWriteStream: vi.fn()
}
return { ...mock, default: mock }
})