[1.5.0-rc] feat(MCP): Add DXT format support for MCP server installation (#7618)

* feat(MCP): Add DXT format support for MCP server installation

- Add comprehensive DXT package upload and extraction functionality
- Support for DXT manifest validation and MCP server configuration
- Hierarchical UI structure: Quick Add | JSON Import | DXT Import
- Variable substitution for DXT args (${__dirname} replacement)
- Automatic cleanup of DXT server directories on removal
- Enhanced error handling and connectivity checks
- Full internationalization support (EN/CN)
- Uses existing node-stream-zip for efficient extraction
- Proper working directory setup for DXT-based servers

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* 🐛 fix(MCP): Fix DXT server installation and deletion issues

- Replace fs.renameSync with cross-filesystem compatible moveDirectory method to handle temp->mcp directory moves across different mount points
- Add recursive copy fallback when rename fails (ENOENT error fix)
- Sanitize server names with slashes to prevent subdirectory creation during installation
- Improve cleanupDxtServer to handle sanitized names and provide fallback lookup
- Add proper error logging and directory existence warnings

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(MCP): Implement comprehensive DXT MCP configuration support

- Add platform_overrides support to DXT manifest interface
- Implement complete variable substitution system (${__dirname}, ${HOME}, ${DESKTOP}, ${DOCUMENTS}, ${pathSeparator}, ${user_config.KEY})
- Add platform detection utilities (getPlatformIdentifier)
- Create resolved MCP configuration system with applyPlatformOverrides
- Export ResolvedMcpConfig interface and utility functions
- Integrate DXT configuration resolution into MCPService runtime
- Support platform-specific command, args, and environment overrides
- Add comprehensive logging for configuration resolution

Addresses DXT MANIFEST.md mcp_configuration requirements:
- Platform-specific configuration variations
- Cross-platform variable substitution
- Flexible command and environment management

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add downloads directory variable substitution and simplify platform detection

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
LiuVaayne 2025-07-14 22:37:56 +08:00 committed by GitHub
parent bf6ccea1e2
commit ee4553130b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 738 additions and 94 deletions

View File

@ -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',

View File

@ -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,

View File

@ -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<string, string>
platform_overrides?: {
[platform: string]: {
command?: string
args?: string[]
env?: Record<string, string>
}
}
}
}
tools?: Array<{
name: string
description: string
}>
keywords?: string[]
license?: string
user_config?: Record<string, any>
compatibility?: {
claude_desktop?: string
platforms?: string[]
runtimes?: Record<string, string>
}
}
export interface DxtUploadResult {
success: boolean
data?: {
manifest: DxtManifest
extractDir: string
}
error?: string
}
export function performVariableSubstitution(
value: string,
extractDir: string,
userConfig?: Record<string, any>
): 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<string, any>): 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<string, string>
}
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<void> {
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<void> {
// 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<DxtUploadResult> {
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<string, any>): 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

View File

@ -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<T extends unknown[], R>(
class McpService {
private clients: Map<string, Client> = new Map()
private pendingClients: Map<string, Promise<Client>> = new Map()
private dxtService = new DxtService()
private activeToolCalls: Map<string, AbortController> = 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<boolean> {
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()

View File

@ -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 -

View File

@ -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)

View File

@ -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",

View File

@ -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": "服务器添加成功",

View File

@ -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<AddMcpServerModalProps> = ({ visible, onClose, onSuccess, existingServers }) => {
const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
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<File | null>(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<AddMcpServerModalProps> = ({ visible, onClose, onSuc
return (
<Modal
title={t('settings.mcp.addServer.importFrom')}
title={
importMethod === 'dxt' ? t('settings.mcp.addServer.importFrom.dxt') : t('settings.mcp.addServer.importFrom')
}
open={visible}
onOk={handleOk}
onCancel={onClose}
onCancel={() => {
form.resetFields()
setDxtFile(null)
setImportMethod(initialImportMethod)
onClose()
}}
confirmLoading={loading}
destroyOnClose
centered
transitionName="animation-move-down"
width={600}>
<Form form={form} layout="vertical" name="add_mcp_server_form">
<Form.Item
name="serverConfig"
label={t('settings.mcp.addServer.importFrom.tooltip')}
rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}>
<CodeEditor
// 如果表單值為空,顯示範例 JSON否則顯示表單值
value={serverConfigValue}
placeholder={initialJsonExample}
language="json"
onChange={handleEditorChange}
maxHeight="300px"
options={{
lint: true,
collapsible: true,
wrappable: true,
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
keymap: true
}}
/>
</Form.Item>
{importMethod === 'json' ? (
<Form.Item
name="serverConfig"
label={t('settings.mcp.addServer.importFrom.tooltip')}
rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}>
<CodeEditor
// 如果表單值為空,顯示範例 JSON否則顯示表單值
value={serverConfigValue}
placeholder={initialJsonExample}
language="json"
onChange={handleEditorChange}
maxHeight="300px"
options={{
lint: true,
collapsible: true,
wrappable: true,
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
keymap: true
}}
/>
</Form.Item>
) : (
<Form.Item
label={t('settings.mcp.addServer.importFrom.dxtFile')}
help={t('settings.mcp.addServer.importFrom.dxtHelp')}>
<Upload
accept=".dxt"
maxCount={1}
beforeUpload={(file) => {
setDxtFile(file)
return false // Prevent automatic upload
}}
onRemove={() => setDxtFile(null)}
fileList={dxtFile ? [{ uid: '-1', name: dxtFile.name, status: 'done' } as any] : []}>
<Button icon={<UploadOutlined />}>{t('settings.mcp.addServer.importFrom.selectDxtFile')}</Button>
</Upload>
</Form.Item>
)}
</Form>
</Modal>
)

View File

@ -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<Set<string>>(new Set())
const [serverVersions, setServerVersions] = useState<Record<string, string | null>>({})
@ -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}
/>
</Container>
)

View File

@ -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 {