mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Add JSON import for MCP servers (#385)
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 <samanhappy@gmail.com>
This commit is contained in:
311
frontend/src/components/JSONImportForm.tsx
Normal file
311
frontend/src/components/JSONImportForm.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiPost } from '@/utils/fetchInterceptor';
|
||||
|
||||
interface JSONImportFormProps {
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface McpServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
type?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ImportJsonFormat {
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
}
|
||||
|
||||
const JSONImportForm: React.FC<JSONImportFormProps> = ({ onSuccess, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [previewServers, setPreviewServers] = useState<Array<{ name: string; config: any }> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const examplePlaceholder = `STDIO example:
|
||||
{
|
||||
"mcpServers": {
|
||||
"stdio-server-example": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-server-example"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SSE example:
|
||||
{
|
||||
"mcpServers": {
|
||||
"sse-server-example": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HTTP example:
|
||||
{
|
||||
"mcpServers": {
|
||||
"http-server-example": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3001",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer your-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(input.trim());
|
||||
|
||||
// Validate structure
|
||||
if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
|
||||
setError(t('jsonImport.invalidFormat'));
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as ImportJsonFormat;
|
||||
} catch (e) {
|
||||
setError(t('jsonImport.parseError'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
setError(null);
|
||||
const parsed = parseAndValidateJson(jsonInput);
|
||||
if (!parsed) return;
|
||||
|
||||
const servers = Object.entries(parsed.mcpServers).map(([name, config]) => {
|
||||
// Normalize config to MCPHub format
|
||||
const normalizedConfig: any = {};
|
||||
|
||||
if (config.type === 'sse' || config.type === 'streamable-http') {
|
||||
normalizedConfig.type = config.type;
|
||||
normalizedConfig.url = config.url;
|
||||
if (config.headers) {
|
||||
normalizedConfig.headers = config.headers;
|
||||
}
|
||||
} else {
|
||||
// Default to stdio
|
||||
normalizedConfig.type = 'stdio';
|
||||
normalizedConfig.command = config.command;
|
||||
normalizedConfig.args = config.args || [];
|
||||
if (config.env) {
|
||||
normalizedConfig.env = config.env;
|
||||
}
|
||||
}
|
||||
|
||||
return { name, config: normalizedConfig };
|
||||
});
|
||||
|
||||
setPreviewServers(servers);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!previewServers) return;
|
||||
|
||||
setIsImporting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let successCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const server of previewServers) {
|
||||
try {
|
||||
const result = await apiPost('/servers', {
|
||||
name: server.name,
|
||||
config: server.config,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errors.push(`${server.name}: ${result.message || t('jsonImport.addFailed')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`${server.name}: ${err instanceof Error ? err.message : t('jsonImport.addFailed')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setError(
|
||||
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
|
||||
'\n' +
|
||||
errors.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
setError(t('jsonImport.importFailed'));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('jsonImport.title')}</h2>
|
||||
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
||||
<p className="text-red-700 whitespace-pre-wrap">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!previewServers ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('jsonImport.inputLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
className="w-full h-96 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
placeholder={examplePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">{t('jsonImport.inputHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={!jsonInput.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('jsonImport.preview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||
{t('jsonImport.previewTitle')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{previewServers.map((server, index) => (
|
||||
<div key={index} className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{server.name}</h4>
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
<div>
|
||||
<strong>{t('server.type')}:</strong> {server.config.type || 'stdio'}
|
||||
</div>
|
||||
{server.config.command && (
|
||||
<div>
|
||||
<strong>{t('server.command')}:</strong> {server.config.command}
|
||||
</div>
|
||||
)}
|
||||
{server.config.args && server.config.args.length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.arguments')}:</strong>{' '}
|
||||
{server.config.args.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
{server.config.url && (
|
||||
<div>
|
||||
<strong>{t('server.url')}:</strong> {server.config.url}
|
||||
</div>
|
||||
)}
|
||||
{server.config.env && Object.keys(server.config.env).length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.envVars')}:</strong>{' '}
|
||||
{Object.keys(server.config.env).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{server.config.headers &&
|
||||
Object.keys(server.config.headers).length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.headers')}:</strong>{' '}
|
||||
{Object.keys(server.config.headers).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={() => setPreviewServers(null)}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('jsonImport.importing')}
|
||||
</>
|
||||
) : (
|
||||
t('jsonImport.import')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONImportForm;
|
||||
@@ -7,6 +7,7 @@ import AddServerForm from '@/components/AddServerForm';
|
||||
import EditServerForm from '@/components/EditServerForm';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import DxtUploadForm from '@/components/DxtUploadForm';
|
||||
import JSONImportForm from '@/components/JSONImportForm';
|
||||
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -25,6 +26,7 @@ const ServersPage: React.FC = () => {
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showDxtUpload, setShowDxtUpload] = useState(false);
|
||||
const [showJsonImport, setShowJsonImport] = useState(false);
|
||||
|
||||
const handleEditClick = async (server: Server) => {
|
||||
const fullServerData = await handleServerEdit(server);
|
||||
@@ -55,6 +57,12 @@ const ServersPage: React.FC = () => {
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
const handleJsonImportSuccess = () => {
|
||||
// Close import dialog and refresh servers
|
||||
setShowJsonImport(false);
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
@@ -70,6 +78,15 @@ const ServersPage: React.FC = () => {
|
||||
{t('nav.market')}
|
||||
</button>
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={() => setShowJsonImport(true)}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('jsonImport.button')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDxtUpload(true)}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
@@ -161,6 +178,13 @@ const ServersPage: React.FC = () => {
|
||||
onCancel={() => setShowDxtUpload(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showJsonImport && (
|
||||
<JSONImportForm
|
||||
onSuccess={handleJsonImportSuccess}
|
||||
onCancel={() => setShowJsonImport(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -204,6 +204,7 @@
|
||||
"processing": "Processing...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"refresh": "Refresh",
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
@@ -598,6 +599,21 @@
|
||||
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
|
||||
"override": "Override"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "Import",
|
||||
"title": "Import Servers from JSON",
|
||||
"inputLabel": "Server Configuration JSON",
|
||||
"inputHelp": "Paste your server configuration JSON. Supports STDIO, SSE, and HTTP (streamable-http) server types.",
|
||||
"preview": "Preview",
|
||||
"previewTitle": "Preview Servers to Import",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"invalidFormat": "Invalid JSON format. The JSON must contain an 'mcpServers' object.",
|
||||
"parseError": "Failed to parse JSON. Please check the format and try again.",
|
||||
"addFailed": "Failed to add server",
|
||||
"importFailed": "Failed to import servers",
|
||||
"partialSuccess": "Imported {{count}} of {{total}} servers successfully. Some servers failed:"
|
||||
},
|
||||
"users": {
|
||||
"add": "Add User",
|
||||
"addNew": "Add New User",
|
||||
|
||||
@@ -204,6 +204,7 @@
|
||||
"processing": "En cours de traitement...",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"back": "Retour",
|
||||
"refresh": "Actualiser",
|
||||
"create": "Créer",
|
||||
"creating": "Création en cours...",
|
||||
@@ -598,6 +599,21 @@
|
||||
"serverExistsConfirm": "Le serveur '{{serverName}}' existe déjà. Voulez-vous le remplacer par la nouvelle version ?",
|
||||
"override": "Remplacer"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "Importer",
|
||||
"title": "Importer des serveurs depuis JSON",
|
||||
"inputLabel": "Configuration JSON du serveur",
|
||||
"inputHelp": "Collez votre configuration JSON de serveur. Prend en charge les types de serveurs STDIO, SSE et HTTP (streamable-http).",
|
||||
"preview": "Aperçu",
|
||||
"previewTitle": "Aperçu des serveurs à importer",
|
||||
"import": "Importer",
|
||||
"importing": "Importation en cours...",
|
||||
"invalidFormat": "Format JSON invalide. Le JSON doit contenir un objet 'mcpServers'.",
|
||||
"parseError": "Échec de l'analyse du JSON. Veuillez vérifier le format et réessayer.",
|
||||
"addFailed": "Échec de l'ajout du serveur",
|
||||
"importFailed": "Échec de l'importation des serveurs",
|
||||
"partialSuccess": "{{count}} serveur(s) sur {{total}} importé(s) avec succès. Certains serveurs ont échoué :"
|
||||
},
|
||||
"users": {
|
||||
"add": "Ajouter un utilisateur",
|
||||
"addNew": "Ajouter un nouvel utilisateur",
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"processing": "处理中...",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"back": "返回",
|
||||
"refresh": "刷新",
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
@@ -600,6 +601,21 @@
|
||||
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
|
||||
"override": "覆盖"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "导入",
|
||||
"title": "从 JSON 导入服务器",
|
||||
"inputLabel": "服务器配置 JSON",
|
||||
"inputHelp": "粘贴您的服务器配置 JSON。支持 STDIO、SSE 和 HTTP (streamable-http) 服务器类型。",
|
||||
"preview": "预览",
|
||||
"previewTitle": "预览要导入的服务器",
|
||||
"import": "导入",
|
||||
"importing": "导入中...",
|
||||
"invalidFormat": "无效的 JSON 格式。JSON 必须包含 'mcpServers' 对象。",
|
||||
"parseError": "解析 JSON 失败。请检查格式后重试。",
|
||||
"addFailed": "添加服务器失败",
|
||||
"importFailed": "导入服务器失败",
|
||||
"partialSuccess": "成功导入 {{count}} / {{total}} 个服务器。部分服务器失败:"
|
||||
},
|
||||
"users": {
|
||||
"add": "添加",
|
||||
"addNew": "添加新用户",
|
||||
|
||||
Reference in New Issue
Block a user