diff --git a/.gitignore b/.gitignore index 1bb3bc7..0119cae 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ yarn-error.log* .vscode/ *.log coverage/ + +data/ \ No newline at end of file diff --git a/README.md b/README.md index 134bfac..c125ac6 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Create a `mcp_settings.json` file to customize your server settings: **Recommended**: Mount your custom config: ```bash -docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub +docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub ``` or run with default settings: diff --git a/README.zh.md b/README.zh.md index 8971274..0c7d32d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -57,7 +57,7 @@ MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活 **推荐**:挂载自定义配置: ```bash -docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub +docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub ``` 或使用默认配置运行: diff --git a/frontend/src/components/DxtUploadForm.tsx b/frontend/src/components/DxtUploadForm.tsx new file mode 100644 index 0000000..c87c6f2 --- /dev/null +++ b/frontend/src/components/DxtUploadForm.tsx @@ -0,0 +1,413 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getApiUrl } from '@/utils/runtime'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; + +interface DxtUploadFormProps { + onSuccess: (serverConfig: any) => void; + onCancel: () => void; +} + +interface DxtUploadResponse { + success: boolean; + data?: { + manifest: any; + extractDir: string; + }; + message?: string; +} + +const DxtUploadForm: React.FC = ({ onSuccess, onCancel }) => { + const { t } = useTranslation(); + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [showServerForm, setShowServerForm] = useState(false); + const [manifestData, setManifestData] = useState(null); + const [extractDir, setExtractDir] = useState(''); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [pendingServerName, setPendingServerName] = useState(''); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const files = e.dataTransfer.files; + if (files.length > 0) { + const file = files[0]; + if (file.name.endsWith('.dxt')) { + setSelectedFile(file); + setError(null); + } else { + setError(t('dxt.invalidFileType')); + } + } + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + const file = files[0]; + if (file.name.endsWith('.dxt')) { + setSelectedFile(file); + setError(null); + } else { + setError(t('dxt.invalidFileType')); + } + } + }; + + const handleUpload = async () => { + if (!selectedFile) { + setError(t('dxt.noFileSelected')); + return; + } + + setIsUploading(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('dxtFile', selectedFile); + + const token = localStorage.getItem('mcphub_token'); + const response = await fetch(getApiUrl('/dxt/upload'), { + method: 'POST', + headers: { + 'x-auth-token': token || '', + }, + body: formData, + }); + + const result: DxtUploadResponse = await response.json(); + + if (!response.ok) { + throw new Error(result.message || `HTTP error! Status: ${response.status}`); + } + + if (result.success && result.data) { + setManifestData(result.data.manifest); + setExtractDir(result.data.extractDir); + setShowServerForm(true); + } else { + throw new Error(result.message || t('dxt.uploadFailed')); + } + } catch (err) { + console.error('DXT upload error:', err); + setError(err instanceof Error ? err.message : t('dxt.uploadFailed')); + } finally { + setIsUploading(false); + } + }; + + const handleInstallServer = async (serverName: string, forceOverride: boolean = false) => { + setIsUploading(true); + setError(null); + + try { + // Convert DXT manifest to MCPHub stdio server configuration + const serverConfig = convertDxtToMcpConfig(manifestData, extractDir, serverName); + + const token = localStorage.getItem('mcphub_token'); + + // First, check if server exists + if (!forceOverride) { + const checkResponse = await fetch(getApiUrl('/servers'), { + method: 'GET', + headers: { + 'x-auth-token': token || '', + }, + }); + + if (checkResponse.ok) { + const checkResult = await checkResponse.json(); + const existingServer = checkResult.data?.find((server: any) => server.name === serverName); + + if (existingServer) { + // Server exists, show confirmation dialog + setPendingServerName(serverName); + setShowConfirmDialog(true); + setIsUploading(false); + return; + } + } + } + + // Install or override the server + const method = forceOverride ? 'PUT' : 'POST'; + const url = forceOverride ? getApiUrl(`/servers/${encodeURIComponent(serverName)}`) : getApiUrl('/servers'); + + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'x-auth-token': token || '', + }, + body: JSON.stringify({ + name: serverName, + config: serverConfig, + }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.message || `HTTP error! Status: ${response.status}`); + } + + if (result.success) { + onSuccess(serverConfig); + } else { + throw new Error(result.message || t('dxt.installFailed')); + } + } catch (err) { + console.error('DXT install error:', err); + setError(err instanceof Error ? err.message : t('dxt.installFailed')); + setIsUploading(false); + } + }; + + const handleConfirmOverride = () => { + setShowConfirmDialog(false); + if (pendingServerName) { + handleInstallServer(pendingServerName, true); + } + }; + + const handleCancelOverride = () => { + setShowConfirmDialog(false); + setPendingServerName(''); + setIsUploading(false); + }; + + const convertDxtToMcpConfig = (manifest: any, extractPath: string, _serverName: string) => { + const mcpConfig = manifest.server?.mcp_config || {}; + + // Convert DXT manifest to MCPHub stdio configuration + const config: any = { + type: 'stdio', + command: mcpConfig.command || 'node', + args: (mcpConfig.args || []).map((arg: string) => + arg.replace('${__dirname}', extractPath) + ), + }; + + // Add environment variables if they exist + if (mcpConfig.env && Object.keys(mcpConfig.env).length > 0) { + config.env = { ...mcpConfig.env }; + + // Replace ${__dirname} in environment variables + Object.keys(config.env).forEach(key => { + if (typeof config.env[key] === 'string') { + config.env[key] = config.env[key].replace('${__dirname}', extractPath); + } + }); + } + + return config; + }; + + if (showServerForm && manifestData) { + return ( + <> + + +
+
+
+

{t('dxt.installServer')}

+ +
+ + {error && ( +
+

{error}

+
+ )} + +
+ {/* Extension Info */} +
+

{t('dxt.extensionInfo')}

+
+
{t('dxt.name')}: {manifestData.display_name || manifestData.name}
+
{t('dxt.version')}: {manifestData.version}
+
{t('dxt.description')}: {manifestData.description}
+ {manifestData.author && ( +
{t('dxt.author')}: {manifestData.author.name}
+ )} + {manifestData.tools && manifestData.tools.length > 0 && ( +
+ {t('dxt.tools')}: +
    + {manifestData.tools.map((tool: any, index: number) => ( +
  • {tool.name} - {tool.description}
  • + ))} +
+
+ )} +
+
+ + {/* Server Configuration */} +
+ + +
+ + {/* Action Buttons */} +
+ + +
+
+
+
+ + ); + } + + return ( +
+
+
+

{t('dxt.uploadTitle')}

+ +
+ + {error && ( +
+

{error}

+
+ )} + + {/* File Drop Zone */} +
+ {selectedFile ? ( +
+ + + +

{selectedFile.name}

+

{(selectedFile.size / 1024 / 1024).toFixed(2)} MB

+
+ ) : ( +
+ + + +
+

{t('dxt.dropFileHere')}

+

{t('dxt.orClickToSelect')}

+
+
+ )} + + +
+ +
+ + +
+
+
+ ); +}; + +export default DxtUploadForm; diff --git a/frontend/src/components/ui/ConfirmDialog.tsx b/frontend/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..3a812d8 --- /dev/null +++ b/frontend/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface ConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title?: string; + message: string; + confirmText?: string; + cancelText?: string; + variant?: 'danger' | 'warning' | 'info'; +} + +const ConfirmDialog: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText, + cancelText, + variant = 'warning' +}) => { + const { t } = useTranslation(); + + if (!isOpen) return null; + + const getVariantStyles = () => { + switch (variant) { + case 'danger': + return { + icon: ( + + + + ), + confirmClass: 'bg-red-600 hover:bg-red-700 text-white', + }; + case 'warning': + return { + icon: ( + + + + ), + confirmClass: 'bg-yellow-600 hover:bg-yellow-700 text-white', + }; + case 'info': + return { + icon: ( + + + + ), + confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white', + }; + default: + return { + icon: null, + confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white', + }; + } + }; + + const { icon, confirmClass } = getVariantStyles(); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } else if (e.key === 'Enter') { + onConfirm(); + } + }; + + return ( +
+
+
+
+ {icon && ( +
+ {icon} +
+ )} +
+ {title && ( +

+ {title} +

+ )} +

+ {message} +

+
+
+ +
+ + +
+
+
+
+ ); +}; + +export default ConfirmDialog; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 55826f9..6d0b30a 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -174,7 +174,8 @@ "copy": "Copy", "copySuccess": "Copied to clipboard", "copyFailed": "Copy failed", - "close": "Close" + "close": "Close", + "confirm": "Confirm" }, "nav": { "dashboard": "Dashboard", @@ -366,5 +367,30 @@ "smartRoutingConfigUpdated": "Smart routing configuration updated successfully", "smartRoutingRequiredFields": "Database URL and OpenAI API Key are required to enable smart routing", "smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}" + }, + "dxt": { + "upload": "Upload", + "uploadTitle": "Upload DXT Extension", + "dropFileHere": "Drop your .dxt file here", + "orClickToSelect": "or click to select from your computer", + "invalidFileType": "Please select a valid .dxt file", + "noFileSelected": "Please select a .dxt file to upload", + "uploading": "Uploading...", + "uploadFailed": "Failed to upload DXT file", + "installServer": "Install MCP Server from DXT", + "extensionInfo": "Extension Information", + "name": "Name", + "version": "Version", + "description": "Description", + "author": "Author", + "tools": "Tools", + "serverName": "Server Name", + "serverNamePlaceholder": "Enter a name for this server", + "install": "Install", + "installing": "Installing...", + "installFailed": "Failed to install server from DXT", + "serverExistsTitle": "Server Already Exists", + "serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?", + "override": "Override" } } \ No newline at end of file diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 0fdb12a..b4f0a20 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -175,7 +175,8 @@ "copy": "复制", "copySuccess": "已复制到剪贴板", "copyFailed": "复制失败", - "close": "关闭" + "close": "关闭", + "confirm": "确认" }, "nav": { "dashboard": "仪表盘", @@ -368,5 +369,30 @@ "smartRoutingConfigUpdated": "智能路由配置更新成功", "smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥", "smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}" + }, + "dxt": { + "upload": "上传", + "uploadTitle": "上传 DXT 扩展", + "dropFileHere": "将 .dxt 文件拖拽到此处", + "orClickToSelect": "或点击从计算机选择", + "invalidFileType": "请选择有效的 .dxt 文件", + "noFileSelected": "请选择要上传的 .dxt 文件", + "uploading": "上传中...", + "uploadFailed": "上传 DXT 文件失败", + "installServer": "从 DXT 安装 MCP 服务器", + "extensionInfo": "扩展信息", + "name": "名称", + "version": "版本", + "description": "描述", + "author": "作者", + "tools": "工具", + "serverName": "服务器名称", + "serverNamePlaceholder": "为此服务器输入名称", + "install": "安装", + "installing": "安装中...", + "installFailed": "从 DXT 安装服务器失败", + "serverExistsTitle": "服务器已存在", + "serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?", + "override": "覆盖" } } \ No newline at end of file diff --git a/frontend/src/pages/ServersPage.tsx b/frontend/src/pages/ServersPage.tsx index 016813b..e07e6e3 100644 --- a/frontend/src/pages/ServersPage.tsx +++ b/frontend/src/pages/ServersPage.tsx @@ -6,6 +6,7 @@ import ServerCard from '@/components/ServerCard'; import AddServerForm from '@/components/AddServerForm'; import EditServerForm from '@/components/EditServerForm'; import { useServerData } from '@/hooks/useServerData'; +import DxtUploadForm from '@/components/DxtUploadForm'; const ServersPage: React.FC = () => { const { t } = useTranslation(); @@ -23,6 +24,7 @@ const ServersPage: React.FC = () => { } = useServerData(); const [editingServer, setEditingServer] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); + const [showDxtUpload, setShowDxtUpload] = useState(false); const handleEditClick = async (server: Server) => { const fullServerData = await handleServerEdit(server); @@ -47,6 +49,12 @@ const ServersPage: React.FC = () => { } }; + const handleDxtUploadSuccess = (_serverConfig: any) => { + // Close upload dialog and refresh servers + setShowDxtUpload(false); + triggerRefresh(); + }; + return (
@@ -62,6 +70,15 @@ const ServersPage: React.FC = () => { {t('nav.market')} +
); }; diff --git a/package.json b/package.json index 468ef67..338cb09 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,10 @@ "dependencies": { "@apidevtools/swagger-parser": "^11.0.1", "@modelcontextprotocol/sdk": "^1.12.1", + "@types/adm-zip": "^0.5.7", + "@types/multer": "^1.4.13", "@types/pg": "^8.15.2", + "adm-zip": "^0.5.16", "axios": "^1.10.0", "bcryptjs": "^3.0.2", "dotenv": "^16.3.1", @@ -55,6 +58,7 @@ "express": "^4.21.2", "express-validator": "^7.2.1", "jsonwebtoken": "^9.0.2", + "multer": "^2.0.1", "openai": "^4.103.0", "openapi-types": "^12.1.3", "pg": "^8.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a86784f..d97a66d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,18 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.12.1 version: 1.12.1 + '@types/adm-zip': + specifier: ^0.5.7 + version: 0.5.7 + '@types/multer': + specifier: ^1.4.13 + version: 1.4.13 '@types/pg': specifier: ^8.15.2 version: 8.15.4 + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 axios: specifier: ^1.10.0 version: 1.10.0 @@ -38,6 +47,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 + multer: + specifier: ^2.0.1 + version: 2.0.1 openai: specifier: ^4.103.0 version: 4.104.0(zod@3.25.48) @@ -616,85 +628,72 @@ packages: resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.1.0': resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.1.0': resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.1.0': resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.1.0': resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.1.0': resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.1.0': resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.2': resolution: {integrity: sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.2': resolution: {integrity: sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.2': resolution: {integrity: sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.2': resolution: {integrity: sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.2': resolution: {integrity: sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.2': resolution: {integrity: sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.2': resolution: {integrity: sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==} @@ -870,28 +869,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@15.3.3': resolution: {integrity: sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@15.3.3': resolution: {integrity: sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@15.3.3': resolution: {integrity: sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@15.3.3': resolution: {integrity: sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==} @@ -1105,67 +1100,56 @@ packages: resolution: {integrity: sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.40.1': resolution: {integrity: sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.40.1': resolution: {integrity: sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.40.1': resolution: {integrity: sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.40.1': resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.40.1': resolution: {integrity: sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.40.1': resolution: {integrity: sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.40.1': resolution: {integrity: sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.40.1': resolution: {integrity: sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.1': resolution: {integrity: sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.40.1': resolution: {integrity: sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.40.1': resolution: {integrity: sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==} @@ -1248,28 +1232,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.8': resolution: {integrity: sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.8': resolution: {integrity: sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.8': resolution: {integrity: sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.8': resolution: {integrity: sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg==} @@ -1319,6 +1299,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/adm-zip@0.5.7': + resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1386,6 +1369,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@1.4.13': + resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} + '@types/node-fetch@2.6.12': resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} @@ -1538,6 +1524,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -1592,6 +1582,9 @@ packages: resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==} engines: {node: '>= 6.0.0'} + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -1842,6 +1835,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + concurrently@9.1.2: resolution: {integrity: sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==} engines: {node: '>=18'} @@ -2820,28 +2817,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -3022,6 +3015,10 @@ packages: resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} engines: {node: '>= 18'} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -3038,6 +3035,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multer@2.0.1: + resolution: {integrity: sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==} + engines: {node: '>= 10.16.0'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3877,6 +3878,9 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typeorm@0.3.24: resolution: {integrity: sha512-4IrHG7A0tY8l5gEGXfW56VOMfUVWEkWlH/h5wmcyZ+V8oCiLj7iTPp0lEjMEZVrxEkGSdP9ErgTKHKXQApl/oA==} engines: {node: '>=16.13.0'} @@ -5156,6 +5160,10 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/adm-zip@0.5.7': + dependencies: + '@types/node': 22.15.29 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.27.4 @@ -5242,6 +5250,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@1.4.13': + dependencies: + '@types/express': 4.17.22 + '@types/node-fetch@2.6.12': dependencies: '@types/node': 22.15.29 @@ -5436,6 +5448,8 @@ snapshots: acorn@8.14.1: {} + adm-zip@0.5.16: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -5483,6 +5497,8 @@ snapshots: app-root-path@3.1.0: {} + append-field@1.0.0: {} + arg@4.1.3: {} argparse@1.0.10: @@ -5774,6 +5790,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + concurrently@9.1.2: dependencies: chalk: 4.1.2 @@ -7176,6 +7199,10 @@ snapshots: dependencies: minipass: 7.1.2 + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mkdirp@1.0.4: {} mkdirp@3.0.1: {} @@ -7184,6 +7211,16 @@ snapshots: ms@2.1.3: {} + multer@2.0.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + nanoid@3.3.11: {} natural-compare@1.4.0: {} @@ -8030,6 +8067,8 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.1 + typedarray@0.0.6: {} + typeorm@0.3.24(pg@8.16.0)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)): dependencies: '@sqltools/formatter': 1.2.5 diff --git a/src/controllers/dxtController.ts b/src/controllers/dxtController.ts new file mode 100644 index 0000000..35c4c14 --- /dev/null +++ b/src/controllers/dxtController.ts @@ -0,0 +1,156 @@ +import { Request, Response } from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import AdmZip from 'adm-zip'; +import { ApiResponse } from '../types/index.js'; + +// Get the directory name in ESM +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Configure multer for file uploads +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const uploadDir = path.join(__dirname, '../../data/uploads/dxt'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const timestamp = Date.now(); + const originalName = path.parse(file.originalname).name; + cb(null, `${originalName}-${timestamp}.dxt`); + }, +}); + +const upload = multer({ + storage, + fileFilter: (req, file, cb) => { + if (file.originalname.endsWith('.dxt')) { + cb(null, true); + } else { + cb(new Error('Only .dxt files are allowed')); + } + }, + limits: { + fileSize: 100 * 1024 * 1024, // 100MB limit + }, +}); + +export const uploadMiddleware = upload.single('dxtFile'); + +// Clean up old DXT server files when installing a new version +const cleanupOldDxtServer = (serverName: string): void => { + try { + const uploadDir = path.join(__dirname, '../../data/uploads/dxt'); + const serverPattern = `server-${serverName}`; + + if (fs.existsSync(uploadDir)) { + const files = fs.readdirSync(uploadDir); + files.forEach((file) => { + if (file.startsWith(serverPattern)) { + const filePath = path.join(uploadDir, file); + if (fs.statSync(filePath).isDirectory()) { + fs.rmSync(filePath, { recursive: true, force: true }); + console.log(`Cleaned up old DXT server directory: ${filePath}`); + } + } + }); + } + } catch (error) { + console.warn('Failed to cleanup old DXT server files:', error); + // Don't fail the installation if cleanup fails + } +}; + +export const uploadDxtFile = async (req: Request, res: Response): Promise => { + try { + if (!req.file) { + res.status(400).json({ + success: false, + message: 'No DXT file uploaded', + }); + return; + } + + const dxtFilePath = req.file.path; + const timestamp = Date.now(); + const tempExtractDir = path.join(path.dirname(dxtFilePath), `temp-extracted-${timestamp}`); + + try { + // Extract the DXT file (which is a ZIP archive) to a temporary directory first + const zip = new AdmZip(dxtFilePath); + zip.extractAllTo(tempExtractDir, true); + + // 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 = 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'); + } + + // Use server name as the final extract directory for automatic version management + const finalExtractDir = path.join(path.dirname(dxtFilePath), `server-${manifest.name}`); + + // Clean up any existing version of this server + cleanupOldDxtServer(manifest.name); + + // Move the temporary directory to the final location + fs.renameSync(tempExtractDir, finalExtractDir); + console.log(`DXT server extracted to: ${finalExtractDir}`); + + // Clean up the uploaded DXT file + fs.unlinkSync(dxtFilePath); + + const response: ApiResponse = { + success: true, + data: { + manifest, + extractDir: finalExtractDir, + }, + }; + + res.json(response); + } catch (extractError) { + // Clean up files on error + if (fs.existsSync(dxtFilePath)) { + fs.unlinkSync(dxtFilePath); + } + if (fs.existsSync(tempExtractDir)) { + fs.rmSync(tempExtractDir, { recursive: true, force: true }); + } + throw extractError; + } + } catch (error) { + console.error('DXT upload error:', error); + + let message = 'Failed to process DXT file'; + if (error instanceof Error) { + message = error.message; + } + + res.status(500).json({ + success: false, + message, + }); + } +}; diff --git a/src/controllers/serverController.ts b/src/controllers/serverController.ts index ac7716a..d433605 100644 --- a/src/controllers/serverController.ts +++ b/src/controllers/serverController.ts @@ -3,8 +3,8 @@ import { ApiResponse, AddServerRequest } from '../types/index.js'; import { getServersInfo, addServer, + addOrUpdateServer, removeServer, - updateMcpServer, notifyToolChanged, syncToolEmbedding, toggleServerStatus, @@ -264,7 +264,7 @@ export const updateServer = async (req: Request, res: Response): Promise = config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers } - const result = await updateMcpServer(name, config); + const result = await addOrUpdateServer(name, config, true); // Allow override for updates if (result.success) { notifyToolChanged(); res.json({ diff --git a/src/routes/index.ts b/src/routes/index.ts index a51a718..ced312b 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -36,6 +36,7 @@ import { login, register, getCurrentUser, changePassword } from '../controllers/ import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js'; import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js'; import { callTool } from '../controllers/toolController.js'; +import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js'; import { auth } from '../middlewares/auth.js'; const router = express.Router(); @@ -67,6 +68,9 @@ export const initRoutes = (app: express.Application): void => { // Tool management routes router.post('/tools/call/:server', callTool); + // DXT upload routes + router.post('/dxt/upload', uploadMiddleware, uploadDxtFile); + // Market routes router.get('/market/servers', getAllMarketServers); router.get('/market/servers/search', searchMarketServersByQuery); diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts index 8f1ae28..52f5d7e 100644 --- a/src/services/mcpService.ts +++ b/src/services/mcpService.ts @@ -513,6 +513,42 @@ export const updateMcpServer = async ( } }; +// Add or update server (supports overriding existing servers for DXT) +export const addOrUpdateServer = async ( + name: string, + config: ServerConfig, + allowOverride: boolean = false, +): Promise<{ success: boolean; message?: string }> => { + try { + const settings = loadSettings(); + const exists = !!settings.mcpServers[name]; + + if (exists && !allowOverride) { + return { success: false, message: 'Server name already exists' }; + } + + // If overriding and this is a DXT server (stdio type with file paths), + // we might want to clean up old files in the future + if (exists && config.type === 'stdio') { + // Close existing server connections + closeServer(name); + // Remove from server infos + serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name); + } + + settings.mcpServers[name] = config; + if (!saveSettings(settings)) { + return { success: false, message: 'Failed to save settings' }; + } + + const action = exists ? 'updated' : 'added'; + return { success: true, message: `Server ${action} successfully` }; + } catch (error) { + console.error(`Failed to add/update server: ${name}`, error); + return { success: false, message: 'Failed to add/update server' }; + } +}; + // Close server client and transport function closeServer(name: string) { const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);