-
-
+ <>
+
+
setIsExpanded(!isExpanded)}
+ >
+
+
{server.name}
+
+
+
+
+
+
+
+
-
-
+
+ {isExpanded && server.tools && (
+
+
{t('server.tools')}
+
+ {server.tools.map((tool, index) => (
+
+ ))}
+
+
+ )}
onConfirm={handleConfirmDelete}
serverName={server.name}
/>
-
- {isExpanded && server.tools && (
-
-
{t('server.tools')}
-
- {server.tools.map((tool, index) => (
-
- ))}
-
-
- )}
-
+ >
)
}
diff --git a/frontend/src/components/icons/LucideIcons.tsx b/frontend/src/components/icons/LucideIcons.tsx
index 83759df..e086ec9 100644
--- a/frontend/src/components/icons/LucideIcons.tsx
+++ b/frontend/src/components/icons/LucideIcons.tsx
@@ -1,10 +1,14 @@
-import { ChevronDown, ChevronRight } from 'lucide-react'
+import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check } from 'lucide-react'
-export { ChevronDown, ChevronRight }
+export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check }
const LucideIcons = {
ChevronDown,
ChevronRight,
+ Edit,
+ Trash,
+ Copy,
+ Check
}
export default LucideIcons
\ No newline at end of file
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
index 1d227c6..61e161d 100644
--- a/frontend/src/components/layout/Sidebar.tsx
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -37,6 +37,15 @@ const Sidebar: React.FC
= ({ collapsed }) => {
),
},
+ {
+ path: '/groups',
+ label: t('nav.groups'),
+ icon: (
+
+ ),
+ },
{
path: '/settings',
label: t('nav.settings'),
diff --git a/frontend/src/components/ui/DeleteDialog.tsx b/frontend/src/components/ui/DeleteDialog.tsx
index ca20a01..8e3177e 100644
--- a/frontend/src/components/ui/DeleteDialog.tsx
+++ b/frontend/src/components/ui/DeleteDialog.tsx
@@ -5,33 +5,40 @@ interface DeleteDialogProps {
onClose: () => void
onConfirm: () => void
serverName: string
+ isGroup?: boolean
}
-const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName }: DeleteDialogProps) => {
+const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false }: DeleteDialogProps) => {
const { t } = useTranslation()
-
+
if (!isOpen) return null
return (
-
-
-
{t('server.delete')}
-
- {t('server.confirmDelete')} {serverName}
-
-
-
-
+
+
+
+
+ {isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
+
+
+ {isGroup
+ ? t('groups.deleteWarning', { name: serverName })
+ : t('server.deleteWarning', { name: serverName })}
+
+
+
+
+
diff --git a/frontend/src/components/ui/ToggleGroup.tsx b/frontend/src/components/ui/ToggleGroup.tsx
new file mode 100644
index 0000000..2982286
--- /dev/null
+++ b/frontend/src/components/ui/ToggleGroup.tsx
@@ -0,0 +1,100 @@
+import React, { ReactNode } from 'react';
+import { cn } from '@/utils/cn';
+
+interface ToggleGroupItemProps {
+ value: string;
+ isSelected: boolean;
+ onClick: () => void;
+ children: ReactNode;
+}
+
+export const ToggleGroupItem: React.FC
= ({
+ value,
+ isSelected,
+ onClick,
+ children
+}) => {
+ return (
+
+ );
+};
+
+interface ToggleGroupProps {
+ label: string;
+ helpText?: string;
+ noOptionsText?: string;
+ values: string[];
+ options: { value: string; label: string }[];
+ onChange: (values: string[]) => void;
+ className?: string;
+}
+
+export const ToggleGroup: React.FC = ({
+ label,
+ helpText,
+ noOptionsText = "No options available",
+ values,
+ options,
+ onChange,
+ className
+}) => {
+ const handleToggle = (value: string) => {
+ const isSelected = values.includes(value);
+ if (isSelected) {
+ onChange(values.filter(v => v !== value));
+ } else {
+ onChange([...values, value]);
+ }
+ };
+
+ return (
+
+
+
+ {options.length === 0 ? (
+
{noOptionsText}
+ ) : (
+
+ {options.map(option => (
+ handleToggle(option.value)}
+ >
+ {option.label}
+
+ ))}
+
+ )}
+
+ {helpText && (
+
+ {helpText}
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/hooks/useGroupData.ts b/frontend/src/hooks/useGroupData.ts
new file mode 100644
index 0000000..4f28286
--- /dev/null
+++ b/frontend/src/hooks/useGroupData.ts
@@ -0,0 +1,232 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Group, ApiResponse } from '@/types';
+
+export const useGroupData = () => {
+ const { t } = useTranslation();
+ const [groups, setGroups] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ const fetchGroups = useCallback(async () => {
+ try {
+ setLoading(true);
+ const token = localStorage.getItem('mcphub_token');
+ const response = await fetch('/api/groups', {
+ headers: {
+ 'x-auth-token': token || ''
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Status: ${response.status}`);
+ }
+
+ const data: ApiResponse = await response.json();
+
+ if (data && data.success && Array.isArray(data.data)) {
+ setGroups(data.data);
+ } else {
+ console.error('Invalid group data format:', data);
+ setGroups([]);
+ }
+
+ setError(null);
+ } catch (err) {
+ console.error('Error fetching groups:', err);
+ setError(err instanceof Error ? err.message : 'Failed to fetch groups');
+ setGroups([]);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // Trigger a refresh of the groups data
+ const triggerRefresh = useCallback(() => {
+ setRefreshKey(prev => prev + 1);
+ }, []);
+
+ // Create a new group with server associations
+ const createGroup = async (name: string, description?: string, servers: string[] = []) => {
+ try {
+ const token = localStorage.getItem('mcphub_token');
+ const response = await fetch('/api/groups', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-auth-token': token || ''
+ },
+ body: JSON.stringify({ name, description, servers }),
+ });
+
+ const result: ApiResponse = await response.json();
+
+ if (!response.ok) {
+ setError(result.message || t('groups.createError'));
+ return null;
+ }
+
+ triggerRefresh();
+ return result.data || null;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to create group');
+ return null;
+ }
+ };
+
+ // Update an existing group with server associations
+ const updateGroup = async (id: string, data: { name?: string; description?: string; servers?: string[] }) => {
+ try {
+ const token = localStorage.getItem('mcphub_token');
+ const response = await fetch(`/api/groups/${id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-auth-token': token || ''
+ },
+ body: JSON.stringify(data),
+ });
+
+ const result: ApiResponse = await response.json();
+
+ if (!response.ok) {
+ setError(result.message || t('groups.updateError'));
+ return null;
+ }
+
+ triggerRefresh();
+ return result.data || null;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to update group');
+ return null;
+ }
+ };
+
+ // Update servers in a group (for batch updates)
+ const updateGroupServers = async (groupId: string, servers: string[]) => {
+ try {
+ const token = localStorage.getItem('mcphub_token');
+ const response = await fetch(`/api/groups/${groupId}/servers/batch`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-auth-token': token || ''
+ },
+ body: JSON.stringify({ servers }),
+ });
+
+ const result: ApiResponse = await response.json();
+
+ if (!response.ok) {
+ setError(result.message || t('groups.updateError'));
+ return null;
+ }
+
+ triggerRefresh();
+ return result.data || null;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to update group servers');
+ return null;
+ }
+ };
+
+ // Delete a group
+ const deleteGroup = async (id: string) => {
+ try {
+ const token = localStorage.getItem('mcphub_token');
+ const response = await fetch(`/api/groups/${id}`, {
+ method: 'DELETE',
+ headers: {
+ 'x-auth-token': token || ''
+ }
+ });
+
+ const result = await response.json();
+
+ if (!response.ok) {
+ setError(result.message || t('groups.deleteError'));
+ return false;
+ }
+
+ triggerRefresh();
+ return true;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to delete group');
+ return false;
+ }
+ };
+
+ // Add server to a group
+ const addServerToGroup = async (groupId: string, serverName: string) => {
+ try {
+ const token = localStorage.getItem('mcphub_token');
+ const response = await fetch(`/api/groups/${groupId}/servers`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-auth-token': token || ''
+ },
+ body: JSON.stringify({ serverName }),
+ });
+
+ const result: ApiResponse = await response.json();
+
+ if (!response.ok) {
+ setError(result.message || t('groups.serverAddError'));
+ return null;
+ }
+
+ triggerRefresh();
+ return result.data || null;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to add server to group');
+ return null;
+ }
+ };
+
+ // Remove server from group
+ const removeServerFromGroup = async (groupId: string, serverName: string) => {
+ try {
+ const token = localStorage.getItem('mcphub_token');
+ const response = await fetch(`/api/groups/${groupId}/servers/${serverName}`, {
+ method: 'DELETE',
+ headers: {
+ 'x-auth-token': token || ''
+ }
+ });
+
+ const result: ApiResponse = await response.json();
+
+ if (!response.ok) {
+ setError(result.message || t('groups.serverRemoveError'));
+ return null;
+ }
+
+ triggerRefresh();
+ return result.data || null;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to remove server from group');
+ return null;
+ }
+ };
+
+ // Fetch groups when the component mounts or refreshKey changes
+ useEffect(() => {
+ fetchGroups();
+ }, [fetchGroups, refreshKey]);
+
+ return {
+ groups,
+ loading,
+ error,
+ setError,
+ triggerRefresh,
+ createGroup,
+ updateGroup,
+ updateGroupServers,
+ deleteGroup,
+ addServerToGroup,
+ removeServerFromGroup
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index 79e6fe7..45b762c 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -34,6 +34,7 @@
"edit": "Edit",
"delete": "Delete",
"confirmDelete": "Are you sure you want to delete this server?",
+ "deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.",
"status": "Status",
"tools": "Tools",
"name": "Server Name",
@@ -80,11 +81,15 @@
"processing": "Processing...",
"save": "Save",
"cancel": "Cancel",
- "refresh": "Refresh"
+ "refresh": "Refresh",
+ "create": "Create",
+ "submitting": "Submitting...",
+ "delete": "Delete"
},
"nav": {
"dashboard": "Dashboard",
"servers": "Servers",
+ "groups": "Groups",
"settings": "Settings",
"changePassword": "Change Password"
},
@@ -100,9 +105,38 @@
"servers": {
"title": "Servers Management"
},
+ "groups": {
+ "title": "Group Management"
+ },
"settings": {
"title": "Settings",
"language": "Language"
}
+ },
+ "groups": {
+ "add": "Add",
+ "addNew": "Add New Group",
+ "edit": "Edit Group",
+ "delete": "Delete",
+ "confirmDelete": "Are you sure you want to delete this group?",
+ "deleteWarning": "Deleting group '{{name}}' will remove it and all its server associations. This action cannot be undone.",
+ "name": "Group Name",
+ "namePlaceholder": "Enter group name",
+ "nameRequired": "Group name is required",
+ "description": "Description",
+ "descriptionPlaceholder": "Enter group description (optional)",
+ "createError": "Failed to create group",
+ "updateError": "Failed to update group",
+ "deleteError": "Failed to delete group",
+ "serverAddError": "Failed to add server to group",
+ "serverRemoveError": "Failed to remove server from group",
+ "addServer": "Add Server to Group",
+ "selectServer": "Select a server to add",
+ "servers": "Servers in Group",
+ "remove": "Remove",
+ "noGroups": "No groups available. Create a new group to get started.",
+ "noServers": "No servers in this group.",
+ "noServerOptions": "No servers available",
+ "serverCount": "{{count}} Servers"
}
}
\ No newline at end of file
diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json
index 2a5e158..2210bce 100644
--- a/frontend/src/locales/zh.json
+++ b/frontend/src/locales/zh.json
@@ -34,6 +34,7 @@
"edit": "编辑",
"delete": "删除",
"confirmDelete": "您确定要删除此服务器吗?",
+ "deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
"status": "状态",
"tools": "工具",
"name": "服务器名称",
@@ -80,13 +81,17 @@
"processing": "处理中...",
"save": "保存",
"cancel": "取消",
- "refresh": "刷新"
+ "refresh": "刷新",
+ "create": "创建",
+ "submitting": "提交中...",
+ "delete": "删除"
},
"nav": {
"dashboard": "仪表盘",
"servers": "服务器",
"settings": "设置",
- "changePassword": "修改密码"
+ "changePassword": "修改密码",
+ "groups": "分组"
},
"pages": {
"dashboard": {
@@ -103,6 +108,35 @@
"settings": {
"title": "设置",
"language": "语言"
+ },
+ "groups": {
+ "title": "分组管理"
}
+ },
+ "groups": {
+ "add": "添加",
+ "addNew": "添加新分组",
+ "edit": "编辑分组",
+ "delete": "删除",
+ "confirmDelete": "您确定要删除此分组吗?",
+ "deleteWarning": "删除分组 '{{name}}' 将会移除该分组及其所有服务器关联。此操作无法撤销。",
+ "name": "分组名称",
+ "namePlaceholder": "请输入分组名称",
+ "nameRequired": "分组名称不能为空",
+ "description": "描述",
+ "descriptionPlaceholder": "请输入分组描述(可选)",
+ "createError": "创建分组失败",
+ "updateError": "更新分组失败",
+ "deleteError": "删除分组失败",
+ "serverAddError": "向分组添加服务器失败",
+ "serverRemoveError": "从分组移除服务器失败",
+ "addServer": "添加服务器到分组",
+ "selectServer": "选择要添加的服务器",
+ "servers": "分组中的服务器",
+ "remove": "移除",
+ "noGroups": "暂无可用分组。创建一个新分组以开始使用。",
+ "noServers": "此分组中没有服务器。",
+ "noServerOptions": "没有可用的服务器",
+ "serverCount": "{{count}} 台服务器"
}
}
\ No newline at end of file
diff --git a/frontend/src/pages/GroupsPage.tsx b/frontend/src/pages/GroupsPage.tsx
new file mode 100644
index 0000000..40d4513
--- /dev/null
+++ b/frontend/src/pages/GroupsPage.tsx
@@ -0,0 +1,116 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Group } from '@/types';
+import { useGroupData } from '@/hooks/useGroupData';
+import { useServerData } from '@/hooks/useServerData';
+import AddGroupForm from '@/components/AddGroupForm';
+import EditGroupForm from '@/components/EditGroupForm';
+import GroupCard from '@/components/GroupCard';
+
+const GroupsPage: React.FC = () => {
+ const { t } = useTranslation();
+ const {
+ groups,
+ loading: groupsLoading,
+ error: groupError,
+ setError: setGroupError,
+ deleteGroup,
+ triggerRefresh
+ } = useGroupData();
+ const { servers } = useServerData();
+
+ const [editingGroup, setEditingGroup] = useState(null);
+ const [showAddForm, setShowAddForm] = useState(false);
+
+ const handleEditClick = (group: Group) => {
+ setEditingGroup(group);
+ };
+
+ const handleEditComplete = () => {
+ setEditingGroup(null);
+ triggerRefresh(); // Refresh the groups list after editing
+ };
+
+ const handleDeleteGroup = async (groupId: string) => {
+ const success = await deleteGroup(groupId);
+ if (!success) {
+ setGroupError(t('groups.deleteError'));
+ }
+ };
+
+ const handleAddGroup = () => {
+ setShowAddForm(true);
+ };
+
+ const handleAddComplete = () => {
+ setShowAddForm(false);
+ triggerRefresh(); // Refresh the groups list after adding
+ };
+
+ return (
+
+
+
{t('pages.groups.title')}
+
+
+
+
+
+ {groupError && (
+
+ )}
+
+ {groupsLoading ? (
+
+
+
+
{t('app.loading')}
+
+
+ ) : groups.length === 0 ? (
+
+
{t('groups.noGroups')}
+
+ ) : (
+
+ {groups.map((group) => (
+
+ ))}
+
+ )}
+
+ {showAddForm && (
+
+ )}
+
+ {editingGroup && (
+
setEditingGroup(null)}
+ />
+ )}
+
+ );
+};
+
+export default GroupsPage;
\ No newline at end of file
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index c5129fd..43951a6 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1,30 +1,30 @@
-// 服务器状态类型
+// Server status types
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
-// 工具输入模式类型
+// Tool input schema types
export interface ToolInputSchema {
type: string;
- properties?: Record;
+ properties?: Record;
required?: string[];
- [key: string]: any;
}
-// 工具类型
+// Tool types
export interface Tool {
name: string;
- description?: string;
+ description: string;
inputSchema: ToolInputSchema;
}
-// 服务器配置类型
+// Server config types
export interface ServerConfig {
url?: string;
command?: string;
- args?: string[] | string;
+ args?: string[];
env?: Record;
+ enabled?: boolean;
}
-// 服务器类型
+// Server types
export interface Server {
name: string;
status: ServerStatus;
@@ -33,26 +33,41 @@ export interface Server {
enabled?: boolean;
}
-// 环境变量类型
+// Group types
+export interface Group {
+ id: string;
+ name: string;
+ description?: string;
+ servers: string[];
+}
+
+// Environment variable types
export interface EnvVar {
key: string;
value: string;
}
-// 表单数据类型
+// Form data types
export interface ServerFormData {
name: string;
url: string;
command: string;
arguments: string;
- args: string[];
+ env: EnvVar[];
}
-// API响应类型
-export interface ApiResponse {
+// Group form data types
+export interface GroupFormData {
+ name: string;
+ description: string;
+ servers: string[]; // Added servers array to include in form data
+}
+
+// API response types
+export interface ApiResponse {
success: boolean;
- data: T;
message?: string;
+ data?: T;
}
// Auth types
@@ -62,10 +77,9 @@ export interface IUser {
}
export interface AuthState {
- token: string | null;
isAuthenticated: boolean;
- loading: boolean;
user: IUser | null;
+ loading: boolean;
error: string | null;
}
@@ -88,5 +102,4 @@ export interface AuthResponse {
token?: string;
user?: IUser;
message?: string;
- errors?: Array<{ msg: string }>;
}
\ No newline at end of file
diff --git a/frontend/src/utils/cn.ts b/frontend/src/utils/cn.ts
index e69de29..dcb15b8 100644
--- a/frontend/src/utils/cn.ts
+++ b/frontend/src/utils/cn.ts
@@ -0,0 +1,10 @@
+import { ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+/**
+ * Combines multiple class names and deduplicates Tailwind CSS classes
+ * This is a utility function for conditionally joining class names together
+ */
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index fcf9799..bc7be8c 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"@tailwindcss/vite": "^4.1.3",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
+ "@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.21",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
@@ -50,6 +51,7 @@
"tailwind-merge": "^3.1.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss": "^4.0.17",
+ "uuid": "^11.1.0",
"zod": "^3.24.2"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f9c3a78..241923e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,6 +29,9 @@ importers:
'@types/react-dom':
specifier: ^19.0.4
version: 19.0.4(@types/react@19.0.12)
+ '@types/uuid':
+ specifier: ^10.0.0
+ version: 10.0.0
autoprefixer:
specifier: ^10.4.21
version: 10.4.21(postcss@8.5.3)
@@ -89,6 +92,9 @@ importers:
tailwindcss:
specifier: ^4.0.17
version: 4.0.17
+ uuid:
+ specifier: ^11.1.0
+ version: 11.1.0
zod:
specifier: ^3.24.2
version: 3.24.2
@@ -1379,6 +1385,9 @@ packages:
'@types/strip-json-comments@0.0.30':
resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==}
+ '@types/uuid@10.0.0':
+ resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
+
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@@ -3473,6 +3482,10 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
+ uuid@11.1.0:
+ resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
+ hasBin: true
+
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -4708,6 +4721,8 @@ snapshots:
'@types/strip-json-comments@0.0.30': {}
+ '@types/uuid@10.0.0': {}
+
'@types/yargs-parser@21.0.3': {}
'@types/yargs@17.0.33':
@@ -7120,6 +7135,8 @@ snapshots:
utils-merge@1.0.1: {}
+ uuid@11.1.0: {}
+
v8-compile-cache-lib@3.0.1: {}
v8-to-istanbul@9.3.0:
diff --git a/src/controllers/groupController.ts b/src/controllers/groupController.ts
new file mode 100644
index 0000000..1888f9f
--- /dev/null
+++ b/src/controllers/groupController.ts
@@ -0,0 +1,341 @@
+import { Request, Response } from 'express';
+import { ApiResponse } from '../types/index.js';
+import {
+ getAllGroups,
+ getGroupById,
+ createGroup,
+ updateGroup,
+ updateGroupServers,
+ deleteGroup,
+ addServerToGroup,
+ removeServerFromGroup,
+ getServersInGroup
+} from '../services/groupService.js';
+
+// Get all groups
+export const getGroups = (_: Request, res: Response): void => {
+ try {
+ const groups = getAllGroups();
+ const response: ApiResponse = {
+ success: true,
+ data: groups,
+ };
+ res.json(response);
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: 'Failed to get groups information',
+ });
+ }
+};
+
+// Get a specific group by ID
+export const getGroup = (req: Request, res: Response): void => {
+ try {
+ const { id } = req.params;
+ if (!id) {
+ res.status(400).json({
+ success: false,
+ message: 'Group ID is required',
+ });
+ return;
+ }
+
+ const group = getGroupById(id);
+ if (!group) {
+ res.status(404).json({
+ success: false,
+ message: 'Group not found',
+ });
+ return;
+ }
+
+ const response: ApiResponse = {
+ success: true,
+ data: group,
+ };
+ res.json(response);
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: 'Failed to get group information',
+ });
+ }
+};
+
+// Create a new group
+export const createNewGroup = (req: Request, res: Response): void => {
+ try {
+ const { name, description, servers } = req.body;
+ if (!name) {
+ res.status(400).json({
+ success: false,
+ message: 'Group name is required',
+ });
+ return;
+ }
+
+ const serverList = Array.isArray(servers) ? servers : [];
+ const newGroup = createGroup(name, description, serverList);
+ if (!newGroup) {
+ res.status(400).json({
+ success: false,
+ message: 'Failed to create group or group name already exists',
+ });
+ return;
+ }
+
+ const response: ApiResponse = {
+ success: true,
+ data: newGroup,
+ message: 'Group created successfully',
+ };
+ res.status(201).json(response);
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: 'Internal server error',
+ });
+ }
+};
+
+// Update an existing group
+export const updateExistingGroup = (req: Request, res: Response): void => {
+ try {
+ const { id } = req.params;
+ const { name, description, servers } = req.body;
+ if (!id) {
+ res.status(400).json({
+ success: false,
+ message: 'Group ID is required',
+ });
+ return;
+ }
+
+ // Allow updating servers along with other fields
+ const updateData: any = {};
+ if (name !== undefined) updateData.name = name;
+ if (description !== undefined) updateData.description = description;
+ if (servers !== undefined) updateData.servers = servers;
+
+ if (Object.keys(updateData).length === 0) {
+ res.status(400).json({
+ success: false,
+ message: 'At least one field (name, description, or servers) is required to update',
+ });
+ return;
+ }
+
+ const updatedGroup = updateGroup(id, updateData);
+ if (!updatedGroup) {
+ res.status(404).json({
+ success: false,
+ message: 'Group not found or name already exists',
+ });
+ return;
+ }
+
+ const response: ApiResponse = {
+ success: true,
+ data: updatedGroup,
+ message: 'Group updated successfully',
+ };
+ res.json(response);
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: 'Internal server error',
+ });
+ }
+};
+
+// Update servers in a group (batch update)
+export const updateGroupServersBatch = (req: Request, res: Response): void => {
+ try {
+ const { id } = req.params;
+ const { servers } = req.body;
+
+ if (!id) {
+ res.status(400).json({
+ success: false,
+ message: 'Group ID is required',
+ });
+ return;
+ }
+
+ if (!Array.isArray(servers)) {
+ res.status(400).json({
+ success: false,
+ message: 'Servers must be an array of server names',
+ });
+ return;
+ }
+
+ const updatedGroup = updateGroupServers(id, servers);
+ if (!updatedGroup) {
+ res.status(404).json({
+ success: false,
+ message: 'Group not found',
+ });
+ return;
+ }
+
+ const response: ApiResponse = {
+ success: true,
+ data: updatedGroup,
+ message: 'Group servers updated successfully',
+ };
+ res.json(response);
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: 'Internal server error',
+ });
+ }
+};
+
+// Delete a group
+export const deleteExistingGroup = (req: Request, res: Response): void => {
+ try {
+ const { id } = req.params;
+ if (!id) {
+ res.status(400).json({
+ success: false,
+ message: 'Group ID is required',
+ });
+ return;
+ }
+
+ const success = deleteGroup(id);
+ if (!success) {
+ res.status(404).json({
+ success: false,
+ message: 'Group not found or failed to delete',
+ });
+ return;
+ }
+
+ res.json({
+ success: true,
+ message: 'Group deleted successfully',
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: 'Internal server error',
+ });
+ }
+};
+
+// Add server to a group
+export const addServerToExistingGroup = (req: Request, res: Response): void => {
+ try {
+ const { id } = req.params;
+ const { serverName } = req.body;
+ if (!id) {
+ res.status(400).json({
+ success: false,
+ message: 'Group ID is required',
+ });
+ return;
+ }
+
+ if (!serverName) {
+ res.status(400).json({
+ success: false,
+ message: 'Server name is required',
+ });
+ return;
+ }
+
+ const updatedGroup = addServerToGroup(id, serverName);
+ if (!updatedGroup) {
+ res.status(404).json({
+ success: false,
+ message: 'Group or server not found',
+ });
+ return;
+ }
+
+ const response: ApiResponse = {
+ success: true,
+ data: updatedGroup,
+ message: 'Server added to group successfully',
+ };
+ res.json(response);
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: 'Internal server error',
+ });
+ }
+};
+
+// Remove server from a group
+export const removeServerFromExistingGroup = (req: Request, res: Response): void => {
+ try {
+ const { id, serverName } = req.params;
+ if (!id || !serverName) {
+ res.status(400).json({
+ success: false,
+ message: 'Group ID and server name are required',
+ });
+ return;
+ }
+
+ const updatedGroup = removeServerFromGroup(id, serverName);
+ if (!updatedGroup) {
+ res.status(404).json({
+ success: false,
+ message: 'Group not found',
+ });
+ return;
+ }
+
+ const response: ApiResponse = {
+ success: true,
+ data: updatedGroup,
+ message: 'Server removed from group successfully',
+ };
+ res.json(response);
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: 'Internal server error',
+ });
+ }
+};
+
+// Get servers in a group
+export const getGroupServers = (req: Request, res: Response): void => {
+ try {
+ const { id } = req.params;
+ if (!id) {
+ res.status(400).json({
+ success: false,
+ message: 'Group ID is required',
+ });
+ return;
+ }
+
+ const group = getGroupById(id);
+ if (!group) {
+ res.status(404).json({
+ success: false,
+ message: 'Group not found',
+ });
+ return;
+ }
+
+ const response: ApiResponse = {
+ success: true,
+ data: group.servers,
+ };
+ res.json(response);
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: 'Failed to get group servers',
+ });
+ }
+};
\ No newline at end of file
diff --git a/src/controllers/serverController.ts b/src/controllers/serverController.ts
index 552d03c..13dca23 100644
--- a/src/controllers/serverController.ts
+++ b/src/controllers/serverController.ts
@@ -5,7 +5,7 @@ import {
addServer,
removeServer,
updateMcpServer,
- recreateMcpServer,
+ notifyToolChanged,
toggleServerStatus,
} from '../services/mcpService.js';
import { loadSettings } from '../config/index.js';
@@ -71,7 +71,7 @@ export const createServer = async (req: Request, res: Response): Promise =
const result = await addServer(name, config);
if (result.success) {
- recreateMcpServer();
+ notifyToolChanged();
res.json({
success: true,
message: 'Server added successfully',
@@ -93,7 +93,6 @@ export const createServer = async (req: Request, res: Response): Promise =
export const deleteServer = async (req: Request, res: Response): Promise => {
try {
const { name } = req.params;
-
if (!name) {
res.status(400).json({
success: false,
@@ -103,9 +102,8 @@ export const deleteServer = async (req: Request, res: Response): Promise =
}
const result = removeServer(name);
-
if (result.success) {
- recreateMcpServer();
+ notifyToolChanged();
res.json({
success: true,
message: 'Server removed successfully',
@@ -128,7 +126,6 @@ export const updateServer = async (req: Request, res: Response): Promise =
try {
const { name } = req.params;
const { config } = req.body;
-
if (!name) {
res.status(400).json({
success: false,
@@ -155,7 +152,7 @@ export const updateServer = async (req: Request, res: Response): Promise =
const result = await updateMcpServer(name, config);
if (result.success) {
- recreateMcpServer();
+ notifyToolChanged();
res.json({
success: true,
message: 'Server updated successfully',
@@ -178,7 +175,6 @@ export const getServerConfig = (req: Request, res: Response): void => {
try {
const { name } = req.params;
const settings = loadSettings();
-
if (!settings.mcpServers || !settings.mcpServers[name]) {
res.status(404).json({
success: false,
@@ -189,7 +185,6 @@ export const getServerConfig = (req: Request, res: Response): void => {
const serverInfo = getServersInfo().find((s) => s.name === name);
const serverConfig = settings.mcpServers[name];
-
const response: ApiResponse = {
success: true,
data: {
@@ -213,7 +208,6 @@ export const toggleServer = async (req: Request, res: Response): Promise =
try {
const { name } = req.params;
const { enabled } = req.body;
-
if (!name) {
res.status(400).json({
success: false,
@@ -231,9 +225,8 @@ export const toggleServer = async (req: Request, res: Response): Promise =
}
const result = await toggleServerStatus(name, enabled);
-
if (result.success) {
- recreateMcpServer();
+ notifyToolChanged();
res.json({
success: true,
message: result.message || `Server ${enabled ? 'enabled' : 'disabled'} successfully`,
diff --git a/src/routes/index.ts b/src/routes/index.ts
index 5e1c6ec..ade6197 100644
--- a/src/routes/index.ts
+++ b/src/routes/index.ts
@@ -8,6 +8,17 @@ import {
deleteServer,
toggleServer,
} from '../controllers/serverController.js';
+import {
+ getGroups,
+ getGroup,
+ createNewGroup,
+ updateExistingGroup,
+ deleteExistingGroup,
+ addServerToExistingGroup,
+ removeServerFromExistingGroup,
+ getGroupServers,
+ updateGroupServersBatch
+} from '../controllers/groupController.js';
import {
login,
register,
@@ -27,6 +38,18 @@ export const initRoutes = (app: express.Application): void => {
router.delete('/servers/:name', deleteServer);
router.post('/servers/:name/toggle', toggleServer);
+ // Group management routes
+ router.get('/groups', getGroups);
+ router.get('/groups/:id', getGroup);
+ router.post('/groups', createNewGroup);
+ router.put('/groups/:id', updateExistingGroup);
+ router.delete('/groups/:id', deleteExistingGroup);
+ router.post('/groups/:id/servers', addServerToExistingGroup);
+ router.delete('/groups/:id/servers/:serverName', removeServerFromExistingGroup);
+ router.get('/groups/:id/servers', getGroupServers);
+ // New route for batch updating servers in a group
+ router.put('/groups/:id/servers/batch', updateGroupServersBatch);
+
// Auth routes (these will NOT be protected by auth middleware)
app.post('/auth/login', [
check('username', 'Username is required').not().isEmpty(),
diff --git a/src/server.ts b/src/server.ts
index 22eae56..fcbe6c9 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,6 +1,6 @@
import express from 'express';
import config from './config/index.js';
-import { initMcpServer, registerAllTools } from './services/mcpService.js';
+import { initMcpServer } from './services/mcpService.js';
import { initMiddlewares } from './middlewares/index.js';
import { initRoutes } from './routes/index.js';
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
@@ -20,17 +20,24 @@ export class AppServer {
try {
// Migrate user data from users.json to mcp_settings.json if needed
migrateUserData();
-
+
// Initialize default admin user if no users exist
await initializeDefaultUser();
-
- const mcpServer = await initMcpServer(config.mcpHubName, config.mcpHubVersion);
- await registerAllTools(mcpServer, true);
+
initMiddlewares(this.app);
initRoutes(this.app);
- this.app.get('/sse', (req, res) => handleSseConnection(req, res));
- this.app.post('/messages', handleSseMessage);
console.log('Server initialized successfully');
+
+ initMcpServer(config.mcpHubName, config.mcpHubVersion)
+ .then(() => {
+ console.log('MCP server initialized successfully');
+ this.app.get('/sse/:groupId?', (req, res) => handleSseConnection(req, res));
+ this.app.post('/messages', handleSseMessage);
+ })
+ .catch((error) => {
+ console.error('Error initializing MCP server:', error);
+ throw error;
+ });
} catch (error) {
console.error('Error initializing server:', error);
throw error;
diff --git a/src/services/groupService.ts b/src/services/groupService.ts
new file mode 100644
index 0000000..35466a4
--- /dev/null
+++ b/src/services/groupService.ts
@@ -0,0 +1,223 @@
+import { v4 as uuidv4 } from 'uuid';
+import { IGroup, McpSettings } from '../types/index.js';
+import { loadSettings, saveSettings } from '../config/index.js';
+import { notifyToolChanged } from './mcpService.js';
+
+// Get all groups
+export const getAllGroups = (): IGroup[] => {
+ const settings = loadSettings();
+ return settings.groups || [];
+};
+
+// Get group by ID
+export const getGroupById = (id: string): IGroup | undefined => {
+ const groups = getAllGroups();
+ return groups.find((group) => group.id === id);
+};
+
+// Create a new group
+export const createGroup = (
+ name: string,
+ description?: string,
+ servers: string[] = [],
+): IGroup | null => {
+ try {
+ const settings = loadSettings();
+ const groups = settings.groups || [];
+
+ // Check if group with same name already exists
+ if (groups.some((group) => group.name === name)) {
+ return null;
+ }
+
+ // Filter out non-existent servers
+ const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
+
+ const newGroup: IGroup = {
+ id: uuidv4(),
+ name,
+ description,
+ servers: validServers,
+ };
+
+ // Initialize groups array if it doesn't exist
+ if (!settings.groups) {
+ settings.groups = [];
+ }
+
+ settings.groups.push(newGroup);
+
+ if (!saveSettings(settings)) {
+ return null;
+ }
+
+ return newGroup;
+ } catch (error) {
+ console.error('Failed to create group:', error);
+ return null;
+ }
+};
+
+// Update an existing group
+export const updateGroup = (id: string, data: Partial): IGroup | null => {
+ try {
+ const settings = loadSettings();
+ if (!settings.groups) {
+ return null;
+ }
+
+ const groupIndex = settings.groups.findIndex((group) => group.id === id);
+ if (groupIndex === -1) {
+ return null;
+ }
+
+ // Check for name uniqueness if name is being updated
+ if (data.name && settings.groups.some((g) => g.name === data.name && g.id !== id)) {
+ return null;
+ }
+
+ // If servers array is provided, validate server existence
+ if (data.servers) {
+ data.servers = data.servers.filter((serverName) => settings.mcpServers[serverName]);
+ }
+
+ const updatedGroup = {
+ ...settings.groups[groupIndex],
+ ...data,
+ };
+
+ settings.groups[groupIndex] = updatedGroup;
+
+ if (!saveSettings(settings)) {
+ return null;
+ }
+
+ notifyToolChanged();
+ return updatedGroup;
+ } catch (error) {
+ console.error(`Failed to update group ${id}:`, error);
+ return null;
+ }
+};
+
+// Update servers in a group (batch update)
+export const updateGroupServers = (groupId: string, servers: string[]): IGroup | null => {
+ try {
+ const settings = loadSettings();
+ if (!settings.groups) {
+ return null;
+ }
+
+ const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
+ if (groupIndex === -1) {
+ return null;
+ }
+
+ // Filter out non-existent servers
+ const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
+
+ settings.groups[groupIndex].servers = validServers;
+
+ if (!saveSettings(settings)) {
+ return null;
+ }
+
+ notifyToolChanged();
+ return settings.groups[groupIndex];
+ } catch (error) {
+ console.error(`Failed to update servers for group ${groupId}:`, error);
+ return null;
+ }
+};
+
+// Delete a group
+export const deleteGroup = (id: string): boolean => {
+ try {
+ const settings = loadSettings();
+ if (!settings.groups) {
+ return false;
+ }
+
+ const initialLength = settings.groups.length;
+ settings.groups = settings.groups.filter((group) => group.id !== id);
+
+ if (settings.groups.length === initialLength) {
+ return false;
+ }
+
+ return saveSettings(settings);
+ } catch (error) {
+ console.error(`Failed to delete group ${id}:`, error);
+ return false;
+ }
+};
+
+// Add server to group
+export const addServerToGroup = (groupId: string, serverName: string): IGroup | null => {
+ try {
+ const settings = loadSettings();
+ if (!settings.groups) {
+ return null;
+ }
+
+ // Verify server exists
+ if (!settings.mcpServers[serverName]) {
+ return null;
+ }
+
+ const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
+ if (groupIndex === -1) {
+ return null;
+ }
+
+ const group = settings.groups[groupIndex];
+
+ // Add server to group if not already in it
+ if (!group.servers.includes(serverName)) {
+ group.servers.push(serverName);
+
+ if (!saveSettings(settings)) {
+ return null;
+ }
+ }
+
+ notifyToolChanged();
+ return group;
+ } catch (error) {
+ console.error(`Failed to add server ${serverName} to group ${groupId}:`, error);
+ return null;
+ }
+};
+
+// Remove server from group
+export const removeServerFromGroup = (groupId: string, serverName: string): IGroup | null => {
+ try {
+ const settings = loadSettings();
+ if (!settings.groups) {
+ return null;
+ }
+
+ const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
+ if (groupIndex === -1) {
+ return null;
+ }
+
+ const group = settings.groups[groupIndex];
+ group.servers = group.servers.filter((name) => name !== serverName);
+
+ if (!saveSettings(settings)) {
+ return null;
+ }
+
+ return group;
+ } catch (error) {
+ console.error(`Failed to remove server ${serverName} from group ${groupId}:`, error);
+ return null;
+ }
+};
+
+// Get all servers in a group
+export const getServersInGroup = (groupId: string): string[] => {
+ const group = getGroupById(groupId);
+ return group ? group.servers : [];
+};
diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts
index fad4ecb..b626f18 100644
--- a/src/services/mcpService.ts
+++ b/src/services/mcpService.ts
@@ -1,37 +1,34 @@
-import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { Server } from '@modelcontextprotocol/sdk/server/index.js';
+import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
-import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
-import * as z from 'zod';
-import { ZodType, ZodRawShape } from 'zod';
import { ServerInfo, ServerConfig } from '../types/index.js';
import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
import config from '../config/index.js';
+import { get } from 'http';
+import { getGroupId } from './sseService.js';
+import { getServersInGroup } from './groupService.js';
-let mcpServer: McpServer;
+let currentServer: Server;
-export const initMcpServer = (name: string, version: string): McpServer => {
- mcpServer = new McpServer({ name, version });
- return mcpServer;
+export const initMcpServer = async (name: string, version: string): Promise => {
+ currentServer = createMcpServer(name, version);
+ await registerAllTools(currentServer, true);
};
-export const setMcpServer = (server: McpServer): void => {
- mcpServer = server;
+export const setMcpServer = (server: Server): void => {
+ currentServer = server;
};
-export const getMcpServer = (): McpServer => {
- return mcpServer;
+export const getMcpServer = (): Server => {
+ return currentServer;
};
-export const recreateMcpServer = async () => {
- console.log('Re-creating McpServer instance');
- const newServer = createMcpServer(config.mcpHubName, config.mcpHubVersion);
- await registerAllTools(newServer, true);
- const oldServer = getMcpServer();
- setMcpServer(newServer);
- oldServer.close();
- console.log('McpServer instance successfully re-created');
+export const notifyToolChanged = async () => {
+ await registerAllTools(currentServer, true);
+ currentServer.sendToolListChanged();
+ console.log('Tool list changed notification sent');
};
// Store all server information
@@ -52,11 +49,11 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
status: 'disconnected',
tools: [],
createTime: Date.now(),
- enabled: false
+ enabled: false,
});
continue;
}
-
+
// Check if server is already connected
const existingServer = existingServerInfos.find(
(s) => s.name === name && s.status === 'connected',
@@ -64,7 +61,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
if (existingServer) {
serverInfos.push({
...existingServer,
- enabled: conf.enabled === undefined ? true : conf.enabled
+ enabled: conf.enabled === undefined ? true : conf.enabled,
});
console.log(`Server '${name}' is already connected.`);
continue;
@@ -107,7 +104,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
);
client.connect(transport, { timeout: Number(config.timeout) }).catch((error) => {
console.error(`Failed to connect client for server ${name} by error: ${error}`);
- const serverInfo = getServerInfoByName(name);
+ const serverInfo = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
}
@@ -127,7 +124,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
};
// Register all MCP tools
-export const registerAllTools = async (server: McpServer, forceInit: boolean): Promise => {
+export const registerAllTools = async (server: Server, forceInit: boolean): Promise => {
initializeClientsFromSettings();
for (const serverInfo of serverInfos) {
if (serverInfo.status === 'connected' && !forceInit) continue;
@@ -136,35 +133,15 @@ export const registerAllTools = async (server: McpServer, forceInit: boolean): P
try {
serverInfo.status = 'connecting';
console.log(`Connecting to server: ${serverInfo.name}...`);
-
const tools = await serverInfo.client.listTools({}, { timeout: Number(config.timeout) });
serverInfo.tools = tools.tools.map((tool) => ({
name: tool.name,
description: tool.description || '',
- inputSchema: tool.inputSchema.properties || {},
+ inputSchema: tool.inputSchema || {},
}));
serverInfo.status = 'connected';
console.log(`Successfully connected to server: ${serverInfo.name}`);
-
- for (const tool of tools.tools) {
- console.log(`Registering tool: ${JSON.stringify(tool)}`);
- await server.tool(
- tool.name,
- tool.description || '',
- json2zod(tool.inputSchema.properties, tool.inputSchema.required),
- async (params: Record) => {
- const currentServer = getServerInfoByName(serverInfo.name)!;
- console.log(`Calling tool: ${tool.name} with params: ${JSON.stringify(params)}`);
- const result = await currentServer.client!.callTool({
- name: tool.name,
- arguments: params,
- });
- console.log(`Tool call result: ${JSON.stringify(result)}`);
- return result as CallToolResult;
- },
- );
- }
} catch (error) {
console.error(
`Failed to connect to server for client: ${serverInfo.name} by error: ${error}`,
@@ -179,7 +156,7 @@ export const getServersInfo = (): Omit[] =>
const settings = loadSettings();
const infos = serverInfos.map(({ name, status, tools, createTime }) => {
const serverConfig = settings.mcpServers[name];
- const enabled = serverConfig ? (serverConfig.enabled !== false) : true;
+ const enabled = serverConfig ? serverConfig.enabled !== false : true;
return {
name,
status,
@@ -195,11 +172,16 @@ export const getServersInfo = (): Omit[] =>
return infos;
};
-// Get server information by name
-const getServerInfoByName = (name: string): ServerInfo | undefined => {
+// Get server by name
+const getServerByName = (name: string): ServerInfo | undefined => {
return serverInfos.find((serverInfo) => serverInfo.name === name);
};
+// Get server by tool name
+const getServerByTool = (toolName: string): ServerInfo | undefined => {
+ return serverInfos.find((serverInfo) => serverInfo.tools.some((tool) => tool.name === toolName));
+};
+
// Add new server
export const addServer = async (
name: string,
@@ -216,7 +198,7 @@ export const addServer = async (
return { success: false, message: 'Failed to save settings' };
}
- registerAllTools(mcpServer, false);
+ registerAllTools(currentServer, false);
return { success: true, message: 'Server added successfully' };
} catch (error) {
console.error(`Failed to add server: ${name}`, error);
@@ -228,7 +210,6 @@ export const addServer = async (
export const removeServer = (name: string): { success: boolean; message?: string } => {
try {
const settings = loadSettings();
-
if (!settings.mcpServers[name]) {
return { success: false, message: 'Server not found' };
}
@@ -263,13 +244,7 @@ export const updateMcpServer = async (
return { success: false, message: 'Failed to save settings' };
}
- const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
- if (serverInfo) {
- serverInfo.client!.close();
- serverInfo.transport!.close();
- console.log(`Closed client and transport for server: ${name}`);
- // TODO kill process
- }
+ closeServer(name);
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
return { success: true, message: 'Server updated successfully' };
@@ -279,10 +254,21 @@ export const updateMcpServer = async (
}
};
+// Close server client and transport
+function closeServer(name: string) {
+ const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
+ if (serverInfo && serverInfo.client && serverInfo.transport) {
+ serverInfo.client.close();
+ serverInfo.transport.close();
+ console.log(`Closed client and transport for server: ${serverInfo.name}`);
+ // TODO kill process
+ }
+}
+
// Toggle server enabled status
export const toggleServerStatus = async (
name: string,
- enabled: boolean
+ enabled: boolean,
): Promise<{ success: boolean; message?: string }> => {
try {
const settings = loadSettings();
@@ -292,22 +278,17 @@ export const toggleServerStatus = async (
// Update the enabled status in settings
settings.mcpServers[name].enabled = enabled;
-
+
if (!saveSettings(settings)) {
return { success: false, message: 'Failed to save settings' };
}
// If disabling, disconnect the server and remove from active servers
if (!enabled) {
- const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
- if (serverInfo && serverInfo.client && serverInfo.transport) {
- serverInfo.client.close();
- serverInfo.transport.close();
- console.log(`Closed client and transport for server: ${name}`);
- }
-
+ closeServer(name);
+
// Update the server info to show as disconnected and disabled
- const index = serverInfos.findIndex(s => s.name === name);
+ const index = serverInfos.findIndex((s) => s.name === name);
if (index !== -1) {
serverInfos[index] = {
...serverInfos[index],
@@ -325,92 +306,52 @@ export const toggleServerStatus = async (
};
// Create McpServer instance
-export const createMcpServer = (name: string, version: string): McpServer => {
- return new McpServer({ name, version });
+export const createMcpServer = (name: string, version: string): Server => {
+ const server = new Server({ name, version }, { capabilities: { tools: {} } });
+ server.setRequestHandler(ListToolsRequestSchema, async (_, extra) => {
+ const sessionId = extra.sessionId || '';
+ const groupId = getGroupId(sessionId);
+ console.log(`Handling ListToolsRequest for groupId: ${groupId}`);
+ const allServerInfos = serverInfos.filter((serverInfo) => {
+ if (serverInfo.enabled === false) return false;
+ if (!groupId) return true;
+ const serversInGroup = getServersInGroup(groupId);
+ return serversInGroup.includes(serverInfo.name);
+ });
+
+ const allTools = [];
+ for (const serverInfo of allServerInfos) {
+ if (serverInfo.tools && serverInfo.tools.length > 0) {
+ allTools.push(...serverInfo.tools);
+ }
+ }
+
+ return {
+ tools: allTools,
+ };
+ });
+
+ server.setRequestHandler(CallToolRequestSchema, async (request, _) => {
+ console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
+ try {
+ if (!request.params.arguments) {
+ throw new Error('Arguments are required');
+ }
+ const serverInfo = getServerByTool(request.params.name);
+ if (!serverInfo) {
+ throw new Error(`Server not found: ${request.params.name}`);
+ }
+ const client = serverInfo.client;
+ if (!client) {
+ throw new Error(`Client not found for server: ${request.params.name}`);
+ }
+ const result = await client.callTool(request.params);
+ console.log(`Tool call result: ${JSON.stringify(result)}`);
+ return result;
+ } catch (error) {
+ console.error(`Error handling CallToolRequest: ${error}`);
+ return { error: `Failed to call tool: ${error}` };
+ }
+ });
+ return server;
};
-
-// Helper function: Convert JSON Schema to Zod Schema
-function json2zod(inputSchema: unknown, required: unknown): ZodRawShape {
- if (typeof inputSchema !== 'object' || inputSchema === null) {
- throw new Error('Invalid input schema');
- }
-
- const properties = inputSchema as Record;
- const processedSchema: ZodRawShape = {};
-
- for (const key in properties) {
- const prop = properties[key];
-
- if (prop instanceof ZodType) {
- processedSchema[key] = prop;
- continue;
- }
-
- if (typeof prop !== 'object' || prop === null) {
- throw new Error(`Invalid property definition for ${key}`);
- }
-
- let zodType: ZodType;
-
- if (prop.type === 'array' && prop.items) {
- if (prop.items.type === 'string') {
- zodType = z.array(z.string());
- } else if (prop.items.type === 'number') {
- zodType = z.array(z.number());
- } else if (prop.items.type === 'integer') {
- zodType = z.array(z.number().int());
- } else if (prop.items.type === 'boolean') {
- zodType = z.array(z.boolean());
- } else if (prop.items.type === 'object' && prop.items.properties) {
- zodType = z.array(z.object(json2zod(prop.items.properties, prop.items.required)));
- } else {
- zodType = z.array(z.any());
- }
- } else {
- switch (prop.type) {
- case 'string':
- if (prop.enum && Array.isArray(prop.enum)) {
- zodType = z.enum(prop.enum as [string, ...string[]]);
- } else {
- zodType = z.string();
- }
- break;
- case 'number':
- zodType = z.number();
- break;
- case 'boolean':
- zodType = z.boolean();
- break;
- case 'integer':
- zodType = z.number().int();
- break;
- case 'object':
- if (prop.properties) {
- zodType = z.object(json2zod(prop.properties, prop.required));
- } else {
- zodType = z.record(z.any());
- }
- break;
- default:
- zodType = z.any();
- }
- }
-
- if (prop.description) {
- zodType = zodType.describe(prop.description);
- }
-
- if (prop.default !== undefined) {
- zodType = zodType.default(prop.default);
- }
-
- required = Array.isArray(required) ? required : [];
- if (Array.isArray(required) && required.includes(key)) {
- processedSchema[key] = zodType;
- } else {
- processedSchema[key] = zodType.optional();
- }
- }
-
- return processedSchema;
-}
diff --git a/src/services/sseService.ts b/src/services/sseService.ts
index fb10230..067e903 100644
--- a/src/services/sseService.ts
+++ b/src/services/sseService.ts
@@ -2,11 +2,16 @@ import { Request, Response } from 'express';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { getMcpServer } from './mcpService.js';
-const transports: { [sessionId: string]: SSEServerTransport } = {};
+const transports: { [sessionId: string]: { transport: SSEServerTransport; groupId: string } } = {};
+
+export const getGroupId = (sessionId: string): string => {
+ return transports[sessionId]?.groupId || '';
+};
export const handleSseConnection = async (req: Request, res: Response): Promise => {
const transport = new SSEServerTransport('/messages', res);
- transports[transport.sessionId] = transport;
+ const groupId = req.params.groupId;
+ transports[transport.sessionId] = { transport, groupId };
res.on('close', () => {
delete transports[transport.sessionId];
@@ -19,8 +24,10 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
export const handleSseMessage = async (req: Request, res: Response): Promise => {
const sessionId = req.query.sessionId as string;
- const transport = transports[sessionId];
-
+ const { transport, groupId } = transports[sessionId];
+ req.params.groupId = groupId;
+ req.query.groupId = groupId;
+ console.log(`Received message for sessionId: ${sessionId} in groupId: ${groupId}`);
if (transport) {
await transport.handlePostMessage(req, res);
} else {
diff --git a/src/types/index.ts b/src/types/index.ts
index 47ec1c6..113806d 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -9,12 +9,21 @@ export interface IUser {
isAdmin?: boolean;
}
+// Group interface for server grouping
+export interface IGroup {
+ id: string; // Unique UUID for the group
+ name: string; // Display name of the group
+ description?: string; // Optional description of the group
+ servers: string[]; // Array of server names that belong to this group
+}
+
// Represents the settings for MCP servers
export interface McpSettings {
users?: IUser[]; // Array of user credentials and permissions
mcpServers: {
[key: string]: ServerConfig; // Key-value pairs of server names and their configurations
};
+ groups?: IGroup[]; // Array of server groups
}
// Configuration details for an individual server