Add batch server and group creation functionality (#499)

This commit is contained in:
samanhappy
2025-12-11 14:21:58 +08:00
committed by GitHub
parent ab7c210281
commit 1dfa0a990b
11 changed files with 815 additions and 56 deletions

View File

@@ -0,0 +1,284 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiPost } from '@/utils/fetchInterceptor';
interface GroupImportFormProps {
onSuccess: () => void;
onCancel: () => void;
}
interface ImportGroupConfig {
name: string;
description?: string;
servers?: string[] | Array<{ name: string; tools?: string[] | 'all' }>;
}
interface ImportJsonFormat {
groups: ImportGroupConfig[];
}
const GroupImportForm: React.FC<GroupImportFormProps> = ({ onSuccess, onCancel }) => {
const { t } = useTranslation();
const [jsonInput, setJsonInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [previewGroups, setPreviewGroups] = useState<ImportGroupConfig[] | null>(null);
const examplePlaceholder = `{
"groups": [
{
"name": "AI Assistants",
"servers": ["openai-server", "anthropic-server"]
},
{
"name": "Development Tools",
"servers": [
{
"name": "github-server",
"tools": ["create_issue", "list_repos"]
},
{
"name": "gitlab-server",
"tools": "all"
}
]
}
]
}
Supports:
- Simple server list: ["server1", "server2"]
- Advanced server config: [{"name": "server1", "tools": ["tool1", "tool2"]}]
- All groups will be imported in a single efficient batch operation.`;
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
try {
const parsed = JSON.parse(input.trim());
// Validate structure
if (!parsed.groups || !Array.isArray(parsed.groups)) {
setError(t('groupImport.invalidFormat'));
return null;
}
// Validate each group
for (const group of parsed.groups) {
if (!group.name || typeof group.name !== 'string') {
setError(t('groupImport.missingName'));
return null;
}
}
return parsed as ImportJsonFormat;
} catch (e) {
setError(t('groupImport.parseError'));
return null;
}
};
const handlePreview = () => {
setError(null);
const parsed = parseAndValidateJson(jsonInput);
if (!parsed) return;
setPreviewGroups(parsed.groups);
};
const handleImport = async () => {
if (!previewGroups) return;
setIsImporting(true);
setError(null);
try {
// Use batch import API for better performance
const result = await apiPost('/groups/batch', {
groups: previewGroups,
});
if (result.success) {
const { successCount, failureCount, results } = result;
if (failureCount > 0) {
const errors = results
.filter((r: any) => !r.success)
.map((r: any) => `${r.name}: ${r.message || t('groupImport.addFailed')}`);
setError(
t('groupImport.partialSuccess', { count: successCount, total: previewGroups.length }) +
'\n' +
errors.join('\n'),
);
}
if (successCount > 0) {
onSuccess();
}
} else {
setError(result.message || t('groupImport.importFailed'));
}
} catch (err) {
console.error('Import error:', err);
setError(t('groupImport.importFailed'));
} finally {
setIsImporting(false);
}
};
const renderServerList = (
servers?: string[] | Array<{ name: string; tools?: string[] | 'all' }>,
) => {
if (!servers || servers.length === 0) {
return <span className="text-gray-500">{t('groups.noServers')}</span>;
}
return (
<div className="space-y-1">
{servers.map((server, idx) => {
if (typeof server === 'string') {
return (
<div key={idx} className="text-sm">
{server}
</div>
);
} else {
return (
<div key={idx} className="text-sm">
{server.name}
{server.tools && server.tools !== 'all' && (
<span className="text-gray-500 ml-2">
({Array.isArray(server.tools) ? server.tools.join(', ') : server.tools})
</span>
)}
{server.tools === 'all' && <span className="text-gray-500 ml-2">(all tools)</span>}
</div>
);
}
})}
</div>
);
};
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('groupImport.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>
)}
{!previewGroups ? (
<div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('groupImport.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('groupImport.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('groupImport.preview')}
</button>
</div>
</div>
) : (
<div>
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 mb-3">
{t('groupImport.previewTitle')}
</h3>
<div className="space-y-3">
{previewGroups.map((group, 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">{group.name}</h4>
{group.description && (
<p className="text-sm text-gray-600 mt-1">{group.description}</p>
)}
<div className="mt-2 text-sm text-gray-600">
<strong>{t('groups.servers')}:</strong>
<div className="mt-1">{renderServerList(group.servers)}</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end space-x-4">
<button
onClick={() => setPreviewGroups(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('groupImport.importing')}
</>
) : (
t('groupImport.import')
)}
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default GroupImportForm;

View File

@@ -29,29 +29,16 @@ const JSONImportForm: React.FC<JSONImportFormProps> = ({ onSuccess, onCancel })
null, null,
); );
const examplePlaceholder = `STDIO example: const examplePlaceholder = `{
{
"mcpServers": { "mcpServers": {
"stdio-server-example": { "stdio-server-example": {
"command": "npx", "command": "npx",
"args": ["-y", "mcp-server-example"] "args": ["-y", "mcp-server-example"]
} },
}
}
SSE example:
{
"mcpServers": {
"sse-server-example": { "sse-server-example": {
"type": "sse", "type": "sse",
"url": "http://localhost:3000" "url": "http://localhost:3000"
} },
}
}
HTTP example:
{
"mcpServers": {
"http-server-example": { "http-server-example": {
"type": "streamable-http", "type": "streamable-http",
"url": "http://localhost:3001", "url": "http://localhost:3001",
@@ -61,7 +48,10 @@ HTTP example:
} }
} }
} }
}`; }
Supports: STDIO, SSE, HTTP (streamable-http), OpenAPI
All servers will be imported in a single efficient batch operation.`;
const parseAndValidateJson = (input: string): ImportJsonFormat | null => { const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
try { try {
@@ -118,29 +108,19 @@ HTTP example:
setError(null); setError(null);
try { try {
let successCount = 0; // Use batch import API for better performance
const errors: string[] = []; const result = await apiPost('/servers/batch', {
servers: previewServers,
for (const server of previewServers) {
try {
const result = await apiPost('/servers', {
name: server.name,
config: server.config,
}); });
if (result.success) { if (result.success && result.data) {
successCount++; const { successCount, failureCount, results } = result.data;
} else {
errors.push(`${server.name}: ${result.message || t('jsonImport.addFailed')}`); if (failureCount > 0) {
} const errors = results
} catch (err) { .filter((r: any) => !r.success)
errors.push( .map((r: any) => `${r.name}: ${r.message || t('jsonImport.addFailed')}`);
`${server.name}: ${err instanceof Error ? err.message : t('jsonImport.addFailed')}`,
);
}
}
if (errors.length > 0) {
setError( setError(
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) + t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
'\n' + '\n' +
@@ -151,6 +131,9 @@ HTTP example:
if (successCount > 0) { if (successCount > 0) {
onSuccess(); onSuccess();
} }
} else {
setError(result.message || t('jsonImport.importFailed'));
}
} catch (err) { } catch (err) {
console.error('Import error:', err); console.error('Import error:', err);
setError(t('jsonImport.importFailed')); setError(t('jsonImport.importFailed'));

View File

@@ -6,6 +6,7 @@ import { useServerData } from '@/hooks/useServerData';
import AddGroupForm from '@/components/AddGroupForm'; import AddGroupForm from '@/components/AddGroupForm';
import EditGroupForm from '@/components/EditGroupForm'; import EditGroupForm from '@/components/EditGroupForm';
import GroupCard from '@/components/GroupCard'; import GroupCard from '@/components/GroupCard';
import GroupImportForm from '@/components/GroupImportForm';
const GroupsPage: React.FC = () => { const GroupsPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -15,12 +16,13 @@ const GroupsPage: React.FC = () => {
error: groupError, error: groupError,
setError: setGroupError, setError: setGroupError,
deleteGroup, deleteGroup,
triggerRefresh triggerRefresh,
} = useGroupData(); } = useGroupData();
const { servers } = useServerData({ refreshOnMount: true }); const { servers } = useServerData({ refreshOnMount: true });
const [editingGroup, setEditingGroup] = useState<Group | null>(null); const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [showImportForm, setShowImportForm] = useState(false);
const handleEditClick = (group: Group) => { const handleEditClick = (group: Group) => {
setEditingGroup(group); setEditingGroup(group);
@@ -47,6 +49,11 @@ const GroupsPage: React.FC = () => {
triggerRefresh(); // Refresh the groups list after adding triggerRefresh(); // Refresh the groups list after adding
}; };
const handleImportSuccess = () => {
setShowImportForm(false);
triggerRefresh(); // Refresh the groups list after import
};
return ( return (
<div> <div>
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
@@ -56,11 +63,38 @@ const GroupsPage: React.FC = () => {
onClick={handleAddGroup} onClick={handleAddGroup}
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" 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"> <svg
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" /> 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="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg> </svg>
{t('groups.add')} {t('groups.add')}
</button> </button>
<button
onClick={() => setShowImportForm(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-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
{t('groupImport.button')}
</button>
</div> </div>
</div> </div>
@@ -73,9 +107,25 @@ const GroupsPage: React.FC = () => {
{groupsLoading ? ( {groupsLoading ? (
<div className="bg-white shadow rounded-lg p-6 loading-container"> <div className="bg-white shadow rounded-lg p-6 loading-container">
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="animate-spin h-10 w-10 text-blue-500 mb-4"
<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> 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> </svg>
<p className="text-gray-600">{t('app.loading')}</p> <p className="text-gray-600">{t('app.loading')}</p>
</div> </div>
@@ -98,8 +148,13 @@ const GroupsPage: React.FC = () => {
</div> </div>
)} )}
{showAddForm && ( {showAddForm && <AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />}
<AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />
{showImportForm && (
<GroupImportForm
onSuccess={handleImportSuccess}
onCancel={() => setShowImportForm(false)}
/>
)} )}
{editingGroup && ( {editingGroup && (

View File

@@ -276,7 +276,7 @@
"recentServers": "Recent Servers" "recentServers": "Recent Servers"
}, },
"servers": { "servers": {
"title": "Servers Management" "title": "Server Management"
}, },
"groups": { "groups": {
"title": "Group Management" "title": "Group Management"
@@ -672,6 +672,22 @@
"importFailed": "Failed to import servers", "importFailed": "Failed to import servers",
"partialSuccess": "Imported {{count}} of {{total}} servers successfully. Some servers failed:" "partialSuccess": "Imported {{count}} of {{total}} servers successfully. Some servers failed:"
}, },
"groupImport": {
"button": "Import",
"title": "Import Groups from JSON",
"inputLabel": "Group Configuration JSON",
"inputHelp": "Paste your group configuration JSON. Each group can contain a list of servers.",
"preview": "Preview",
"previewTitle": "Preview Groups to Import",
"import": "Import",
"importing": "Importing...",
"invalidFormat": "Invalid JSON format. The JSON must contain a 'groups' array.",
"missingName": "Each group must have a 'name' field.",
"parseError": "Failed to parse JSON. Please check the format and try again.",
"addFailed": "Failed to add group",
"importFailed": "Failed to import groups",
"partialSuccess": "Imported {{count}} of {{total}} groups successfully. Some groups failed:"
},
"users": { "users": {
"add": "Add User", "add": "Add User",
"addNew": "Add New User", "addNew": "Add New User",

View File

@@ -673,6 +673,22 @@
"importFailed": "Échec de l'importation des serveurs", "importFailed": "Échec de l'importation des serveurs",
"partialSuccess": "{{count}} serveur(s) sur {{total}} importé(s) avec succès. Certains serveurs ont échoué :" "partialSuccess": "{{count}} serveur(s) sur {{total}} importé(s) avec succès. Certains serveurs ont échoué :"
}, },
"groupImport": {
"button": "Importer",
"title": "Importer des groupes depuis JSON",
"inputLabel": "Configuration JSON des groupes",
"inputHelp": "Collez votre configuration JSON de groupes. Chaque groupe peut contenir une liste de serveurs.",
"preview": "Aperçu",
"previewTitle": "Aperçu des groupes à importer",
"import": "Importer",
"importing": "Importation en cours...",
"invalidFormat": "Format JSON invalide. Le JSON doit contenir un tableau 'groups'.",
"missingName": "Chaque groupe doit avoir un champ 'name'.",
"parseError": "Échec de l'analyse du JSON. Veuillez vérifier le format et réessayer.",
"addFailed": "Échec de l'ajout du groupe",
"importFailed": "Échec de l'importation des groupes",
"partialSuccess": "{{count}} groupe(s) sur {{total}} importé(s) avec succès. Certains groupes ont échoué :"
},
"users": { "users": {
"add": "Ajouter un utilisateur", "add": "Ajouter un utilisateur",
"addNew": "Ajouter un nouvel utilisateur", "addNew": "Ajouter un nouvel utilisateur",

View File

@@ -673,6 +673,22 @@
"importFailed": "Sunucular içe aktarılamadı", "importFailed": "Sunucular içe aktarılamadı",
"partialSuccess": "{{total}} sunucudan {{count}} tanesi başarıyla içe aktarıldı. Bazı sunucular başarısız oldu:" "partialSuccess": "{{total}} sunucudan {{count}} tanesi başarıyla içe aktarıldı. Bazı sunucular başarısız oldu:"
}, },
"groupImport": {
"button": "İçe Aktar",
"title": "JSON'dan Grupları İçe Aktar",
"inputLabel": "Grup Yapılandırma JSON",
"inputHelp": "Grup yapılandırma JSON'unuzu yapıştırın. Her grup bir sunucu listesi içerebilir.",
"preview": "Önizle",
"previewTitle": "İçe Aktarılacak Grupları Önizle",
"import": "İçe Aktar",
"importing": "İçe aktarılıyor...",
"invalidFormat": "Geçersiz JSON formatı. JSON bir 'groups' dizisi içermelidir.",
"missingName": "Her grubun bir 'name' alanı olmalıdır.",
"parseError": "JSON ayrıştırılamadı. Lütfen formatı kontrol edip tekrar deneyin.",
"addFailed": "Grup eklenemedi",
"importFailed": "Gruplar içe aktarılamadı",
"partialSuccess": "{{total}} gruptan {{count}} tanesi başarıyla içe aktarıldı. Bazı gruplar başarısız oldu:"
},
"users": { "users": {
"add": "Kullanıcı Ekle", "add": "Kullanıcı Ekle",
"addNew": "Yeni Kullanıcı Ekle", "addNew": "Yeni Kullanıcı Ekle",

View File

@@ -675,6 +675,22 @@
"importFailed": "导入服务器失败", "importFailed": "导入服务器失败",
"partialSuccess": "成功导入 {{count}} / {{total}} 个服务器。部分服务器失败:" "partialSuccess": "成功导入 {{count}} / {{total}} 个服务器。部分服务器失败:"
}, },
"groupImport": {
"button": "导入",
"title": "从 JSON 导入分组",
"inputLabel": "分组配置 JSON",
"inputHelp": "粘贴您的分组配置 JSON。每个分组可以包含一个服务器列表。",
"preview": "预览",
"previewTitle": "预览要导入的分组",
"import": "导入",
"importing": "导入中...",
"invalidFormat": "无效的 JSON 格式。JSON 必须包含 'groups' 数组。",
"missingName": "每个分组必须有 'name' 字段。",
"parseError": "解析 JSON 失败。请检查格式后重试。",
"addFailed": "添加分组失败",
"importFailed": "导入分组失败",
"partialSuccess": "成功导入 {{count}} / {{total}} 个分组。部分分组失败:"
},
"users": { "users": {
"add": "添加", "add": "添加",
"addNew": "添加新用户", "addNew": "添加新用户",

View File

@@ -1,5 +1,11 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { ApiResponse } from '../types/index.js'; import {
ApiResponse,
AddGroupRequest,
BatchCreateGroupsRequest,
BatchCreateGroupsResponse,
BatchGroupResult,
} from '../types/index.js';
import { import {
getAllGroups, getAllGroups,
getGroupByIdOrName, getGroupByIdOrName,
@@ -106,6 +112,143 @@ export const createNewGroup = async (req: Request, res: Response): Promise<void>
} }
}; };
// Batch create groups - validates and creates multiple groups in one request
export const batchCreateGroups = async (req: Request, res: Response): Promise<void> => {
try {
const { groups } = req.body as BatchCreateGroupsRequest;
// Validate request body
if (!groups || !Array.isArray(groups)) {
res.status(400).json({
success: false,
message: 'Request body must contain a "groups" array',
});
return;
}
if (groups.length === 0) {
res.status(400).json({
success: false,
message: 'Groups array cannot be empty',
});
return;
}
// Helper function to validate a single group configuration
const validateGroupConfig = (group: AddGroupRequest): { valid: boolean; message?: string } => {
if (!group.name || typeof group.name !== 'string') {
return { valid: false, message: 'Group name is required and must be a string' };
}
if (group.description !== undefined && typeof group.description !== 'string') {
return { valid: false, message: 'Group description must be a string' };
}
if (group.servers !== undefined && !Array.isArray(group.servers)) {
return { valid: false, message: 'Group servers must be an array' };
}
// Validate server configurations if provided in new format
if (group.servers) {
for (const server of group.servers) {
if (typeof server === 'object' && server !== null) {
if (!server.name || typeof server.name !== 'string') {
return {
valid: false,
message: 'Server configuration must have a name property',
};
}
if (
server.tools !== undefined &&
server.tools !== 'all' &&
!Array.isArray(server.tools)
) {
return {
valid: false,
message: 'Server tools must be "all" or an array of tool names',
};
}
}
}
}
return { valid: true };
};
// Process each group
const results: BatchGroupResult[] = [];
let successCount = 0;
let failureCount = 0;
// Get current user for owner field
const currentUser = (req as any).user;
const defaultOwner = currentUser?.username || 'admin';
for (const groupData of groups) {
const { name, description, servers } = groupData;
// Validate group configuration
const validation = validateGroupConfig(groupData);
if (!validation.valid) {
results.push({
name: name || 'unknown',
success: false,
message: validation.message,
});
failureCount++;
continue;
}
try {
const serverList = Array.isArray(servers) ? servers : [];
const newGroup = await createGroup(name, description, serverList, defaultOwner);
if (newGroup) {
results.push({
name,
success: true,
message: 'Group created successfully',
});
successCount++;
} else {
results.push({
name,
success: false,
message: 'Failed to create group or group name already exists',
});
failureCount++;
}
} catch (error) {
results.push({
name,
success: false,
message: error instanceof Error ? error.message : 'Failed to create group',
});
failureCount++;
}
}
// Return response
const response: BatchCreateGroupsResponse = {
success: successCount > 0,
successCount,
failureCount,
results,
};
// Use 207 Multi-Status if there were partial failures, 200 if all succeeded
const statusCode = failureCount > 0 && successCount > 0 ? 207 : successCount > 0 ? 200 : 400;
res.status(statusCode).json(response);
} catch (error) {
console.error('Batch create groups error:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Update an existing group // Update an existing group
export const updateExistingGroup = async (req: Request, res: Response): Promise<void> => { export const updateExistingGroup = async (req: Request, res: Response): Promise<void> => {
try { try {

View File

@@ -1,5 +1,13 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { ApiResponse, AddServerRequest, McpSettings } from '../types/index.js'; import {
ApiResponse,
AddServerRequest,
McpSettings,
BatchCreateServersRequest,
BatchCreateServersResponse,
BatchServerResult,
ServerConfig,
} from '../types/index.js';
import { import {
getServersInfo, getServersInfo,
addServer, addServer,
@@ -189,6 +197,177 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
} }
}; };
// Batch create servers - validates and creates multiple servers in one request
export const batchCreateServers = async (req: Request, res: Response): Promise<void> => {
try {
const { servers } = req.body as BatchCreateServersRequest;
// Validate request body
if (!servers || !Array.isArray(servers)) {
res.status(400).json({
success: false,
message: 'Request body must contain a "servers" array',
});
return;
}
if (servers.length === 0) {
res.status(400).json({
success: false,
message: 'Servers array cannot be empty',
});
return;
}
// Helper function to validate a single server configuration
const validateServerConfig = (
name: string,
config: ServerConfig,
): { valid: boolean; message?: string } => {
if (!name || typeof name !== 'string') {
return { valid: false, message: 'Server name is required and must be a string' };
}
if (!config || typeof config !== 'object') {
return { valid: false, message: 'Server configuration is required and must be an object' };
}
if (
!config.url &&
!config.openapi?.url &&
!config.openapi?.schema &&
(!config.command || !config.args)
) {
return {
valid: false,
message:
'Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments',
};
}
// Validate server type if specified
if (config.type && !['stdio', 'sse', 'streamable-http', 'openapi'].includes(config.type)) {
return {
valid: false,
message: 'Server type must be one of: stdio, sse, streamable-http, openapi',
};
}
// Validate URL is provided for sse and streamable-http types
if ((config.type === 'sse' || config.type === 'streamable-http') && !config.url) {
return { valid: false, message: `URL is required for ${config.type} server type` };
}
// Validate OpenAPI specification URL or schema is provided for openapi type
if (config.type === 'openapi' && !config.openapi?.url && !config.openapi?.schema) {
return {
valid: false,
message: 'OpenAPI specification URL or schema is required for openapi server type',
};
}
// Validate headers if provided
if (config.headers && typeof config.headers !== 'object') {
return { valid: false, message: 'Headers must be an object' };
}
// Validate that headers are only used with sse, streamable-http, and openapi types
if (config.headers && config.type === 'stdio') {
return { valid: false, message: 'Headers are not supported for stdio server type' };
}
return { valid: true };
};
// Process each server
const results: BatchServerResult[] = [];
let successCount = 0;
let failureCount = 0;
// Get current user for owner field
const currentUser = (req as any).user;
const defaultOwner = currentUser?.username || 'admin';
for (const server of servers) {
const { name, config } = server;
// Validate server configuration
const validation = validateServerConfig(name, config);
if (!validation.valid) {
results.push({
name: name || 'unknown',
success: false,
message: validation.message,
});
failureCount++;
continue;
}
try {
// Set default keep-alive interval for SSE servers if not specified
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
}
// Set owner property if not provided
if (!config.owner) {
config.owner = defaultOwner;
}
// Attempt to add server
const result = await addServer(name, config);
if (result.success) {
results.push({
name,
success: true,
});
successCount++;
} else {
results.push({
name,
success: false,
message: result.message || 'Failed to add server',
});
failureCount++;
}
} catch (error) {
results.push({
name,
success: false,
message: error instanceof Error ? error.message : 'Internal server error',
});
failureCount++;
}
}
// Notify tool changes if any server was added successfully
if (successCount > 0) {
notifyToolChanged();
}
// Prepare response
const response: ApiResponse<BatchCreateServersResponse> = {
success: successCount > 0, // Success if at least one server was created
data: {
success: successCount > 0,
successCount,
failureCount,
results,
},
};
// Return 207 Multi-Status if there were partial failures, 200 if all succeeded, 400 if all failed
const statusCode = failureCount === 0 ? 200 : successCount === 0 ? 400 : 207;
res.status(statusCode).json(response);
} catch (error) {
console.error('Batch create servers error:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
export const deleteServer = async (req: Request, res: Response): Promise<void> => { export const deleteServer = async (req: Request, res: Response): Promise<void> => {
try { try {
const { name } = req.params; const { name } = req.params;

View File

@@ -6,6 +6,7 @@ import {
getAllSettings, getAllSettings,
getServerConfig, getServerConfig,
createServer, createServer,
batchCreateServers,
updateServer, updateServer,
deleteServer, deleteServer,
toggleServer, toggleServer,
@@ -20,6 +21,7 @@ import {
getGroups, getGroups,
getGroup, getGroup,
createNewGroup, createNewGroup,
batchCreateGroups,
updateExistingGroup, updateExistingGroup,
deleteExistingGroup, deleteExistingGroup,
addServerToExistingGroup, addServerToExistingGroup,
@@ -134,6 +136,7 @@ export const initRoutes = (app: express.Application): void => {
router.get('/servers/:name', getServerConfig); router.get('/servers/:name', getServerConfig);
router.get('/settings', getAllSettings); router.get('/settings', getAllSettings);
router.post('/servers', createServer); router.post('/servers', createServer);
router.post('/servers/batch', batchCreateServers);
router.put('/servers/:name', updateServer); router.put('/servers/:name', updateServer);
router.delete('/servers/:name', deleteServer); router.delete('/servers/:name', deleteServer);
router.post('/servers/:name/toggle', toggleServer); router.post('/servers/:name/toggle', toggleServer);
@@ -148,6 +151,7 @@ export const initRoutes = (app: express.Application): void => {
router.get('/groups', getGroups); router.get('/groups', getGroups);
router.get('/groups/:id', getGroup); router.get('/groups/:id', getGroup);
router.post('/groups', createNewGroup); router.post('/groups', createNewGroup);
router.post('/groups/batch', batchCreateGroups);
router.put('/groups/:id', updateExistingGroup); router.put('/groups/:id', updateExistingGroup);
router.delete('/groups/:id', deleteExistingGroup); router.delete('/groups/:id', deleteExistingGroup);
router.post('/groups/:id/servers', addServerToExistingGroup); router.post('/groups/:id/servers', addServerToExistingGroup);

View File

@@ -420,3 +420,50 @@ export interface AddServerRequest {
name: string; // Name of the server to add name: string; // Name of the server to add
config: ServerConfig; // Configuration details for the server config: ServerConfig; // Configuration details for the server
} }
// Request payload for batch creating servers
export interface BatchCreateServersRequest {
servers: AddServerRequest[]; // Array of servers to create
}
// Result for a single server in batch operation
export interface BatchServerResult {
name: string; // Server name
success: boolean; // Whether the operation succeeded
message?: string; // Error message if failed
}
// Response for batch create servers operation
export interface BatchCreateServersResponse {
success: boolean; // Overall operation success (true if at least one server succeeded)
successCount: number; // Number of servers successfully created
failureCount: number; // Number of servers that failed
results: BatchServerResult[]; // Detailed results for each server
}
// Request payload for adding a new group
export interface AddGroupRequest {
name: string; // Name of the group to add
description?: string; // Optional description of the group
servers?: string[] | IGroupServerConfig[]; // Array of server names or server configurations
}
// Request payload for batch creating groups
export interface BatchCreateGroupsRequest {
groups: AddGroupRequest[]; // Array of groups to create
}
// Result for a single group in batch operation
export interface BatchGroupResult {
name: string; // Group name
success: boolean; // Whether the operation succeeded
message?: string; // Error message if failed
}
// Response for batch create groups operation
export interface BatchCreateGroupsResponse {
success: boolean; // Overall operation success (true if at least one group succeeded)
successCount: number; // Number of groups successfully created
failureCount: number; // Number of groups that failed
results: BatchGroupResult[]; // Detailed results for each group
}