mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
feat: update README files for clarity and consistency; refactor server management logic
This commit is contained in:
@@ -8,7 +8,7 @@ MCPHub is a unified hub server that consolidates multiple MCP (Model Context Pro
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Built-in MCP Servers**: Comes with featured MCP servers like `amap-maps`, `github`, `slack`, and more.
|
- **Built-in featured MCP Servers**: Comes with featured MCP servers like `amap-maps`, `playwright`, `slack`, and more.
|
||||||
- **Centralized Management**: Oversee multiple MCP servers from one convenient hub.
|
- **Centralized Management**: Oversee multiple MCP servers from one convenient hub.
|
||||||
- **Broad Protocol Support**: Works seamlessly with both stdio and SSE MCP protocols.
|
- **Broad Protocol Support**: Works seamlessly with both stdio and SSE MCP protocols.
|
||||||
- **Intuitive Dashboard UI**: Monitor server status and manage servers dynamically via a web interface.
|
- **Intuitive Dashboard UI**: Monitor server status and manage servers dynamically via a web interface.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ MCPHub 是一款统一的中心服务,可以将多个 MCP(Model Context Prot
|
|||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- **内置 MCP 服务**:提供 `amap-maps`、`github`、`slack` 等热门服务。
|
- **内置精选 MCP 服务**:默认安装 `amap-maps`、`playwright`、`slack` 等热门服务,开箱即用。
|
||||||
- **集中管理**:通过单一中心轻松管理多个 MCP 服务。
|
- **集中管理**:通过单一中心轻松管理多个 MCP 服务。
|
||||||
- **协议兼容**:同时支持 stdio 与 SSE MCP 协议,确保无缝对接。
|
- **协议兼容**:同时支持 stdio 与 SSE MCP 协议,确保无缝对接。
|
||||||
- **直观仪表盘**:通过 Web 界面实时监控服务状态,并动态管理服务。
|
- **直观仪表盘**:通过 Web 界面实时监控服务状态,并动态管理服务。
|
||||||
|
|||||||
@@ -1,39 +1,13 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
||||||
import { ApiResponse, AddServerRequest } from '../types/index.js';
|
import { ApiResponse, AddServerRequest } from '../types/index.js';
|
||||||
import {
|
import {
|
||||||
getServersInfo,
|
getServersInfo,
|
||||||
addServer,
|
addServer,
|
||||||
removeServer,
|
removeServer,
|
||||||
createMcpServer,
|
|
||||||
registerAllTools,
|
|
||||||
updateMcpServer,
|
updateMcpServer,
|
||||||
|
recreateMcpServer,
|
||||||
} from '../services/mcpService.js';
|
} from '../services/mcpService.js';
|
||||||
import { loadSettings } from '../config/index.js';
|
import { loadSettings } from '../config/index.js';
|
||||||
import config from '../config/index.js';
|
|
||||||
|
|
||||||
let mcpServerInstance: McpServer;
|
|
||||||
|
|
||||||
export const setMcpServerInstance = (server: McpServer): void => {
|
|
||||||
mcpServerInstance = server;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 重新创建 McpServer 实例
|
|
||||||
export const recreateMcpServerInstance = async (): Promise<McpServer> => {
|
|
||||||
console.log('Re-creating McpServer instance');
|
|
||||||
|
|
||||||
// 创建新的 McpServer 实例
|
|
||||||
const newServer = createMcpServer(config.mcpHubName, config.mcpHubVersion);
|
|
||||||
|
|
||||||
// 重新注册所有工具
|
|
||||||
await registerAllTools(newServer);
|
|
||||||
|
|
||||||
// 更新全局实例
|
|
||||||
mcpServerInstance.close();
|
|
||||||
mcpServerInstance = newServer;
|
|
||||||
console.log('McpServer instance successfully re-created');
|
|
||||||
return mcpServerInstance;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllServers = (_: Request, res: Response): void => {
|
export const getAllServers = (_: Request, res: Response): void => {
|
||||||
try {
|
try {
|
||||||
@@ -70,7 +44,6 @@ export const getAllSettings = (_: Request, res: Response): void => {
|
|||||||
export const createServer = async (req: Request, res: Response): Promise<void> => {
|
export const createServer = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { name, config } = req.body as AddServerRequest;
|
const { name, config } = req.body as AddServerRequest;
|
||||||
|
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name || typeof name !== 'string') {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -95,9 +68,9 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await addServer(mcpServerInstance, name, config);
|
const result = await addServer(name, config);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
recreateMcpServer();
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Server added successfully',
|
message: 'Server added successfully',
|
||||||
@@ -128,12 +101,10 @@ export const deleteServer = async (req: Request, res: Response): Promise<void> =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先删除服务器
|
|
||||||
const result = removeServer(name);
|
const result = removeServer(name);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 重新创建 McpServer 实例
|
recreateMcpServer();
|
||||||
recreateMcpServerInstance();
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Server removed successfully',
|
message: 'Server removed successfully',
|
||||||
@@ -181,10 +152,9 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateMcpServer(mcpServerInstance, name, config);
|
const result = await updateMcpServer(name, config);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
recreateMcpServerInstance();
|
recreateMcpServer();
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Server updated successfully',
|
message: 'Server updated successfully',
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import {
|
import {
|
||||||
getAllServers,
|
getAllServers,
|
||||||
getAllSettings,
|
getAllSettings,
|
||||||
createServer,
|
createServer,
|
||||||
updateServer,
|
updateServer,
|
||||||
deleteServer,
|
deleteServer,
|
||||||
setMcpServerInstance
|
|
||||||
} from '../controllers/serverController.js';
|
} from '../controllers/serverController.js';
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
export const initRoutes = (app: express.Application, server: McpServer): void => {
|
export const initRoutes = (app: express.Application): void => {
|
||||||
setMcpServerInstance(server);
|
|
||||||
|
|
||||||
router.get('/servers', getAllServers);
|
router.get('/servers', getAllServers);
|
||||||
router.get('/settings', getAllSettings);
|
router.get('/settings', getAllSettings);
|
||||||
router.post('/servers', createServer);
|
router.post('/servers', createServer);
|
||||||
@@ -23,4 +19,4 @@ export const initRoutes = (app: express.Application, server: McpServer): void =>
|
|||||||
app.use('/api', router);
|
app.use('/api', router);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import config from './config/index.js';
|
import config from './config/index.js';
|
||||||
import { createMcpServer, registerAllTools } from './services/mcpService.js';
|
import { initMcpServer, registerAllTools } from './services/mcpService.js';
|
||||||
import { initMiddlewares } from './middlewares/index.js';
|
import { initMiddlewares } from './middlewares/index.js';
|
||||||
import { initRoutes } from './routes/index.js';
|
import { initRoutes } from './routes/index.js';
|
||||||
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
|
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
||||||
|
|
||||||
export class AppServer {
|
export class AppServer {
|
||||||
private app: express.Application;
|
private app: express.Application;
|
||||||
private mcpServer: McpServer;
|
|
||||||
private port: number | string;
|
private port: number | string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.app = express();
|
this.app = express();
|
||||||
this.port = config.port;
|
this.port = config.port;
|
||||||
this.mcpServer = createMcpServer(config.mcpHubName, config.mcpHubVersion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await registerAllTools(this.mcpServer);
|
const mcpServer = await initMcpServer(config.mcpHubName, config.mcpHubVersion);
|
||||||
|
await registerAllTools(mcpServer, true);
|
||||||
initMiddlewares(this.app);
|
initMiddlewares(this.app);
|
||||||
initRoutes(this.app, this.mcpServer);
|
initRoutes(this.app);
|
||||||
this.app.get('/sse', (req, res) => handleSseConnection(req, res, this.mcpServer));
|
this.app.get('/sse', (req, res) => handleSseConnection(req, res));
|
||||||
this.app.post('/messages', handleSseMessage);
|
this.app.post('/messages', handleSseMessage);
|
||||||
console.log('Server initialized successfully');
|
console.log('Server initialized successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -40,10 +38,6 @@ export class AppServer {
|
|||||||
getApp(): express.Application {
|
getApp(): express.Application {
|
||||||
return this.app;
|
return this.app;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMcpServer(): McpServer {
|
|
||||||
return this.mcpServer;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AppServer;
|
export default AppServer;
|
||||||
|
|||||||
@@ -8,6 +8,32 @@ import { ZodType, ZodRawShape } from 'zod';
|
|||||||
import { ServerInfo, ServerConfig } from '../types/index.js';
|
import { ServerInfo, ServerConfig } from '../types/index.js';
|
||||||
import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
|
import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
|
import config from '../config/index.js';
|
||||||
|
|
||||||
|
let mcpServer: McpServer;
|
||||||
|
|
||||||
|
export const initMcpServer = (name: string, version: string): McpServer => {
|
||||||
|
mcpServer = new McpServer({ name, version });
|
||||||
|
return mcpServer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setMcpServer = (server: McpServer): void => {
|
||||||
|
mcpServer = server;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMcpServer = (): McpServer => {
|
||||||
|
return mcpServer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recreateMcpServer = async () => {
|
||||||
|
console.log('Re-creating McpServer instance');
|
||||||
|
const newServer = createMcpServer(config.mcpHubName, config.mcpHubVersion);
|
||||||
|
await registerAllTools(newServer, true);
|
||||||
|
let oldServer = getMcpServer();
|
||||||
|
setMcpServer(newServer);
|
||||||
|
oldServer.close();
|
||||||
|
console.log('McpServer instance successfully re-created');
|
||||||
|
};
|
||||||
|
|
||||||
// Store all server information
|
// Store all server information
|
||||||
let serverInfos: ServerInfo[] = [];
|
let serverInfos: ServerInfo[] = [];
|
||||||
@@ -86,10 +112,10 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Register all MCP tools
|
// Register all MCP tools
|
||||||
export const registerAllTools = async (server: McpServer): Promise<void> => {
|
export const registerAllTools = async (server: McpServer, forceInit: boolean): Promise<void> => {
|
||||||
initializeClientsFromSettings();
|
initializeClientsFromSettings();
|
||||||
for (const serverInfo of serverInfos) {
|
for (const serverInfo of serverInfos) {
|
||||||
if (serverInfo.status === 'connected') continue;
|
if (serverInfo.status === 'connected' && !forceInit) continue;
|
||||||
if (!serverInfo.client || !serverInfo.transport) continue;
|
if (!serverInfo.client || !serverInfo.transport) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -108,7 +134,6 @@ export const registerAllTools = async (server: McpServer): Promise<void> => {
|
|||||||
|
|
||||||
for (const tool of tools.tools) {
|
for (const tool of tools.tools) {
|
||||||
console.log(`Registering tool: ${JSON.stringify(tool)}`);
|
console.log(`Registering tool: ${JSON.stringify(tool)}`);
|
||||||
|
|
||||||
await server.tool(
|
await server.tool(
|
||||||
tool.name,
|
tool.name,
|
||||||
tool.description || '',
|
tool.description || '',
|
||||||
@@ -151,25 +176,21 @@ const getServerInfoByName = (name: string): ServerInfo | undefined => {
|
|||||||
|
|
||||||
// Add new server
|
// Add new server
|
||||||
export const addServer = async (
|
export const addServer = async (
|
||||||
mcpServer: McpServer,
|
|
||||||
name: string,
|
name: string,
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
): Promise<{ success: boolean; message?: string }> => {
|
): Promise<{ success: boolean; message?: string }> => {
|
||||||
try {
|
try {
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
|
|
||||||
if (settings.mcpServers[name]) {
|
if (settings.mcpServers[name]) {
|
||||||
return { success: false, message: 'Server name already exists' };
|
return { success: false, message: 'Server name already exists' };
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.mcpServers[name] = config;
|
settings.mcpServers[name] = config;
|
||||||
|
|
||||||
if (!saveSettings(settings)) {
|
if (!saveSettings(settings)) {
|
||||||
return { success: false, message: 'Failed to save settings' };
|
return { success: false, message: 'Failed to save settings' };
|
||||||
}
|
}
|
||||||
|
|
||||||
registerAllTools(mcpServer);
|
registerAllTools(mcpServer, false);
|
||||||
|
|
||||||
return { success: true, message: 'Server added successfully' };
|
return { success: true, message: 'Server added successfully' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to add server: ${name}`, error);
|
console.error(`Failed to add server: ${name}`, error);
|
||||||
@@ -178,10 +199,7 @@ export const addServer = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Remove server
|
// Remove server
|
||||||
export const removeServer = (
|
export const removeServer = (name: string): { success: boolean; message?: string } => {
|
||||||
name: string,
|
|
||||||
mcpServer?: McpServer,
|
|
||||||
): { success: boolean; message?: string } => {
|
|
||||||
try {
|
try {
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
|
|
||||||
@@ -195,24 +213,7 @@ export const removeServer = (
|
|||||||
return { success: false, message: 'Failed to save settings' };
|
return { success: false, message: 'Failed to save settings' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close existing connections
|
|
||||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
|
||||||
if (serverInfo && serverInfo.client) {
|
|
||||||
serverInfo.client.close();
|
|
||||||
serverInfo.transport?.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from list
|
|
||||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||||
|
|
||||||
// Re-create and initialize the McpServer if provided
|
|
||||||
if (mcpServer) {
|
|
||||||
console.log(`Re-initializing McpServer after removing ${name}`);
|
|
||||||
registerAllTools(mcpServer).catch((error) => {
|
|
||||||
console.error(`Error re-initializing McpServer after removing ${name}:`, error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, message: 'Server removed successfully' };
|
return { success: true, message: 'Server removed successfully' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to remove server: ${name}`, error);
|
console.error(`Failed to remove server: ${name}`, error);
|
||||||
@@ -222,41 +223,27 @@ export const removeServer = (
|
|||||||
|
|
||||||
// Update existing server
|
// Update existing server
|
||||||
export const updateMcpServer = async (
|
export const updateMcpServer = async (
|
||||||
mcpServer: McpServer,
|
|
||||||
name: string,
|
name: string,
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
): Promise<{ success: boolean; message?: string }> => {
|
): Promise<{ success: boolean; message?: string }> => {
|
||||||
try {
|
try {
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
|
|
||||||
if (!settings.mcpServers[name]) {
|
if (!settings.mcpServers[name]) {
|
||||||
return { success: false, message: 'Server not found' };
|
return { success: false, message: 'Server not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update server configuration
|
|
||||||
settings.mcpServers[name] = config;
|
settings.mcpServers[name] = config;
|
||||||
|
|
||||||
if (!saveSettings(settings)) {
|
if (!saveSettings(settings)) {
|
||||||
return { success: false, message: 'Failed to save settings' };
|
return { success: false, message: 'Failed to save settings' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close existing connections if any
|
|
||||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||||
if (serverInfo && serverInfo.client) {
|
if (serverInfo && serverInfo.client) {
|
||||||
serverInfo.transport?.close();
|
|
||||||
// serverInfo.transport = undefined;
|
|
||||||
serverInfo.client.close();
|
|
||||||
// serverInfo.client = undefined;
|
|
||||||
console.log(`Closed existing connection for server: ${name}`);
|
|
||||||
|
|
||||||
// kill process
|
// kill process
|
||||||
// await killProcess(serverInfo);
|
// await killProcess(serverInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from list
|
|
||||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||||
console.log(`Server Infos after removing: ${JSON.stringify(serverInfos)}`);
|
|
||||||
|
|
||||||
return { success: true, message: 'Server updated successfully' };
|
return { success: true, message: 'Server updated successfully' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to update server: ${name}`, error);
|
console.error(`Failed to update server: ${name}`, error);
|
||||||
@@ -291,8 +278,6 @@ export const createMcpServer = (name: string, version: string): McpServer => {
|
|||||||
return new McpServer({ name, version });
|
return new McpServer({ name, version });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optimized comments to focus on key details and removed redundant explanations
|
|
||||||
|
|
||||||
// Helper function: Convert JSON Schema to Zod Schema
|
// Helper function: Convert JSON Schema to Zod Schema
|
||||||
function cast(inputSchema: unknown): ZodRawShape {
|
function cast(inputSchema: unknown): ZodRawShape {
|
||||||
if (typeof inputSchema !== 'object' || inputSchema === null) {
|
if (typeof inputSchema !== 'object' || inputSchema === null) {
|
||||||
|
|||||||
@@ -1,33 +1,26 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import { getMcpServer } from './mcpService.js';
|
||||||
|
|
||||||
const transports: { [sessionId: string]: SSEServerTransport } = {};
|
const transports: { [sessionId: string]: SSEServerTransport } = {};
|
||||||
|
|
||||||
export const handleSseConnection = async (
|
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
server: McpServer
|
|
||||||
): Promise<void> => {
|
|
||||||
const transport = new SSEServerTransport('/messages', res);
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
transports[transport.sessionId] = transport;
|
transports[transport.sessionId] = transport;
|
||||||
|
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
delete transports[transport.sessionId];
|
delete transports[transport.sessionId];
|
||||||
console.log(`SSE connection closed: ${transport.sessionId}`);
|
console.log(`SSE connection closed: ${transport.sessionId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`New SSE connection established: ${transport.sessionId}`);
|
console.log(`New SSE connection established: ${transport.sessionId}`);
|
||||||
await server.connect(transport);
|
await getMcpServer().connect(transport);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleSseMessage = async (
|
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
|
||||||
req: Request,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> => {
|
|
||||||
const sessionId = req.query.sessionId as string;
|
const sessionId = req.query.sessionId as string;
|
||||||
const transport = transports[sessionId];
|
const transport = transports[sessionId];
|
||||||
|
|
||||||
if (transport) {
|
if (transport) {
|
||||||
await transport.handlePostMessage(req, res);
|
await transport.handlePostMessage(req, res);
|
||||||
} else {
|
} else {
|
||||||
@@ -38,4 +31,4 @@ export const handleSseMessage = async (
|
|||||||
|
|
||||||
export const getConnectionCount = (): number => {
|
export const getConnectionCount = (): number => {
|
||||||
return Object.keys(transports).length;
|
return Object.keys(transports).length;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user