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:
Copilot
2025-10-26 19:13:06 +08:00
committed by GitHub
parent 26b26a5fb1
commit 2f7726b008
5 changed files with 383 additions and 0 deletions

View 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;

View File

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

View File

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

View File

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

View File

@@ -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": "添加新用户",