mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 13:31:32 +08:00
[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:
parent
bf6ccea1e2
commit
ee4553130b
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
396
src/main/services/DxtService.ts
Normal file
396
src/main/services/DxtService.ts
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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 - 文件路径
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "服务器添加成功",
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user