diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 94d7f8819a..5389c7542a 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -74,6 +74,7 @@ export enum IpcChannel { Mcp_ServersChanged = 'mcp:servers-changed', Mcp_ServersUpdated = 'mcp:servers-updated', Mcp_CheckConnectivity = 'mcp:check-connectivity', + Mcp_UploadDxt = 'mcp:upload-dxt', Mcp_SetProgress = 'mcp:set-progress', Mcp_AbortTool = 'mcp:abort-tool', Mcp_GetServerVersion = 'mcp:get-server-version', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4731056370..b72615657f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -17,6 +17,7 @@ import AppUpdater from './services/AppUpdater' import BackupManager from './services/BackupManager' import { configManager } from './services/ConfigManager' import CopilotService from './services/CopilotService' +import DxtService from './services/DxtService' import { ExportService } from './services/ExportService' import FileStorage from './services/FileStorage' import FileService from './services/FileSystemService' @@ -46,6 +47,7 @@ const backupManager = new BackupManager() const exportService = new ExportService(fileManager) const obsidianVaultService = new ObsidianVaultService() const vertexAIService = VertexAIService.getInstance() +const dxtService = new DxtService() export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater(mainWindow) @@ -508,6 +510,24 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { mainWindow.webContents.send('mcp-progress', progress) }) + // DXT upload handler + ipcMain.handle(IpcChannel.Mcp_UploadDxt, async (event, fileBuffer: ArrayBuffer, fileName: string) => { + try { + // Create a temporary file with the uploaded content + const tempPath = await fileManager.createTempFile(event, fileName) + await fileManager.writeFile(event, tempPath, Buffer.from(fileBuffer)) + + // Process DXT file using the temporary path + return await dxtService.uploadDxt(event, tempPath) + } catch (error) { + log.error('[IPC] DXT upload error:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to upload DXT file' + } + } + }) + // Register Python execution handler ipcMain.handle( IpcChannel.Python_Execute, diff --git a/src/main/services/DxtService.ts b/src/main/services/DxtService.ts new file mode 100644 index 0000000000..f4324d25b4 --- /dev/null +++ b/src/main/services/DxtService.ts @@ -0,0 +1,396 @@ +import { getMcpDir, getTempDir } from '@main/utils/file' +import logger from 'electron-log' +import * as fs from 'fs' +import StreamZip from 'node-stream-zip' +import * as os from 'os' +import * as path from 'path' +import { v4 as uuidv4 } from 'uuid' + +// Type definitions +export interface DxtManifest { + dxt_version: string + name: string + display_name?: string + version: string + description?: string + long_description?: string + author?: { + name?: string + email?: string + url?: string + } + repository?: { + type?: string + url?: string + } + homepage?: string + documentation?: string + support?: string + icon?: string + server: { + type: string + entry_point: string + mcp_config: { + command: string + args: string[] + env?: Record + platform_overrides?: { + [platform: string]: { + command?: string + args?: string[] + env?: Record + } + } + } + } + tools?: Array<{ + name: string + description: string + }> + keywords?: string[] + license?: string + user_config?: Record + compatibility?: { + claude_desktop?: string + platforms?: string[] + runtimes?: Record + } +} + +export interface DxtUploadResult { + success: boolean + data?: { + manifest: DxtManifest + extractDir: string + } + error?: string +} + +export function performVariableSubstitution( + value: string, + extractDir: string, + userConfig?: Record +): string { + let result = value + + // Replace ${__dirname} with the extraction directory + result = result.replace(/\$\{__dirname\}/g, extractDir) + + // Replace ${HOME} with user's home directory + result = result.replace(/\$\{HOME\}/g, os.homedir()) + + // Replace ${DESKTOP} with user's desktop directory + const desktopDir = path.join(os.homedir(), 'Desktop') + result = result.replace(/\$\{DESKTOP\}/g, desktopDir) + + // Replace ${DOCUMENTS} with user's documents directory + const documentsDir = path.join(os.homedir(), 'Documents') + result = result.replace(/\$\{DOCUMENTS\}/g, documentsDir) + + // Replace ${DOWNLOADS} with user's downloads directory + const downloadsDir = path.join(os.homedir(), 'Downloads') + result = result.replace(/\$\{DOWNLOADS\}/g, downloadsDir) + + // Replace ${pathSeparator} or ${/} with the platform-specific path separator + result = result.replace(/\$\{pathSeparator\}/g, path.sep) + result = result.replace(/\$\{\/\}/g, path.sep) + + // Replace ${user_config.KEY} with user-configured values + if (userConfig) { + result = result.replace(/\$\{user_config\.([^}]+)\}/g, (match, key) => { + return userConfig[key] || match // Keep original if not found + }) + } + + return result +} + +export function applyPlatformOverrides(mcpConfig: any, extractDir: string, userConfig?: Record): any { + const platform = process.platform + const resolvedConfig = { ...mcpConfig } + + // Apply platform-specific overrides + if (mcpConfig.platform_overrides && mcpConfig.platform_overrides[platform]) { + const override = mcpConfig.platform_overrides[platform] + + // Override command if specified + if (override.command) { + resolvedConfig.command = override.command + } + + // Override args if specified + if (override.args) { + resolvedConfig.args = override.args + } + + // Merge environment variables + if (override.env) { + resolvedConfig.env = { ...resolvedConfig.env, ...override.env } + } + } + + // Apply variable substitution to all string values + if (resolvedConfig.command) { + resolvedConfig.command = performVariableSubstitution(resolvedConfig.command, extractDir, userConfig) + } + + if (resolvedConfig.args) { + resolvedConfig.args = resolvedConfig.args.map((arg: string) => + performVariableSubstitution(arg, extractDir, userConfig) + ) + } + + if (resolvedConfig.env) { + for (const [key, value] of Object.entries(resolvedConfig.env)) { + resolvedConfig.env[key] = performVariableSubstitution(value as string, extractDir, userConfig) + } + } + + return resolvedConfig +} + +export interface ResolvedMcpConfig { + command: string + args: string[] + env?: Record +} + +class DxtService { + private tempDir = path.join(getTempDir(), 'dxt_uploads') + private mcpDir = getMcpDir() + + constructor() { + this.ensureDirectories() + } + + private ensureDirectories() { + try { + // Create temp directory + if (!fs.existsSync(this.tempDir)) { + fs.mkdirSync(this.tempDir, { recursive: true }) + } + // Create MCP directory + if (!fs.existsSync(this.mcpDir)) { + fs.mkdirSync(this.mcpDir, { recursive: true }) + } + } catch (error) { + logger.error('[DxtService] Failed to create directories:', error) + } + } + + private async moveDirectory(source: string, destination: string): Promise { + try { + // Try rename first (works if on same filesystem) + fs.renameSync(source, destination) + } catch (error) { + // If rename fails (cross-filesystem), use copy + remove + logger.info('[DxtService] Cross-filesystem move detected, using copy + remove') + + // Ensure parent directory exists + const parentDir = path.dirname(destination) + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }) + } + + // Recursively copy directory + await this.copyDirectory(source, destination) + + // Remove source directory + fs.rmSync(source, { recursive: true, force: true }) + } + } + + private async copyDirectory(source: string, destination: string): Promise { + // Create destination directory + fs.mkdirSync(destination, { recursive: true }) + + // Read source directory + const entries = fs.readdirSync(source, { withFileTypes: true }) + + // Copy each entry + for (const entry of entries) { + const sourcePath = path.join(source, entry.name) + const destPath = path.join(destination, entry.name) + + if (entry.isDirectory()) { + await this.copyDirectory(sourcePath, destPath) + } else { + fs.copyFileSync(sourcePath, destPath) + } + } + } + + public async uploadDxt(_: Electron.IpcMainInvokeEvent, filePath: string): Promise { + const tempExtractDir = path.join(this.tempDir, `dxt_${uuidv4()}`) + + try { + // Validate file exists + if (!fs.existsSync(filePath)) { + throw new Error('DXT file not found') + } + + // Extract the DXT file (which is a ZIP archive) to a temporary directory + logger.info('[DxtService] Extracting DXT file:', filePath) + + const zip = new StreamZip.async({ file: filePath }) + await zip.extract(null, tempExtractDir) + await zip.close() + + // Read and validate the manifest.json + const manifestPath = path.join(tempExtractDir, 'manifest.json') + if (!fs.existsSync(manifestPath)) { + throw new Error('manifest.json not found in DXT file') + } + + const manifestContent = fs.readFileSync(manifestPath, 'utf-8') + const manifest: DxtManifest = JSON.parse(manifestContent) + + // Validate required fields in manifest + if (!manifest.dxt_version) { + throw new Error('Invalid manifest: missing dxt_version') + } + if (!manifest.name) { + throw new Error('Invalid manifest: missing name') + } + if (!manifest.version) { + throw new Error('Invalid manifest: missing version') + } + if (!manifest.server) { + throw new Error('Invalid manifest: missing server configuration') + } + if (!manifest.server.mcp_config) { + throw new Error('Invalid manifest: missing server.mcp_config') + } + if (!manifest.server.mcp_config.command) { + throw new Error('Invalid manifest: missing server.mcp_config.command') + } + if (!Array.isArray(manifest.server.mcp_config.args)) { + throw new Error('Invalid manifest: server.mcp_config.args must be an array') + } + + // Use server name as the final extract directory for automatic version management + // Sanitize the name to prevent creating subdirectories + const sanitizedName = manifest.name.replace(/\//g, '-') + const serverDirName = `server-${sanitizedName}` + const finalExtractDir = path.join(this.mcpDir, serverDirName) + + // Clean up any existing version of this server + if (fs.existsSync(finalExtractDir)) { + logger.info('[DxtService] Removing existing server directory:', finalExtractDir) + fs.rmSync(finalExtractDir, { recursive: true, force: true }) + } + + // Move the temporary directory to the final location + // Use recursive copy + remove instead of rename to handle cross-filesystem moves + await this.moveDirectory(tempExtractDir, finalExtractDir) + logger.info('[DxtService] DXT server extracted to:', finalExtractDir) + + // Clean up the uploaded DXT file if it's in temp directory + if (filePath.startsWith(this.tempDir)) { + fs.unlinkSync(filePath) + } + + // Return success with manifest and extraction path + return { + success: true, + data: { + manifest, + extractDir: finalExtractDir + } + } + } catch (error) { + // Clean up on error + if (fs.existsSync(tempExtractDir)) { + fs.rmSync(tempExtractDir, { recursive: true, force: true }) + } + + const errorMessage = error instanceof Error ? error.message : 'Failed to process DXT file' + logger.error('[DxtService] DXT upload error:', error) + + return { + success: false, + error: errorMessage + } + } + } + + /** + * Get resolved MCP configuration for a DXT server with platform overrides and variable substitution + */ + public getResolvedMcpConfig(dxtPath: string, userConfig?: Record): ResolvedMcpConfig | null { + try { + // Read the manifest from the DXT server directory + const manifestPath = path.join(dxtPath, 'manifest.json') + if (!fs.existsSync(manifestPath)) { + logger.error('[DxtService] Manifest not found:', manifestPath) + return null + } + + const manifestContent = fs.readFileSync(manifestPath, 'utf-8') + const manifest: DxtManifest = JSON.parse(manifestContent) + + if (!manifest.server?.mcp_config) { + logger.error('[DxtService] No mcp_config found in manifest') + return null + } + + // Apply platform overrides and variable substitution + const resolvedConfig = applyPlatformOverrides(manifest.server.mcp_config, dxtPath, userConfig) + + logger.info('[DxtService] Resolved MCP config:', { + command: resolvedConfig.command, + args: resolvedConfig.args, + env: resolvedConfig.env ? Object.keys(resolvedConfig.env) : undefined + }) + + return resolvedConfig + } catch (error) { + logger.error('[DxtService] Failed to resolve MCP config:', error) + return null + } + } + + public cleanupDxtServer(serverName: string): boolean { + try { + // Handle server names that might contain slashes (e.g., "anthropic/sequential-thinking") + // by replacing slashes with the same separator used during installation + const sanitizedName = serverName.replace(/\//g, '-') + const serverDirName = `server-${sanitizedName}` + const serverDir = path.join(this.mcpDir, serverDirName) + + // First try the sanitized path + if (fs.existsSync(serverDir)) { + logger.info('[DxtService] Removing DXT server directory:', serverDir) + fs.rmSync(serverDir, { recursive: true, force: true }) + return true + } + + // Fallback: try with original name in case it was stored differently + const originalServerDir = path.join(this.mcpDir, `server-${serverName}`) + if (fs.existsSync(originalServerDir)) { + logger.info('[DxtService] Removing DXT server directory:', originalServerDir) + fs.rmSync(originalServerDir, { recursive: true, force: true }) + return true + } + + logger.warn('[DxtService] Server directory not found:', serverDir) + return false + } catch (error) { + logger.error('[DxtService] Failed to cleanup DXT server:', error) + return false + } + } + + public cleanup() { + try { + // Clean up temp directory + if (fs.existsSync(this.tempDir)) { + fs.rmSync(this.tempDir, { recursive: true, force: true }) + } + } catch (error) { + logger.error('[DxtService] Cleanup error:', error) + } + } +} + +export default DxtService diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 0e9100d086..8ae99cf54e 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -31,6 +31,7 @@ import { memoize } from 'lodash' import { v4 as uuidv4 } from 'uuid' import { CacheService } from './CacheService' +import DxtService from './DxtService' import { CallBackServer } from './mcp/oauth/callback' import { McpOAuthClientProvider } from './mcp/oauth/provider' import getLoginShellEnvironment from './mcp/shell-env' @@ -72,6 +73,7 @@ function withCache( class McpService { private clients: Map = new Map() private pendingClients: Map> = new Map() + private dxtService = new DxtService() private activeToolCalls: Map = new Map() constructor() { @@ -88,6 +90,7 @@ class McpService { this.stopServer = this.stopServer.bind(this) this.abortTool = this.abortTool.bind(this) this.cleanup = this.cleanup.bind(this) + this.checkMcpConnectivity = this.checkMcpConnectivity.bind(this) this.getServerVersion = this.getServerVersion.bind(this) } @@ -137,7 +140,7 @@ class McpService { // Create new client instance for each connection const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} }) - const args = [...(server.args || [])] + let args = [...(server.args || [])] // let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport const authProvider = new McpOAuthClientProvider({ @@ -207,6 +210,23 @@ class McpService { } else if (server.command) { let cmd = server.command + // For DXT servers, use resolved configuration with platform overrides and variable substitution + if (server.dxtPath) { + const resolvedConfig = this.dxtService.getResolvedMcpConfig(server.dxtPath) + if (resolvedConfig) { + cmd = resolvedConfig.command + args = resolvedConfig.args + // Merge resolved environment variables with existing ones + server.env = { + ...server.env, + ...resolvedConfig.env + } + Logger.info(`[MCP] Using resolved DXT config - command: ${cmd}, args: ${args?.join(' ')}`) + } else { + Logger.warn(`[MCP] Failed to resolve DXT config for ${server.name}, falling back to manifest values`) + } + } + if (server.command === 'npx') { cmd = await getBinaryPath('bun') Logger.info(`[MCP] Using command: ${cmd}`) @@ -253,7 +273,7 @@ class McpService { this.removeProxyEnv(loginShellEnv) } - const stdioTransport = new StdioClientTransport({ + const transportOptions: any = { command: cmd, args, env: { @@ -261,7 +281,15 @@ class McpService { ...server.env }, stderr: 'pipe' - }) + } + + // For DXT servers, set the working directory to the extracted path + if (server.dxtPath) { + transportOptions.cwd = server.dxtPath + Logger.info(`[MCP] Setting working directory for DXT server: ${server.dxtPath}`) + } + + const stdioTransport = new StdioClientTransport(transportOptions) stdioTransport.stderr?.on('data', (data) => Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString()) ) @@ -379,6 +407,18 @@ class McpService { if (existingClient) { await this.closeClient(serverKey) } + + // If this is a DXT server, cleanup its directory + if (server.dxtPath) { + try { + const cleaned = this.dxtService.cleanupDxtServer(server.name) + if (cleaned) { + Logger.info(`[MCP] Cleaned up DXT server directory for: ${server.name}`) + } + } catch (error) { + Logger.error(`[MCP] Failed to cleanup DXT server: ${server.name}`, error) + } + } } async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) { @@ -404,6 +444,12 @@ class McpService { public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise { Logger.info(`[MCP] Checking connectivity for server: ${server.name}`) try { + Logger.info(`[MCP] About to call initClient for server: ${server.name}`, { hasInitClient: !!this.initClient }) + + if (!this.initClient) { + throw new Error('initClient method is not available') + } + const client = await this.initClient(server) // Attempt to list tools as a way to check connectivity await client.listTools() diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 4cf8bd0e44..d03af3ad72 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -207,6 +207,10 @@ export function getAppConfigDir(name: string) { return path.join(getConfigDir(), name) } +export function getMcpDir() { + return path.join(os.homedir(), '.cherrystudio', 'mcp') +} + /** * 读取文件内容并自动检测编码格式进行解码 * @param filePath - 文件路径 diff --git a/src/preload/index.ts b/src/preload/index.ts index 9e62eb639f..17493df72e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -240,6 +240,10 @@ const api = { ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }), getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo), checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server), + uploadDxt: async (file: File) => { + const buffer = await file.arrayBuffer() + return ipcRenderer.invoke(IpcChannel.Mcp_UploadDxt, buffer, file.name) + }, abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId), setProgress: (progress: number) => ipcRenderer.invoke(IpcChannel.Mcp_SetProgress, progress), getServerVersion: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index eb49fbe793..68e652ac73 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1764,6 +1764,13 @@ "addServer.importFrom.invalid": "Invalid input, please check JSON format", "addServer.importFrom.nameExists": "Server already exists: {{name}}", "addServer.importFrom.oneServer": "Only one MCP server configuration at a time", + "addServer.importFrom.method": "Import Method", + "addServer.importFrom.dxtFile": "DXT Package File", + "addServer.importFrom.dxtHelp": "Select a .dxt file containing an MCP server package", + "addServer.importFrom.selectDxtFile": "Select DXT File", + "addServer.importFrom.noDxtFile": "Please select a DXT file", + "addServer.importFrom.dxtProcessFailed": "Failed to process DXT file", + "addServer.importFrom.dxt": "Import DXT Package", "addServer.importFrom.placeholder": "Paste MCP server JSON config", "addServer.importFrom.tooltip": "Please copy the configuration JSON (prioritizing\n NPX or UVX configurations) from the MCP Servers introduction page and paste it into the input box.", "addSuccess": "Server added successfully", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 7f01234f51..76121e7f51 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1764,6 +1764,13 @@ "addServer.importFrom.invalid": "无效输入,请检查 JSON 格式", "addServer.importFrom.nameExists": "服务器已存在:{{name}}", "addServer.importFrom.oneServer": "每次只能保存一個 MCP 伺服器配置", + "addServer.importFrom.method": "导入方式", + "addServer.importFrom.dxtFile": "DXT 包文件", + "addServer.importFrom.dxtHelp": "选择包含 MCP 服务器的 .dxt 文件", + "addServer.importFrom.selectDxtFile": "选择 DXT 文件", + "addServer.importFrom.noDxtFile": "请选择一个 DXT 文件", + "addServer.importFrom.dxtProcessFailed": "处理 DXT 文件失败", + "addServer.importFrom.dxt": "导入 DXT 包", "addServer.importFrom.placeholder": "粘贴 MCP 服务器 JSON 配置", "addServer.importFrom.tooltip": "请从 MCP Servers 的介绍页面复制配置 JSON(优先使用\n NPX 或 UVX 配置),并粘贴到输入框中", "addSuccess": "服务器添加成功", diff --git a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx index c862394d16..d7fcdba06c 100644 --- a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx @@ -1,10 +1,11 @@ +import { UploadOutlined } from '@ant-design/icons' import { nanoid } from '@reduxjs/toolkit' import CodeEditor from '@renderer/components/CodeEditor' import { useAppDispatch } from '@renderer/store' import { setMCPServerActive } from '@renderer/store/mcp' import { MCPServer } from '@renderer/types' -import { Form, Modal } from 'antd' -import { FC, useCallback, useState } from 'react' +import { Button, Form, Modal, Upload } from 'antd' +import { FC, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' interface AddMcpServerModalProps { @@ -12,6 +13,7 @@ interface AddMcpServerModalProps { onClose: () => void onSuccess: (server: MCPServer) => void existingServers: MCPServer[] + initialImportMethod?: 'json' | 'dxt' } interface ParsedServerData extends MCPServer { @@ -54,80 +56,197 @@ const initialJsonExample = `// 示例 JSON (stdio): // } ` -const AddMcpServerModal: FC = ({ visible, onClose, onSuccess, existingServers }) => { +const AddMcpServerModal: FC = ({ + visible, + onClose, + onSuccess, + existingServers, + initialImportMethod = 'json' +}) => { const { t } = useTranslation() const [form] = Form.useForm() const [loading, setLoading] = useState(false) + const [importMethod, setImportMethod] = useState<'json' | 'dxt'>(initialImportMethod) + const [dxtFile, setDxtFile] = useState(null) const dispatch = useAppDispatch() + // Update import method when initialImportMethod changes + useEffect(() => { + setImportMethod(initialImportMethod) + }, [initialImportMethod]) + const handleOk = async () => { try { - const values = await form.validateFields() - const inputValue = values.serverConfig.trim() setLoading(true) - const { serverToAdd, error } = parseAndExtractServer(inputValue, t) - - if (error) { - form.setFields([ - { - name: 'serverConfig', - errors: [error] - } - ]) - setLoading(false) - return - } - - // 檢查重複名稱 - if (existingServers && existingServers.some((server) => server.name === serverToAdd!.name)) { - form.setFields([ - { - name: 'serverConfig', - errors: [t('settings.mcp.addServer.importFrom.nameExists', { name: serverToAdd!.name })] - } - ]) - setLoading(false) - return - } - - // 如果成功解析並通過所有檢查,立即加入伺服器(非啟用狀態)並關閉對話框 - const newServer: MCPServer = { - id: nanoid(), - name: serverToAdd!.name!, - description: serverToAdd!.description ?? '', - baseUrl: serverToAdd!.baseUrl ?? serverToAdd!.url ?? '', - command: serverToAdd!.command ?? '', - args: Array.isArray(serverToAdd!.args) ? serverToAdd!.args : [], - env: serverToAdd!.env || {}, - isActive: false, - type: serverToAdd!.type, - logoUrl: serverToAdd!.logoUrl, - provider: serverToAdd!.provider, - providerUrl: serverToAdd!.providerUrl, - tags: serverToAdd!.tags, - configSample: serverToAdd!.configSample, - headers: serverToAdd!.headers || {} - } - - onSuccess(newServer) - form.resetFields() - onClose() - - // 在背景非同步檢查伺服器可用性並更新狀態 - window.api.mcp - .checkMcpConnectivity(newServer) - .then((isConnected) => { - console.log(`Connectivity check for ${newServer.name}: ${isConnected}`) - dispatch(setMCPServerActive({ id: newServer.id, isActive: isConnected })) - }) - .catch((connError: any) => { - console.error(`Connectivity check failed for ${newServer.name}:`, connError) + if (importMethod === 'dxt') { + if (!dxtFile) { window.message.error({ - content: t(`${newServer.name} settings.mcp.addServer.importFrom.connectionFailed`), - key: 'mcp-quick-add-failed' + content: t('settings.mcp.addServer.importFrom.noDxtFile'), + key: 'mcp-no-dxt-file' }) - }) + setLoading(false) + return + } + + // Process DXT file + try { + const result = await window.api.mcp.uploadDxt(dxtFile) + + if (!result.success) { + window.message.error({ + content: result.error || t('settings.mcp.addServer.importFrom.dxtProcessFailed'), + key: 'mcp-dxt-process-failed' + }) + setLoading(false) + return + } + + const { manifest, extractDir } = result.data + + // Check for duplicate names + if (existingServers && existingServers.some((server) => server.name === manifest.name)) { + window.message.error({ + content: t('settings.mcp.addServer.importFrom.nameExists', { name: manifest.name }), + key: 'mcp-name-exists' + }) + setLoading(false) + return + } + + // Process args with variable substitution + const processedArgs = manifest.server.mcp_config.args + .map((arg) => { + // Replace ${__dirname} with the extraction directory + let processedArg = arg.replace(/\$\{__dirname\}/g, extractDir) + + // For now, remove user_config variables and their values + processedArg = processedArg.replace(/--[^=]*=\$\{user_config\.[^}]+\}/g, '') + + return processedArg.trim() + }) + .filter((arg) => arg.trim() !== '' && arg !== '--' && arg !== '=' && !arg.startsWith('--=')) + + console.log('Processed DXT args:', processedArgs) + + // Create MCPServer from DXT manifest + const newServer: MCPServer = { + id: nanoid(), + name: manifest.display_name || manifest.name, + description: manifest.description || manifest.long_description || '', + baseUrl: '', + command: manifest.server.mcp_config.command, + args: processedArgs, + env: manifest.server.mcp_config.env || {}, + isActive: false, + type: 'stdio', + // Add DXT-specific metadata + dxtVersion: manifest.dxt_version, + dxtPath: extractDir, + // Add additional metadata from manifest + logoUrl: manifest.icon ? `${extractDir}/${manifest.icon}` : undefined, + provider: manifest.author?.name, + providerUrl: manifest.homepage || manifest.repository?.url, + tags: manifest.keywords + } + + onSuccess(newServer) + form.resetFields() + setDxtFile(null) + onClose() + + // Check server connectivity in background (with timeout) + setTimeout(() => { + window.api.mcp + .checkMcpConnectivity(newServer) + .then((isConnected) => { + console.log(`Connectivity check for ${newServer.name}: ${isConnected}`) + dispatch(setMCPServerActive({ id: newServer.id, isActive: isConnected })) + }) + .catch((connError: any) => { + console.error(`Connectivity check failed for ${newServer.name}:`, connError) + // Don't show error for DXT servers as they might need additional setup + console.warn( + `DXT server ${newServer.name} connectivity check failed, this is normal for servers requiring additional configuration` + ) + }) + }, 1000) // Delay to ensure server is properly added to store + } catch (error) { + console.error('DXT processing error:', error) + window.message.error({ + content: t('settings.mcp.addServer.importFrom.dxtProcessFailed'), + key: 'mcp-dxt-error' + }) + setLoading(false) + return + } + } else { + // Original JSON import logic + const values = await form.validateFields() + const inputValue = values.serverConfig.trim() + + const { serverToAdd, error } = parseAndExtractServer(inputValue, t) + + if (error) { + form.setFields([ + { + name: 'serverConfig', + errors: [error] + } + ]) + setLoading(false) + return + } + + // 檢查重複名稱 + if (existingServers && existingServers.some((server) => server.name === serverToAdd!.name)) { + form.setFields([ + { + name: 'serverConfig', + errors: [t('settings.mcp.addServer.importFrom.nameExists', { name: serverToAdd!.name })] + } + ]) + setLoading(false) + return + } + + // 如果成功解析並通過所有檢查,立即加入伺服器(非啟用狀態)並關閉對話框 + const newServer: MCPServer = { + id: nanoid(), + name: serverToAdd!.name!, + description: serverToAdd!.description ?? '', + baseUrl: serverToAdd!.baseUrl ?? serverToAdd!.url ?? '', + command: serverToAdd!.command ?? '', + args: Array.isArray(serverToAdd!.args) ? serverToAdd!.args : [], + env: serverToAdd!.env || {}, + isActive: false, + type: serverToAdd!.type, + logoUrl: serverToAdd!.logoUrl, + provider: serverToAdd!.provider, + providerUrl: serverToAdd!.providerUrl, + tags: serverToAdd!.tags, + configSample: serverToAdd!.configSample + } + + onSuccess(newServer) + form.resetFields() + onClose() + + // 在背景非同步檢查伺服器可用性並更新狀態 + window.api.mcp + .checkMcpConnectivity(newServer) + .then((isConnected) => { + console.log(`Connectivity check for ${newServer.name}: ${isConnected}`) + dispatch(setMCPServerActive({ id: newServer.id, isActive: isConnected })) + }) + .catch((connError: any) => { + console.error(`Connectivity check failed for ${newServer.name}:`, connError) + window.message.error({ + content: t(`${newServer.name} settings.mcp.addServer.importFrom.connectionFailed`), + key: 'mcp-quick-add-failed' + }) + }) + } } finally { setLoading(false) } @@ -147,38 +266,63 @@ const AddMcpServerModal: FC = ({ visible, onClose, onSuc return ( { + form.resetFields() + setDxtFile(null) + setImportMethod(initialImportMethod) + onClose() + }} confirmLoading={loading} destroyOnClose centered transitionName="animation-move-down" width={600}>
- - - + {importMethod === 'json' ? ( + + + + ) : ( + + { + setDxtFile(file) + return false // Prevent automatic upload + }} + onRemove={() => setDxtFile(null)} + fileList={dxtFile ? [{ uid: '-1', name: dxtFile.name, status: 'done' } as any] : []}> + + + + )}
) diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx index b8efffd2c6..d0ef9120b9 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx @@ -24,6 +24,7 @@ const McpServersList: FC = () => { const { t } = useTranslation() const navigate = useNavigate() const [isAddModalVisible, setIsAddModalVisible] = useState(false) + const [modalType, setModalType] = useState<'json' | 'dxt'>('json') const [loadingServerIds, setLoadingServerIds] = useState>(new Set()) const [serverVersions, setServerVersions] = useState>({}) @@ -128,9 +129,20 @@ const McpServersList: FC = () => { } }, { - key: 'quick', + key: 'json', label: t('settings.mcp.addServer.importFrom'), - onClick: () => setIsAddModalVisible(true) + onClick: () => { + setModalType('json') + setIsAddModalVisible(true) + } + }, + { + key: 'dxt', + label: t('settings.mcp.addServer.importFrom.dxt'), + onClick: () => { + setModalType('dxt') + setIsAddModalVisible(true) + } } ] }} @@ -216,6 +228,7 @@ const McpServersList: FC = () => { onClose={() => setIsAddModalVisible(false)} onSuccess={handleAddServerSuccess} existingServers={mcpServers} // 傳遞現有的伺服器列表 + initialImportMethod={modalType} /> ) diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index a8e0047fa1..58876962e7 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -633,6 +633,8 @@ export interface MCPServer { logoUrl?: string // URL of the MCP server's logo tags?: string[] // List of tags associated with this server timeout?: number // Timeout in seconds for requests to this server, default is 60 seconds + dxtVersion?: string // Version of the DXT package + dxtPath?: string // Path where the DXT package was extracted } export interface MCPToolInputSchema {