mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-10 23:59:45 +08:00
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:
parent
334b9bbe04
commit
6d15b0dfd1
@ -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) {
|
||||
// 🎯 如果用户提供了自定义处理函数,使用它
|
||||
|
||||
@ -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
116
packages/shared/mcp.ts
Normal 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
|
||||
})
|
||||
}
|
||||
240
src/main/__tests__/mcp.test.ts
Normal file
240
src/main/__tests__/mcp.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
213
src/main/mcpServers/hub/README.md
Normal file
213
src/main/mcpServers/hub/README.md
Normal 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 |
|
||||
119
src/main/mcpServers/hub/__tests__/generator.test.ts
Normal file
119
src/main/mcpServers/hub/__tests__/generator.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
229
src/main/mcpServers/hub/__tests__/hub.test.ts
Normal file
229
src/main/mcpServers/hub/__tests__/hub.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
159
src/main/mcpServers/hub/__tests__/runtime.test.ts
Normal file
159
src/main/mcpServers/hub/__tests__/runtime.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
118
src/main/mcpServers/hub/__tests__/search.test.ts
Normal file
118
src/main/mcpServers/hub/__tests__/search.test.ts
Normal 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(')
|
||||
})
|
||||
})
|
||||
})
|
||||
152
src/main/mcpServers/hub/generator.ts
Normal file
152
src/main/mcpServers/hub/generator.ts
Normal 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
|
||||
}
|
||||
184
src/main/mcpServers/hub/index.ts
Normal file
184
src/main/mcpServers/hub/index.ts
Normal 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
|
||||
96
src/main/mcpServers/hub/mcp-bridge.ts
Normal file
96
src/main/mcpServers/hub/mcp-bridge.ts
Normal 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
|
||||
}
|
||||
170
src/main/mcpServers/hub/runtime.ts
Normal file
170
src/main/mcpServers/hub/runtime.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
109
src/main/mcpServers/hub/search.ts
Normal file
109
src/main/mcpServers/hub/search.ts
Normal 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, '\\$&')
|
||||
}
|
||||
113
src/main/mcpServers/hub/types.ts
Normal file
113
src/main/mcpServers/hub/types.ts
Normal 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
|
||||
133
src/main/mcpServers/hub/worker.ts
Normal file
133
src/main/mcpServers/hub/worker.ts
Normal 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
|
||||
}
|
||||
})
|
||||
`
|
||||
@ -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,
|
||||
|
||||
75
src/main/services/__tests__/MCPService.test.ts
Normal file
75
src/main/services/__tests__/MCPService.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@ -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'
|
||||
|
||||
@ -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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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[]
|
||||
// 内置搜索配置
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -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)
|
||||
|
||||
174
src/renderer/src/config/prompts-code-mode.ts
Normal file
174
src/renderer/src/config/prompts-code-mode.ts
Normal 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 directly—wrap them in \`exec\` code.
|
||||
|
||||
${toolsSection}
|
||||
`
|
||||
}
|
||||
@ -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 => {
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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 服务器"
|
||||
},
|
||||
|
||||
@ -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 設定"
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Αδυναμία εύρεσης της αρχικής μηνύματος χρήστη",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "元のユーザーメッセージを見つけることができませんでした",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Не удалось найти исходное сообщение пользователя",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
) {
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
54
src/renderer/src/services/__tests__/mcpMode.test.ts
Normal file
54
src/renderer/src/services/__tests__/mcpMode.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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(),
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user