From 4d736c543dbf73cc999885169df03d18bcd3fa2e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:39:01 +0800 Subject: [PATCH] feat: Add MCP settings export and copy functionality (#367) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com> Co-authored-by: samanhappy --- .prettierrc | 4 +- frontend/src/components/ServerCard.tsx | 130 ++++-- frontend/src/constants/permissions.ts | 1 + frontend/src/hooks/useSettingsData.ts | 16 + frontend/src/pages/SettingsPage.tsx | 442 ++++++++++++++------- locales/en.json | 12 +- locales/fr.json | 12 +- locales/zh.json | 12 +- src/controllers/configController.ts | 45 ++- src/routes/index.ts | 5 +- tests/controllers/configController.test.ts | 139 +++++++ 11 files changed, 650 insertions(+), 168 deletions(-) create mode 100644 tests/controllers/configController.test.ts diff --git a/.prettierrc b/.prettierrc index ca8527e..86396de 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,7 @@ { - "semi": true, + "semi": false, "trailingComma": "all", "singleQuote": true, "printWidth": 100, "tabWidth": 2 -} +} \ No newline at end of file diff --git a/frontend/src/components/ServerCard.tsx b/frontend/src/components/ServerCard.tsx index 5a193bb..18437b2 100644 --- a/frontend/src/components/ServerCard.tsx +++ b/frontend/src/components/ServerCard.tsx @@ -7,6 +7,7 @@ import ToolCard from '@/components/ui/ToolCard' import PromptCard from '@/components/ui/PromptCard' import DeleteDialog from '@/components/ui/DeleteDialog' import { useToast } from '@/contexts/ToastContext' +import { useSettingsData } from '@/hooks/useSettingsData' interface ServerCardProps { server: Server @@ -39,6 +40,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar } }, []) + const { exportMCPSettings } = useSettingsData() + const handleRemove = (e: React.MouseEvent) => { e.stopPropagation() setShowDeleteDialog(true) @@ -99,6 +102,39 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar } } + const handleCopyServerConfig = async (e: React.MouseEvent) => { + e.stopPropagation() + try { + const result = await exportMCPSettings(server.name) + const configJson = JSON.stringify(result.data, null, 2) + + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(configJson) + showToast(t('common.copySuccess') || 'Copied to clipboard', 'success') + } else { + // Fallback for HTTP or unsupported clipboard API + const textArea = document.createElement('textarea') + textArea.value = configJson + textArea.style.position = 'fixed' + textArea.style.left = '-9999px' + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + try { + document.execCommand('copy') + showToast(t('common.copySuccess') || 'Copied to clipboard', 'success') + } catch (err) { + showToast(t('common.copyFailed') || 'Copy failed', 'error') + console.error('Copy to clipboard failed:', err) + } + document.body.removeChild(textArea) + } + } catch (error) { + console.error('Error copying server configuration:', error) + showToast(t('common.copyFailed') || 'Copy failed', 'error') + } + } + const handleConfirmDelete = () => { onRemove(server.name) setShowDeleteDialog(false) @@ -111,7 +147,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar if (result.success) { showToast( t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }), - 'success' + 'success', ) // Trigger refresh to update the tool's state in the UI if (onRefresh) { @@ -133,7 +169,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar if (result.success) { showToast( t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }), - 'success' + 'success', ) // Trigger refresh to update the prompt's state in the UI if (onRefresh) { @@ -150,21 +186,33 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar return ( <> -
+
setIsExpanded(!isExpanded)} >
-

{server.name}

+

+ {server.name} +

{/* Tool count display */}
- + - {server.tools?.length || 0} {t('server.tools')} + + {server.tools?.length || 0} {t('server.tools')} +
{/* Prompt count display */} @@ -173,7 +221,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar - {server.prompts?.length || 0} {t('server.prompts')} + + {server.prompts?.length || 0} {t('server.prompts')} +
{server.error && ( @@ -196,19 +246,25 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar maxHeight: '300px', overflowY: 'auto', width: '480px', - transform: 'translateX(50%)' + transform: 'translateX(50%)', }} onClick={(e) => e.stopPropagation()} >
-

{t('server.errorDetails')}

+

+ {t('server.errorDetails')} +

-
{server.error}
+
+                        {server.error}
+                      
)} @@ -230,6 +288,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar )}
+
+ +
+ {mcpSettingsJson && ( +
+
+                        {mcpSettingsJson}
+                      
+
+ )} + + + + )} + + + + ) +} + +export default SettingsPage diff --git a/locales/en.json b/locales/en.json index 24dab22..c6dd6cd 100644 --- a/locales/en.json +++ b/locales/en.json @@ -75,6 +75,7 @@ "addServer": "Add Server", "add": "Add", "edit": "Edit", + "copy": "Copy", "delete": "Delete", "confirmDelete": "Are you sure you want to delete this server?", "deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.", @@ -124,6 +125,7 @@ "argumentsPlaceholder": "Enter arguments", "errorDetails": "Error Details", "viewErrorDetails": "View error details", + "copyConfig": "Copy Configuration", "confirmVariables": "Confirm Variable Configuration", "variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:", "detectedVariables": "Detected Variables", @@ -200,6 +202,7 @@ "copyJson": "Copy JSON", "copySuccess": "Copied to clipboard", "copyFailed": "Copy failed", + "copied": "Copied", "close": "Close", "confirm": "Confirm", "language": "Language", @@ -502,7 +505,14 @@ "systemSettings": "System Settings", "nameSeparatorLabel": "Name Separator", "nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)", - "restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly." + "restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly.", + "exportMcpSettings": "Export Settings", + "mcpSettingsJson": "MCP Settings JSON", + "mcpSettingsJsonDescription": "View, copy, or download your current mcp_settings.json configuration for backup or migration to other tools", + "copyToClipboard": "Copy to Clipboard", + "downloadJson": "Download JSON", + "exportSuccess": "Settings exported successfully", + "exportError": "Failed to fetch settings" }, "dxt": { "upload": "Upload", diff --git a/locales/fr.json b/locales/fr.json index 3f53bda..bf09160 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -75,6 +75,7 @@ "addServer": "Ajouter un serveur", "add": "Ajouter", "edit": "Modifier", + "copy": "Copier", "delete": "Supprimer", "confirmDelete": "Êtes-vous sûr de vouloir supprimer ce serveur ?", "deleteWarning": "La suppression du serveur '{{name}}' le supprimera ainsi que toutes ses données. Cette action est irréversible.", @@ -124,6 +125,7 @@ "argumentsPlaceholder": "Entrez les arguments", "errorDetails": "Détails de l'erreur", "viewErrorDetails": "Voir les détails de l'erreur", + "copyConfig": "Copier la configuration", "confirmVariables": "Confirmer la configuration des variables", "variablesDetected": "Variables détectées dans la configuration. Veuillez confirmer que ces variables sont correctement configurées :", "detectedVariables": "Variables détectées", @@ -200,6 +202,7 @@ "copyJson": "Copier le JSON", "copySuccess": "Copié dans le presse-papiers", "copyFailed": "Échec de la copie", + "copied": "Copié", "close": "Fermer", "confirm": "Confirmer", "language": "Langue", @@ -502,7 +505,14 @@ "systemSettings": "Paramètres système", "nameSeparatorLabel": "Séparateur de noms", "nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)", - "restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres." + "restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres.", + "exportMcpSettings": "Exporter les paramètres", + "mcpSettingsJson": "JSON des paramètres MCP", + "mcpSettingsJsonDescription": "Afficher, copier ou télécharger votre configuration mcp_settings.json actuelle pour la sauvegarde ou la migration vers d'autres outils", + "copyToClipboard": "Copier dans le presse-papiers", + "downloadJson": "Télécharger JSON", + "exportSuccess": "Paramètres exportés avec succès", + "exportError": "Échec de la récupération des paramètres" }, "dxt": { "upload": "Télécharger", diff --git a/locales/zh.json b/locales/zh.json index 6809e51..b8e6b98 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -75,6 +75,7 @@ "addServer": "添加服务器", "add": "添加", "edit": "编辑", + "copy": "复制", "delete": "删除", "confirmDelete": "您确定要删除此服务器吗?", "deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。", @@ -124,6 +125,7 @@ "argumentsPlaceholder": "请输入参数", "errorDetails": "错误详情", "viewErrorDetails": "查看错误详情", + "copyConfig": "复制配置", "confirmVariables": "确认变量配置", "variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:", "detectedVariables": "检测到的变量", @@ -201,6 +203,7 @@ "copyJson": "复制JSON", "copySuccess": "已复制到剪贴板", "copyFailed": "复制失败", + "copied": "已复制", "close": "关闭", "confirm": "确认", "language": "语言", @@ -504,7 +507,14 @@ "systemSettings": "系统设置", "nameSeparatorLabel": "名称分隔符", "nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-)", - "restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。" + "restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。", + "exportMcpSettings": "导出配置", + "mcpSettingsJson": "MCP 配置 JSON", + "mcpSettingsJsonDescription": "查看、复制或下载当前的 mcp_settings.json 配置,可用于备份或迁移到其他工具", + "copyToClipboard": "复制到剪贴板", + "downloadJson": "下载 JSON", + "exportSuccess": "配置导出成功", + "exportError": "获取配置失败" }, "dxt": { "upload": "上传", diff --git a/src/controllers/configController.ts b/src/controllers/configController.ts index abe6ae3..697e270 100644 --- a/src/controllers/configController.ts +++ b/src/controllers/configController.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import config from '../config/index.js'; -import { loadSettings } from '../config/index.js'; +import { loadSettings, loadOriginalSettings } from '../config/index.js'; import { getDataService } from '../services/services.js'; import { DataService } from '../services/dataService.js'; import { IUser } from '../types/index.js'; @@ -72,3 +72,46 @@ export const getPublicConfig = (req: Request, res: Response): void => { }); } }; + +/** + * Get MCP settings in JSON format for export/copy + * Supports both full settings and individual server configuration + */ +export const getMcpSettingsJson = (req: Request, res: Response): void => { + try { + const { serverName } = req.query; + const settings = loadOriginalSettings(); + if (serverName && typeof serverName === 'string') { + // Return individual server configuration + const serverConfig = settings.mcpServers[serverName]; + if (!serverConfig) { + res.status(404).json({ + success: false, + message: `Server '${serverName}' not found`, + }); + return; + } + + res.json({ + success: true, + data: { + mcpServers: { + [serverName]: serverConfig, + }, + }, + }); + } else { + // Return full settings + res.json({ + success: true, + data: settings, + }); + } + } catch (error) { + console.error('Error getting MCP settings JSON:', error); + res.status(500).json({ + success: false, + message: 'Failed to get MCP settings', + }); + } +}; diff --git a/src/routes/index.ts b/src/routes/index.ts index 2b18340..98b8bab 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -58,7 +58,7 @@ import { } from '../controllers/cloudController.js'; import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js'; import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js'; -import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js'; +import { getRuntimeConfig, getPublicConfig, getMcpSettingsJson } from '../controllers/configController.js'; import { callTool } from '../controllers/toolController.js'; import { getPrompt } from '../controllers/promptController.js'; import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js'; @@ -149,6 +149,9 @@ export const initRoutes = (app: express.Application): void => { router.delete('/logs', clearLogs); router.get('/logs/stream', streamLogs); + // MCP settings export route + router.get('/mcp-settings/export', getMcpSettingsJson); + // Auth routes - move to router instead of app directly router.post( '/auth/login', diff --git a/tests/controllers/configController.test.ts b/tests/controllers/configController.test.ts new file mode 100644 index 0000000..af1f369 --- /dev/null +++ b/tests/controllers/configController.test.ts @@ -0,0 +1,139 @@ +import { getMcpSettingsJson } from '../../src/controllers/configController.js' +import * as config from '../../src/config/index.js' +import { Request, Response } from 'express' + +// Mock the config module +jest.mock('../../src/config/index.js') + +describe('ConfigController - getMcpSettingsJson', () => { + let mockRequest: Partial + let mockResponse: Partial + let mockJson: jest.Mock + let mockStatus: jest.Mock + + beforeEach(() => { + mockJson = jest.fn() + mockStatus = jest.fn().mockReturnThis() + mockRequest = { + query: {}, + } + mockResponse = { + json: mockJson, + status: mockStatus, + } + + // Reset mocks + jest.clearAllMocks() + }) + + describe('Full Settings Export', () => { + it('should handle settings without users array', () => { + const mockSettings = { + mcpServers: { + 'test-server': { + command: 'test', + args: ['--test'], + }, + }, + } + + ;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings) + + getMcpSettingsJson(mockRequest as Request, mockResponse as Response) + + expect(mockJson).toHaveBeenCalledWith({ + success: true, + data: { + mcpServers: mockSettings.mcpServers, + users: undefined, + }, + }) + }) + }) + + describe('Individual Server Export', () => { + it('should return individual server configuration when serverName is specified', () => { + const mockSettings = { + mcpServers: { + 'test-server': { + command: 'test', + args: ['--test'], + env: { + TEST_VAR: 'test-value', + }, + }, + 'another-server': { + command: 'another', + args: ['--another'], + }, + }, + users: [ + { + username: 'admin', + password: '$2b$10$hashedpassword', + isAdmin: true, + }, + ], + } + + mockRequest.query = { serverName: 'test-server' } + ;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings) + + getMcpSettingsJson(mockRequest as Request, mockResponse as Response) + + expect(mockJson).toHaveBeenCalledWith({ + success: true, + data: { + mcpServers: { + 'test-server': { + command: 'test', + args: ['--test'], + env: { + TEST_VAR: 'test-value', + }, + }, + }, + }, + }) + }) + + it('should return 404 when server does not exist', () => { + const mockSettings = { + mcpServers: { + 'test-server': { + command: 'test', + args: ['--test'], + }, + }, + } + + mockRequest.query = { serverName: 'non-existent-server' } + ;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings) + + getMcpSettingsJson(mockRequest as Request, mockResponse as Response) + + expect(mockStatus).toHaveBeenCalledWith(404) + expect(mockJson).toHaveBeenCalledWith({ + success: false, + message: "Server 'non-existent-server' not found", + }) + }) + }) + + describe('Error Handling', () => { + it('should handle errors gracefully and return 500', () => { + const errorMessage = 'Failed to load settings' + ;(config.loadOriginalSettings as jest.Mock).mockImplementation(() => { + throw new Error(errorMessage) + }) + + getMcpSettingsJson(mockRequest as Request, mockResponse as Response) + + expect(mockStatus).toHaveBeenCalledWith(500) + expect(mockJson).toHaveBeenCalledWith({ + success: false, + message: 'Failed to get MCP settings', + }) + }) + }) +})