feat: add group management functionality (#12)

This commit is contained in:
samanhappy
2025-04-17 18:55:04 +08:00
committed by GitHub
parent 6a065ca2f2
commit adef02d3b9
27 changed files with 1953 additions and 446 deletions

View File

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

View File

@@ -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<void> => {
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<void> => {
export const registerAllTools = async (server: Server, forceInit: boolean): Promise<void> => {
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<string, unknown>) => {
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<ServerInfo, 'client' | 'transport'>[] =>
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<ServerInfo, 'client' | 'transport'>[] =>
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<string, any>;
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;
}

View File

@@ -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<void> => {
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<void> => {
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 {