mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
feat: enhance JSON serialization safety & add dxt upload limit (#230)
This commit is contained in:
@@ -3,6 +3,8 @@ import fs from 'fs';
|
||||
import { McpSettings } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -15,6 +17,8 @@ const defaultConfig = {
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
};
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
// Settings cache
|
||||
let settingsCache: McpSettings | null = null;
|
||||
|
||||
@@ -22,7 +26,7 @@ export const getSettingsPath = (): string => {
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings');
|
||||
};
|
||||
|
||||
export const loadSettings = (): McpSettings => {
|
||||
export const loadOriginalSettings = (): McpSettings => {
|
||||
// If cache exists, return cached data directly
|
||||
if (settingsCache) {
|
||||
return settingsCache;
|
||||
@@ -49,13 +53,18 @@ export const loadSettings = (): McpSettings => {
|
||||
}
|
||||
};
|
||||
|
||||
export const loadSettings = (): McpSettings => {
|
||||
return dataService.filterSettings!(loadOriginalSettings());
|
||||
};
|
||||
|
||||
export const saveSettings = (settings: McpSettings): boolean => {
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
|
||||
|
||||
// Update cache after successful save
|
||||
settingsCache = settings;
|
||||
settingsCache = mergedSettings;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Request, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { validationResult } from 'express-validator';
|
||||
import { findUserByUsername, verifyPassword, createUser, updateUserPassword } from '../models/User.js';
|
||||
import {
|
||||
findUserByUsername,
|
||||
verifyPassword,
|
||||
createUser,
|
||||
updateUserPassword,
|
||||
} from '../models/User.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
// Default secret key - in production, use an environment variable
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||
@@ -21,7 +30,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
||||
return;
|
||||
@@ -29,7 +38,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await verifyPassword(password, user.password);
|
||||
|
||||
|
||||
if (!isPasswordValid) {
|
||||
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
||||
return;
|
||||
@@ -39,26 +48,22 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
const payload = {
|
||||
user: {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin || false
|
||||
}
|
||||
isAdmin: user.isAdmin || false,
|
||||
},
|
||||
};
|
||||
|
||||
jwt.sign(
|
||||
payload,
|
||||
JWT_SECRET,
|
||||
{ expiresIn: TOKEN_EXPIRY },
|
||||
(err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin,
|
||||
permissions: dataService.getPermissions(user),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ success: false, message: 'Server error' });
|
||||
@@ -79,7 +84,7 @@ export const register = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Create new user
|
||||
const newUser = await createUser({ username, password, isAdmin });
|
||||
|
||||
|
||||
if (!newUser) {
|
||||
res.status(400).json({ success: false, message: 'User already exists' });
|
||||
return;
|
||||
@@ -89,26 +94,22 @@ export const register = async (req: Request, res: Response): Promise<void> => {
|
||||
const payload = {
|
||||
user: {
|
||||
username: newUser.username,
|
||||
isAdmin: newUser.isAdmin || false
|
||||
}
|
||||
isAdmin: newUser.isAdmin || false,
|
||||
},
|
||||
};
|
||||
|
||||
jwt.sign(
|
||||
payload,
|
||||
JWT_SECRET,
|
||||
{ expiresIn: TOKEN_EXPIRY },
|
||||
(err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
username: newUser.username,
|
||||
isAdmin: newUser.isAdmin
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
username: newUser.username,
|
||||
isAdmin: newUser.isAdmin,
|
||||
permissions: dataService.getPermissions(newUser),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
res.status(500).json({ success: false, message: 'Server error' });
|
||||
@@ -120,13 +121,14 @@ export const getCurrentUser = (req: Request, res: Response): void => {
|
||||
try {
|
||||
// User is already attached to request by auth middleware
|
||||
const user = (req as any).user;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin
|
||||
}
|
||||
isAdmin: user.isAdmin,
|
||||
permissions: dataService.getPermissions(user),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get current user error:', error);
|
||||
@@ -149,7 +151,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
try {
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, message: 'User not found' });
|
||||
return;
|
||||
@@ -157,7 +159,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
|
||||
// Verify current password
|
||||
const isPasswordValid = await verifyPassword(currentPassword, user.password);
|
||||
|
||||
|
||||
if (!isPasswordValid) {
|
||||
res.status(401).json({ success: false, message: 'Current password is incorrect' });
|
||||
return;
|
||||
@@ -165,7 +167,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
|
||||
// Update the password
|
||||
const updated = await updateUserPassword(username, newPassword);
|
||||
|
||||
|
||||
if (!updated) {
|
||||
res.status(500).json({ success: false, message: 'Failed to update password' });
|
||||
return;
|
||||
@@ -176,4 +178,4 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
console.error('Change password error:', error);
|
||||
res.status(500).json({ success: false, message: 'Server error' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,18 +2,13 @@ import { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
|
||||
// Get the directory name in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../data/uploads/dxt');
|
||||
const uploadDir = path.join(process.cwd(), 'data/uploads/dxt');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
@@ -36,7 +31,7 @@ const upload = multer({
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024, // 100MB limit
|
||||
fileSize: 500 * 1024 * 1024, // 500MB limit
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,7 +40,7 @@ export const uploadMiddleware = upload.single('dxtFile');
|
||||
// Clean up old DXT server files when installing a new version
|
||||
const cleanupOldDxtServer = (serverName: string): void => {
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '../../data/uploads/dxt');
|
||||
const uploadDir = path.join(process.cwd(), 'data/uploads/dxt');
|
||||
const serverPattern = `server-${serverName}`;
|
||||
|
||||
if (fs.existsSync(uploadDir)) {
|
||||
|
||||
@@ -75,7 +75,12 @@ export const createNewGroup = (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
const serverList = Array.isArray(servers) ? servers : [];
|
||||
const newGroup = createGroup(name, description, serverList);
|
||||
|
||||
// Set owner property - use current user's username, default to 'admin'
|
||||
const currentUser = (req as any).user;
|
||||
const owner = currentUser?.username || 'admin';
|
||||
|
||||
const newGroup = createGroup(name, description, serverList, owner);
|
||||
if (!newGroup) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
||||
@@ -11,16 +11,18 @@ import {
|
||||
} from '../services/mcpService.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||
import { createSafeJSON } from '../utils/serialization.js';
|
||||
|
||||
export const getAllServers = (_: Request, res: Response): void => {
|
||||
try {
|
||||
const serversInfo = getServersInfo();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: serversInfo,
|
||||
data: createSafeJSON(serversInfo),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to get servers information:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get servers information',
|
||||
@@ -33,7 +35,7 @@ export const getAllSettings = (_: Request, res: Response): void => {
|
||||
const settings = loadSettings();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: settings,
|
||||
data: createSafeJSON(settings),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
@@ -127,6 +129,12 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||
}
|
||||
|
||||
// Set owner property - use current user's username, default to 'admin'
|
||||
if (!config.owner) {
|
||||
const currentUser = (req as any).user;
|
||||
config.owner = currentUser?.username || 'admin';
|
||||
}
|
||||
|
||||
const result = await addServer(name, config);
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
@@ -264,6 +272,12 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||
}
|
||||
|
||||
// Set owner property if not provided - use current user's username, default to 'admin'
|
||||
if (!config.owner) {
|
||||
const currentUser = (req as any).user;
|
||||
config.owner = currentUser?.username || 'admin';
|
||||
}
|
||||
|
||||
const result = await addOrUpdateServer(name, config, true); // Allow override for updates
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
|
||||
263
src/controllers/userController.ts
Normal file
263
src/controllers/userController.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import {
|
||||
getAllUsers,
|
||||
getUserByUsername,
|
||||
createNewUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getUserCount,
|
||||
getAdminCount,
|
||||
} from '../services/userService.js';
|
||||
|
||||
// Admin permission check middleware function
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
const user = (req as any).user;
|
||||
if (!user || !user.isAdmin) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'Admin privileges required',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Get all users (admin only)
|
||||
export const getUsers = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const users = getAllUsers().map(({ password: _, ...user }) => user); // Remove password from response
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: users,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get users information',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get a specific user by username (admin only)
|
||||
export const getUser = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
if (!username) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUserByUsername(username);
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { password: _, ...userData } = user; // Remove password from response
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: userData,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get user information',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new user (admin only)
|
||||
export const createUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const { username, password, isAdmin } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username and password are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newUser = await createNewUser(username, password, isAdmin || false);
|
||||
if (!newUser) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Failed to create user or username already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { password: _, ...userData } = newUser; // Remove password from response
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: userData,
|
||||
message: 'User created successfully',
|
||||
};
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing user (admin only)
|
||||
export const updateExistingUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
const { isAdmin, newPassword } = req.body;
|
||||
|
||||
if (!username) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if trying to change admin status
|
||||
if (isAdmin !== undefined) {
|
||||
const currentUser = getUserByUsername(username);
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent removing admin status from the last admin
|
||||
if (currentUser.isAdmin && !isAdmin && getAdminCount() === 1) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Cannot remove admin status from the last admin user',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (isAdmin !== undefined) updateData.isAdmin = isAdmin;
|
||||
if (newPassword) updateData.newPassword = newPassword;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'At least one field (isAdmin or newPassword) is required to update',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUser = await updateUser(username, updateData);
|
||||
if (!updatedUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'User not found or update failed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { password: _, ...userData } = updatedUser; // Remove password from response
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: userData,
|
||||
message: 'User updated successfully',
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a user (admin only)
|
||||
export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
if (!username) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if trying to delete the current admin user
|
||||
const currentUser = (req as any).user;
|
||||
if (currentUser.username === username) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Cannot delete your own account',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const success = deleteUser(username);
|
||||
if (!success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'User not found, failed to delete, or cannot delete the last admin',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'User deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get user statistics (admin only)
|
||||
export const getUserStats = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const totalUsers = getUserCount();
|
||||
const adminUsers = getAdminCount();
|
||||
const regularUsers = totalUsers - adminUsers;
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
totalUsers,
|
||||
adminUsers,
|
||||
regularUsers,
|
||||
},
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get user statistics',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { auth } from './auth.js';
|
||||
import { userContextMiddleware } from './userContext.js';
|
||||
import { initializeDefaultUser } from '../models/User.js';
|
||||
import config from '../config/index.js';
|
||||
|
||||
@@ -27,7 +28,13 @@ export const initMiddlewares = (app: express.Application): void => {
|
||||
if (
|
||||
req.path !== `${basePath}/sse` &&
|
||||
!req.path.startsWith(`${basePath}/sse/`) &&
|
||||
req.path !== `${basePath}/messages`
|
||||
req.path !== `${basePath}/messages` &&
|
||||
!req.path.match(
|
||||
new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+/messages$`),
|
||||
) &&
|
||||
!req.path.match(
|
||||
new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+/sse(/.*)?$`),
|
||||
)
|
||||
) {
|
||||
express.json()(req, res, next);
|
||||
} else {
|
||||
@@ -46,7 +53,15 @@ export const initMiddlewares = (app: express.Application): void => {
|
||||
if (req.path === '/auth/login' || req.path === '/auth/register') {
|
||||
next();
|
||||
} else {
|
||||
auth(req, res, next);
|
||||
// Apply authentication middleware first
|
||||
auth(req, res, (err) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
} else {
|
||||
// Apply user context middleware after successful authentication
|
||||
userContextMiddleware(req, res, next);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
136
src/middlewares/userContext.ts
Normal file
136
src/middlewares/userContext.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { UserContextService } from '../services/userContextService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* User context middleware
|
||||
* Sets user context after authentication middleware, allowing service layer to access current user information
|
||||
*/
|
||||
export const userContextMiddleware = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const currentUser = (req as any).user as IUser;
|
||||
|
||||
if (currentUser) {
|
||||
// Set user context
|
||||
const userContextService = UserContextService.getInstance();
|
||||
userContextService.setCurrentUser(currentUser);
|
||||
|
||||
// Clean up user context when response ends
|
||||
res.on('finish', () => {
|
||||
const userContextService = UserContextService.getInstance();
|
||||
userContextService.clearCurrentUser();
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error in user context middleware:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* User context middleware for SSE/MCP endpoints
|
||||
* Extracts user from URL path parameter and sets user context
|
||||
*/
|
||||
export const sseUserContextMiddleware = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const username = req.params.user;
|
||||
|
||||
if (username) {
|
||||
// For user-scoped routes, set the user context
|
||||
// Note: In a real implementation, you should validate the user exists
|
||||
// and has proper permissions
|
||||
const user: IUser = {
|
||||
username,
|
||||
password: '',
|
||||
isAdmin: false, // TODO: Should be retrieved from user database
|
||||
};
|
||||
|
||||
userContextService.setCurrentUser(user);
|
||||
|
||||
// Clean up user context when response ends
|
||||
res.on('finish', () => {
|
||||
userContextService.clearCurrentUser();
|
||||
});
|
||||
|
||||
// Also clean up on connection close for SSE
|
||||
res.on('close', () => {
|
||||
userContextService.clearCurrentUser();
|
||||
});
|
||||
|
||||
console.log(`User context set for SSE/MCP endpoint: ${username}`);
|
||||
} else {
|
||||
// For global routes, clear user context (admin access)
|
||||
userContextService.clearCurrentUser();
|
||||
console.log('Global SSE/MCP endpoint access - no user context');
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error in SSE user context middleware:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extended data service that can directly access current user context
|
||||
*/
|
||||
export interface ContextAwareDataService {
|
||||
getCurrentUserFromContext(): Promise<IUser | null>;
|
||||
getUserDataFromContext(dataType: string): Promise<any>;
|
||||
isCurrentUserAdmin(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class ContextAwareDataServiceImpl implements ContextAwareDataService {
|
||||
private getUserContextService() {
|
||||
return UserContextService.getInstance();
|
||||
}
|
||||
|
||||
async getCurrentUserFromContext(): Promise<IUser | null> {
|
||||
const userContextService = this.getUserContextService();
|
||||
return userContextService.getCurrentUser();
|
||||
}
|
||||
|
||||
async getUserDataFromContext(dataType: string): Promise<any> {
|
||||
const userContextService = this.getUserContextService();
|
||||
const user = userContextService.getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('No user in context');
|
||||
}
|
||||
|
||||
console.log(`Getting ${dataType} data for user: ${user.username}`);
|
||||
|
||||
// Return different data based on user permissions
|
||||
if (user.isAdmin) {
|
||||
return {
|
||||
type: dataType,
|
||||
data: 'Admin level data from context',
|
||||
user: user.username,
|
||||
access: 'full',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: dataType,
|
||||
data: 'User level data from context',
|
||||
user: user.username,
|
||||
access: 'limited',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async isCurrentUserAdmin(): Promise<boolean> {
|
||||
const userContextService = this.getUserContextService();
|
||||
return userContextService.isAdmin();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,14 @@ import {
|
||||
getGroupServers,
|
||||
updateGroupServersBatch,
|
||||
} from '../controllers/groupController.js';
|
||||
import {
|
||||
getUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
updateExistingUser,
|
||||
deleteExistingUser,
|
||||
getUserStats,
|
||||
} from '../controllers/userController.js';
|
||||
import {
|
||||
getAllMarketServers,
|
||||
getMarketServer,
|
||||
@@ -65,6 +73,14 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// New route for batch updating servers in a group
|
||||
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
|
||||
|
||||
// User management routes (admin only)
|
||||
router.get('/users', getUsers);
|
||||
router.get('/users/:username', getUser);
|
||||
router.post('/users', createUser);
|
||||
router.put('/users/:username', updateExistingUser);
|
||||
router.delete('/users/:username', deleteExistingUser);
|
||||
router.get('/users-stats', getUserStats);
|
||||
|
||||
// Tool management routes
|
||||
router.post('/tools/call/:server', callTool);
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import express from 'express';
|
||||
import config from './config/index.js';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { initUpstreamServers } from './services/mcpService.js';
|
||||
import { initUpstreamServers, connected } from './services/mcpService.js';
|
||||
import { initMiddlewares } from './middlewares/index.js';
|
||||
import { initRoutes } from './routes/index.js';
|
||||
import {
|
||||
@@ -13,10 +12,10 @@ import {
|
||||
handleMcpOtherRequest,
|
||||
} from './services/sseService.js';
|
||||
import { initializeDefaultUser } from './models/User.js';
|
||||
import { sseUserContextMiddleware } from './middlewares/userContext.js';
|
||||
|
||||
// Get the directory name in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// Get the current working directory (will be project root in most cases)
|
||||
const currentFileDir = process.cwd() + '/src';
|
||||
|
||||
export class AppServer {
|
||||
private app: express.Application;
|
||||
@@ -42,11 +41,52 @@ export class AppServer {
|
||||
initUpstreamServers()
|
||||
.then(() => {
|
||||
console.log('MCP server initialized successfully');
|
||||
this.app.get(`${this.basePath}/sse/:group?`, (req, res) => handleSseConnection(req, res));
|
||||
this.app.post(`${this.basePath}/messages`, handleSseMessage);
|
||||
this.app.post(`${this.basePath}/mcp/:group?`, handleMcpPostRequest);
|
||||
this.app.get(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest);
|
||||
this.app.delete(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest);
|
||||
|
||||
// Original routes (global and group-based)
|
||||
this.app.get(`${this.basePath}/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(`${this.basePath}/messages`, sseUserContextMiddleware, handleSseMessage);
|
||||
this.app.post(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpPostRequest,
|
||||
);
|
||||
this.app.get(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
this.app.delete(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
|
||||
// User-scoped routes with user context middleware
|
||||
this.app.get(`${this.basePath}/:user/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/:user/messages`,
|
||||
sseUserContextMiddleware,
|
||||
handleSseMessage,
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpPostRequest,
|
||||
);
|
||||
this.app.get(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
this.app.delete(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error initializing MCP server:', error);
|
||||
@@ -108,6 +148,10 @@ export class AppServer {
|
||||
});
|
||||
}
|
||||
|
||||
connected(): boolean {
|
||||
return connected();
|
||||
}
|
||||
|
||||
getApp(): express.Application {
|
||||
return this.app;
|
||||
}
|
||||
@@ -119,7 +163,7 @@ export class AppServer {
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Current directory:', process.cwd());
|
||||
console.log('DEBUG: Script directory:', __dirname);
|
||||
console.log('DEBUG: Script directory:', currentFileDir);
|
||||
}
|
||||
|
||||
// First, find the package root directory
|
||||
@@ -159,13 +203,13 @@ export class AppServer {
|
||||
// Possible locations for package.json
|
||||
const possibleRoots = [
|
||||
// Standard npm package location
|
||||
path.resolve(__dirname, '..', '..'),
|
||||
path.resolve(currentFileDir, '..', '..'),
|
||||
// Current working directory
|
||||
process.cwd(),
|
||||
// When running from dist directory
|
||||
path.resolve(__dirname, '..'),
|
||||
path.resolve(currentFileDir, '..'),
|
||||
// When installed via npx
|
||||
path.resolve(__dirname, '..', '..', '..'),
|
||||
path.resolve(currentFileDir, '..', '..', '..'),
|
||||
];
|
||||
|
||||
// Special handling for npx
|
||||
|
||||
13
src/services/dataService.test.ts
Normal file
13
src/services/dataService.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DataService } from './dataService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import './services.js';
|
||||
|
||||
describe('DataService', () => {
|
||||
test('should get default implementation and call foo method', async () => {
|
||||
const dataService: DataService = await getDataService();
|
||||
const consoleSpy = jest.spyOn(console, 'log');
|
||||
dataService.foo();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('default implementation');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
31
src/services/dataService.ts
Normal file
31
src/services/dataService.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { IUser, McpSettings } from '../types/index.js';
|
||||
|
||||
export interface DataService {
|
||||
foo(): void;
|
||||
filterData(data: any[]): any[];
|
||||
filterSettings(settings: McpSettings): McpSettings;
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings;
|
||||
getPermissions(user: IUser): string[];
|
||||
}
|
||||
|
||||
export class DataServiceImpl implements DataService {
|
||||
foo() {
|
||||
console.log('default implementation');
|
||||
}
|
||||
|
||||
filterData(data: any[]): any[] {
|
||||
return data;
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings): McpSettings {
|
||||
return settings;
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings {
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
getPermissions(_user: IUser): string[] {
|
||||
return ['*'];
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,15 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { IGroup } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { notifyToolChanged } from './mcpService.js';
|
||||
import { getDataService } from './services.js';
|
||||
|
||||
// Get all groups
|
||||
export const getAllGroups = (): IGroup[] => {
|
||||
const settings = loadSettings();
|
||||
return settings.groups || [];
|
||||
const dataService = getDataService();
|
||||
return dataService.filterData
|
||||
? dataService.filterData(settings.groups || [])
|
||||
: settings.groups || [];
|
||||
};
|
||||
|
||||
// Get group by ID or name
|
||||
@@ -29,6 +33,7 @@ export const createGroup = (
|
||||
name: string,
|
||||
description?: string,
|
||||
servers: string[] = [],
|
||||
owner?: string,
|
||||
): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
@@ -47,6 +52,7 @@ export const createGroup = (
|
||||
name,
|
||||
description,
|
||||
servers: validServers,
|
||||
owner: owner || 'admin',
|
||||
};
|
||||
|
||||
// Initialize groups array if it doesn't exist
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup } from './groupService.js';
|
||||
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
|
||||
import { OpenAPIClient } from '../clients/openapi.js';
|
||||
import { getDataService } from './services.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
@@ -101,6 +102,33 @@ export const syncToolEmbedding = async (serverName: string, toolName: string) =>
|
||||
// Store all server information
|
||||
let serverInfos: ServerInfo[] = [];
|
||||
|
||||
// Returns true if all servers are connected
|
||||
export const connected = (): boolean => {
|
||||
return serverInfos.every((serverInfo) => serverInfo.status === 'connected');
|
||||
};
|
||||
|
||||
// Global cleanup function to close all connections
|
||||
export const cleanupAllServers = (): void => {
|
||||
for (const serverInfo of serverInfos) {
|
||||
try {
|
||||
if (serverInfo.client) {
|
||||
serverInfo.client.close();
|
||||
}
|
||||
if (serverInfo.transport) {
|
||||
serverInfo.transport.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error closing server ${serverInfo.name}:`, error);
|
||||
}
|
||||
}
|
||||
serverInfos = [];
|
||||
|
||||
// Clear session servers as well
|
||||
Object.keys(servers).forEach((sessionId) => {
|
||||
delete servers[sessionId];
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to create transport based on server configuration
|
||||
const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
||||
let transport;
|
||||
@@ -294,6 +322,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
console.log(`Skipping disabled server: ${name}`);
|
||||
serverInfos.push({
|
||||
name,
|
||||
owner: conf.owner,
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
tools: [],
|
||||
@@ -327,6 +356,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
);
|
||||
serverInfos.push({
|
||||
name,
|
||||
owner: conf.owner,
|
||||
status: 'disconnected',
|
||||
error: 'Missing OpenAPI specification URL or schema',
|
||||
tools: [],
|
||||
@@ -338,6 +368,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
// Create server info first and keep reference to it
|
||||
const serverInfo: ServerInfo = {
|
||||
name,
|
||||
owner: conf.owner,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
@@ -418,6 +449,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
// Create server info first and keep reference to it
|
||||
const serverInfo: ServerInfo = {
|
||||
name,
|
||||
owner: conf.owner,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
@@ -480,7 +512,11 @@ export const registerAllTools = async (isInit: boolean): Promise<void> => {
|
||||
// Get all server information
|
||||
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
|
||||
const settings = loadSettings();
|
||||
const infos = serverInfos.map(({ name, status, tools, createTime, error }) => {
|
||||
const dataService = getDataService();
|
||||
const filterServerInfos: ServerInfo[] = dataService.filterData
|
||||
? dataService.filterData(serverInfos)
|
||||
: serverInfos;
|
||||
const infos = filterServerInfos.map(({ name, status, tools, createTime, error }) => {
|
||||
const serverConfig = settings.mcpServers[name];
|
||||
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
||||
|
||||
@@ -774,13 +810,15 @@ Available servers: ${serversList}`;
|
||||
};
|
||||
}
|
||||
|
||||
const allServerInfos = serverInfos.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
const allServerInfos = getDataService()
|
||||
.filterData(serverInfos)
|
||||
.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
|
||||
37
src/services/registry.ts
Normal file
37
src/services/registry.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
type Class<T> = new (...args: any[]) => T;
|
||||
|
||||
interface Service<T> {
|
||||
defaultImpl: Class<T>;
|
||||
}
|
||||
|
||||
const registry = new Map<string, Service<any>>();
|
||||
const instances = new Map<string, unknown>();
|
||||
|
||||
export function registerService<T>(key: string, entry: Service<T>) {
|
||||
registry.set(key, entry);
|
||||
}
|
||||
|
||||
export function getService<T>(key: string): T {
|
||||
if (instances.has(key)) {
|
||||
return instances.get(key) as T;
|
||||
}
|
||||
|
||||
const entry = registry.get(key);
|
||||
if (!entry) throw new Error(`Service not registered for key: ${key.toString()}`);
|
||||
|
||||
let Impl = entry.defaultImpl;
|
||||
|
||||
const overridePath = './' + key + 'x.js';
|
||||
import(overridePath)
|
||||
.then((mod) => {
|
||||
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'] ?? Impl.name;
|
||||
if (typeof override === 'function') {
|
||||
Impl = override;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const instance = new Impl();
|
||||
instances.set(key, instance);
|
||||
return instance;
|
||||
}
|
||||
10
src/services/services.ts
Normal file
10
src/services/services.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { registerService, getService } from './registry.js';
|
||||
import { DataService, DataServiceImpl } from './dataService.js';
|
||||
|
||||
registerService('dataService', {
|
||||
defaultImpl: DataServiceImpl,
|
||||
});
|
||||
|
||||
export function getDataService(): DataService {
|
||||
return getService<DataService>('dataService');
|
||||
}
|
||||
482
src/services/sseService.test.ts
Normal file
482
src/services/sseService.test.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { jest } from '@jest/globals';
|
||||
import {
|
||||
handleSseConnection,
|
||||
handleSseMessage,
|
||||
handleMcpPostRequest,
|
||||
handleMcpOtherRequest,
|
||||
getGroup,
|
||||
getConnectionCount,
|
||||
} from './sseService.js';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('./mcpService.js', () => ({
|
||||
deleteMcpServer: jest.fn(),
|
||||
getMcpServer: jest.fn(() => ({
|
||||
connect: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../config/index.js', () => {
|
||||
const config = {
|
||||
basePath: '/test',
|
||||
};
|
||||
return {
|
||||
__esModule: true,
|
||||
default: config,
|
||||
loadSettings: jest.fn(() => ({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./userContextService.js', () => ({
|
||||
UserContextService: {
|
||||
getInstance: jest.fn(() => ({
|
||||
getCurrentUser: jest.fn(() => ({ username: 'testuser' })),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
|
||||
SSEServerTransport: jest.fn().mockImplementation((_path, _res) => ({
|
||||
sessionId: 'test-session-id',
|
||||
connect: jest.fn(),
|
||||
handlePostMessage: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
|
||||
StreamableHTTPServerTransport: jest.fn().mockImplementation(() => ({
|
||||
sessionId: 'test-session-id',
|
||||
connect: jest.fn(),
|
||||
handleRequest: jest.fn(),
|
||||
onclose: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
||||
isInitializeRequest: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
// Import mocked modules
|
||||
import { getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
|
||||
// Mock Express Request and Response
|
||||
const createMockRequest = (overrides: Partial<Request> = {}): Request =>
|
||||
({
|
||||
headers: {},
|
||||
params: {},
|
||||
query: {},
|
||||
body: {},
|
||||
...overrides,
|
||||
}) as Request;
|
||||
|
||||
const createMockResponse = (): Response => {
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
on: jest.fn(),
|
||||
} as unknown as Response;
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('sseService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset settings cache
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer authentication', () => {
|
||||
it('should pass when bearer auth is disabled', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(401);
|
||||
expect(SSEServerTransport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth is enabled but no authorization header', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth is enabled with invalid token', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: { authorization: 'Bearer invalid-token' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||
});
|
||||
|
||||
it('should pass when bearer auth is enabled with valid token', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: { authorization: 'Bearer test-key' },
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(401);
|
||||
expect(SSEServerTransport).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroup', () => {
|
||||
it('should return empty string for non-existent session', () => {
|
||||
const result = getGroup('non-existent-session');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return group for existing session', () => {
|
||||
// This would need to be tested after a connection is established
|
||||
// For now, testing the default behavior
|
||||
const result = getGroup('test-session');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnectionCount', () => {
|
||||
it('should return current number of connections', () => {
|
||||
const count = getConnectionCount();
|
||||
// The count may be > 0 due to previous tests since transports is module-level
|
||||
expect(typeof count).toBe('number');
|
||||
expect(count).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSseConnection', () => {
|
||||
it('should reject global routes when disabled', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest(); // No group in params
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
'Global routes are disabled. Please specify a group ID.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create SSE transport for valid request', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(SSEServerTransport).toHaveBeenCalledWith('/test/testuser/messages', res);
|
||||
expect(getMcpServer).toHaveBeenCalledWith('test-session-id', 'test-group');
|
||||
});
|
||||
|
||||
it('should handle user context correctly', async () => {
|
||||
const mockGetCurrentUser = jest.fn(() => ({ username: 'testuser2' }));
|
||||
(UserContextService.getInstance as jest.MockedFunction<any>).mockReturnValue({
|
||||
getCurrentUser: mockGetCurrentUser,
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(mockGetCurrentUser).toHaveBeenCalled();
|
||||
expect(SSEServerTransport).toHaveBeenCalledWith('/test/testuser2/messages', res);
|
||||
});
|
||||
|
||||
it('should handle anonymous user correctly', async () => {
|
||||
const mockGetCurrentUser = jest.fn(() => null);
|
||||
(UserContextService.getInstance as jest.MockedFunction<any>).mockReturnValue({
|
||||
getCurrentUser: mockGetCurrentUser,
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(mockGetCurrentUser).toHaveBeenCalled();
|
||||
expect(SSEServerTransport).toHaveBeenCalledWith('/test/messages', res);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSseMessage', () => {
|
||||
it('should return 400 when sessionId is missing', async () => {
|
||||
const req = createMockRequest({
|
||||
query: {}, // No sessionId
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseMessage(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.send).toHaveBeenCalledWith('Missing sessionId parameter');
|
||||
});
|
||||
|
||||
it('should return 404 when transport not found', async () => {
|
||||
const req = createMockRequest({
|
||||
query: { sessionId: 'non-existent-session' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseMessage(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.send).toHaveBeenCalledWith('No transport found for sessionId');
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
query: { sessionId: 'test-session' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseMessage(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleMcpPostRequest', () => {
|
||||
it('should reject global routes when disabled', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
params: {}, // No group
|
||||
body: { method: 'initialize' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
'Global routes are disabled. Please specify a group ID.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create new transport for initialize request without sessionId', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
body: { method: 'initialize' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalled();
|
||||
expect(getMcpServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for invalid session', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
headers: { 'mcp-session-id': 'invalid-session' },
|
||||
body: { method: 'someMethod' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
body: { method: 'initialize' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleMcpOtherRequest', () => {
|
||||
it('should return 400 for missing session ID', async () => {
|
||||
const req = createMockRequest({
|
||||
headers: {}, // No mcp-session-id
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpOtherRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.send).toHaveBeenCalledWith('Invalid or missing session ID');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid session ID', async () => {
|
||||
const req = createMockRequest({
|
||||
headers: { 'mcp-session-id': 'invalid-session' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpOtherRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.send).toHaveBeenCalledWith('Invalid or missing session ID');
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: { 'mcp-session-id': 'test-session' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpOtherRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
|
||||
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
|
||||
|
||||
@@ -38,8 +39,14 @@ const validateBearerAuth = (req: Request): boolean => {
|
||||
};
|
||||
|
||||
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
// Check bearer auth
|
||||
// User context is now set by sseUserContextMiddleware
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
console.warn('Bearer authentication failed or not provided');
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
@@ -55,11 +62,25 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
|
||||
// Check if this is a global route (no group) and if it's allowed
|
||||
if (!group && !routingConfig.enableGlobalRoute) {
|
||||
console.warn('Global routes are disabled, group ID is required');
|
||||
res.status(403).send('Global routes are disabled. Please specify a group ID.');
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = new SSEServerTransport(`${config.basePath}/messages`, res);
|
||||
// For user-scoped routes, validate that the user has access to the requested group
|
||||
if (username && group) {
|
||||
// Additional validation can be added here to check if user has access to the group
|
||||
console.log(`User ${username} accessing group: ${group}`);
|
||||
}
|
||||
|
||||
// Construct the appropriate messages path based on user context
|
||||
const messagesPath = username
|
||||
? `${config.basePath}/${username}/messages`
|
||||
: `${config.basePath}/messages`;
|
||||
|
||||
console.log(`Creating SSE transport with messages path: ${messagesPath}`);
|
||||
|
||||
const transport = new SSEServerTransport(messagesPath, res);
|
||||
transports[transport.sessionId] = { transport, group: group };
|
||||
|
||||
res.on('close', () => {
|
||||
@@ -69,13 +90,18 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
});
|
||||
|
||||
console.log(
|
||||
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}`,
|
||||
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}${username ? ` for user: ${username}` : ''}`,
|
||||
);
|
||||
await getMcpServer(transport.sessionId, group).connect(transport);
|
||||
};
|
||||
|
||||
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
|
||||
// Check bearer auth
|
||||
// User context is now set by sseUserContextMiddleware
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
@@ -101,24 +127,31 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
||||
const { transport, group } = transportData;
|
||||
req.params.group = group;
|
||||
req.query.group = group;
|
||||
console.log(`Received message for sessionId: ${sessionId} in group: ${group}`);
|
||||
console.log(`Received message for sessionId: ${sessionId} in group: ${group}${username ? ` for user: ${username}` : ''}`);
|
||||
|
||||
await (transport as SSEServerTransport).handlePostMessage(req, res);
|
||||
};
|
||||
|
||||
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
|
||||
// User context is now set by sseUserContextMiddleware
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
const group = req.params.group;
|
||||
const body = req.body;
|
||||
console.log(
|
||||
`Handling MCP post request for sessionId: ${sessionId} and group: ${group} with body: ${JSON.stringify(body)}`,
|
||||
`Handling MCP post request for sessionId: ${sessionId} and group: ${group}${username ? ` for user: ${username}` : ''} with body: ${JSON.stringify(body)}`,
|
||||
);
|
||||
// Check bearer auth
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get filtered settings based on user context (after setting user context)
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
@@ -150,7 +183,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`MCP connection established: ${transport.sessionId}`);
|
||||
console.log(`MCP connection established: ${transport.sessionId}${username ? ` for user: ${username}` : ''}`);
|
||||
await getMcpServer(transport.sessionId, group).connect(transport);
|
||||
} else {
|
||||
res.status(400).json({
|
||||
@@ -169,8 +202,14 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
};
|
||||
|
||||
export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
console.log('Handling MCP other request');
|
||||
// Check bearer auth
|
||||
// User context is now set by sseUserContextMiddleware
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
console.log(`Handling MCP other request${username ? ` for user: ${username}` : ''}`);
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
|
||||
59
src/services/userContextService.ts
Normal file
59
src/services/userContextService.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { IUser } from '../types/index.js';
|
||||
|
||||
// User context storage
|
||||
class UserContext {
|
||||
private static instance: UserContext;
|
||||
private currentUser: IUser | null = null;
|
||||
|
||||
static getInstance(): UserContext {
|
||||
if (!UserContext.instance) {
|
||||
UserContext.instance = new UserContext();
|
||||
}
|
||||
return UserContext.instance;
|
||||
}
|
||||
|
||||
setUser(user: IUser): void {
|
||||
this.currentUser = user;
|
||||
}
|
||||
|
||||
getUser(): IUser | null {
|
||||
return this.currentUser;
|
||||
}
|
||||
|
||||
clearUser(): void {
|
||||
this.currentUser = null;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserContextService {
|
||||
private static instance: UserContextService;
|
||||
private userContext = UserContext.getInstance();
|
||||
|
||||
static getInstance(): UserContextService {
|
||||
if (!UserContextService.instance) {
|
||||
UserContextService.instance = new UserContextService();
|
||||
}
|
||||
return UserContextService.instance;
|
||||
}
|
||||
|
||||
getCurrentUser(): IUser | null {
|
||||
return this.userContext.getUser();
|
||||
}
|
||||
|
||||
setCurrentUser(user: IUser): void {
|
||||
this.userContext.setUser(user);
|
||||
}
|
||||
|
||||
clearCurrentUser(): void {
|
||||
this.userContext.clearUser();
|
||||
}
|
||||
|
||||
isAdmin(): boolean {
|
||||
const user = this.getCurrentUser();
|
||||
return user?.isAdmin || false;
|
||||
}
|
||||
|
||||
hasUser(): boolean {
|
||||
return this.getCurrentUser() !== null;
|
||||
}
|
||||
}
|
||||
126
src/services/userService.ts
Normal file
126
src/services/userService.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { IUser } from '../types/index.js';
|
||||
import { getUsers, createUser, findUserByUsername } from '../models/User.js';
|
||||
import { saveSettings, loadSettings } from '../config/index.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
// Get all users
|
||||
export const getAllUsers = (): IUser[] => {
|
||||
return getUsers();
|
||||
};
|
||||
|
||||
// Get user by username
|
||||
export const getUserByUsername = (username: string): IUser | undefined => {
|
||||
return findUserByUsername(username);
|
||||
};
|
||||
|
||||
// Create a new user
|
||||
export const createNewUser = async (
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin: boolean = false,
|
||||
): Promise<IUser | null> => {
|
||||
try {
|
||||
const existingUser = findUserByUsername(username);
|
||||
if (existingUser) {
|
||||
return null; // User already exists
|
||||
}
|
||||
|
||||
const userData: IUser = {
|
||||
username,
|
||||
password,
|
||||
isAdmin,
|
||||
};
|
||||
|
||||
return await createUser(userData);
|
||||
} catch (error) {
|
||||
console.error('Failed to create user:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Update user information
|
||||
export const updateUser = async (
|
||||
username: string,
|
||||
data: { isAdmin?: boolean; newPassword?: string },
|
||||
): Promise<IUser | null> => {
|
||||
try {
|
||||
const users = getUsers();
|
||||
const userIndex = users.findIndex((user) => user.username === username);
|
||||
|
||||
if (userIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = users[userIndex];
|
||||
|
||||
// Update admin status if provided
|
||||
if (data.isAdmin !== undefined) {
|
||||
user.isAdmin = data.isAdmin;
|
||||
}
|
||||
|
||||
// Update password if provided
|
||||
if (data.newPassword) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(data.newPassword, salt);
|
||||
}
|
||||
|
||||
// Save users array back to settings
|
||||
const { saveSettings, loadSettings } = await import('../config/index.js');
|
||||
const settings = loadSettings();
|
||||
settings.users = users;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('Failed to update user:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a user
|
||||
export const deleteUser = (username: string): boolean => {
|
||||
try {
|
||||
// Cannot delete the last admin user
|
||||
const users = getUsers();
|
||||
const adminUsers = users.filter((user) => user.isAdmin);
|
||||
const userToDelete = users.find((user) => user.username === username);
|
||||
|
||||
if (userToDelete?.isAdmin && adminUsers.length === 1) {
|
||||
return false; // Cannot delete the last admin
|
||||
}
|
||||
|
||||
const filteredUsers = users.filter((user) => user.username !== username);
|
||||
|
||||
if (filteredUsers.length === users.length) {
|
||||
return false; // User not found
|
||||
}
|
||||
|
||||
// Save filtered users back to settings
|
||||
const settings = loadSettings();
|
||||
settings.users = filteredUsers;
|
||||
|
||||
return saveSettings(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if user has admin permissions
|
||||
export const isUserAdmin = (username: string): boolean => {
|
||||
const user = findUserByUsername(username);
|
||||
return user?.isAdmin || false;
|
||||
};
|
||||
|
||||
// Get user count
|
||||
export const getUserCount = (): number => {
|
||||
return getUsers().length;
|
||||
};
|
||||
|
||||
// Get admin count
|
||||
export const getAdminCount = (): number => {
|
||||
return getUsers().filter((user) => user.isAdmin).length;
|
||||
};
|
||||
@@ -18,6 +18,7 @@ export interface IGroup {
|
||||
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
|
||||
owner?: string; // Owner of the group, defaults to 'admin' user
|
||||
}
|
||||
|
||||
// Market server types
|
||||
@@ -74,6 +75,30 @@ export interface MarketServer {
|
||||
is_official?: boolean;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
skipAuth?: boolean; // Controls whether authentication is required for frontend and API access
|
||||
};
|
||||
install?: {
|
||||
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
||||
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
||||
};
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
};
|
||||
}
|
||||
|
||||
// Represents the settings for MCP servers
|
||||
export interface McpSettings {
|
||||
users?: IUser[]; // Array of user credentials and permissions
|
||||
@@ -81,21 +106,8 @@ export interface McpSettings {
|
||||
[key: string]: ServerConfig; // Key-value pairs of server names and their configurations
|
||||
};
|
||||
groups?: IGroup[]; // Array of server groups
|
||||
systemConfig?: {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
skipAuth?: boolean; // Controls whether authentication is required for frontend and API access
|
||||
};
|
||||
install?: {
|
||||
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
||||
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
||||
};
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
// Add other system configuration sections here in the future
|
||||
};
|
||||
systemConfig?: SystemConfig; // System-wide configuration settings
|
||||
userConfigs?: Record<string, UserConfig>; // User-specific configurations
|
||||
}
|
||||
|
||||
// Configuration details for an individual server
|
||||
@@ -107,6 +119,7 @@ export interface ServerConfig {
|
||||
env?: Record<string, string>; // Environment variables
|
||||
headers?: Record<string, string>; // HTTP headers for SSE/streamable-http/openapi servers
|
||||
enabled?: boolean; // Flag to enable/disable the server
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
||||
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
|
||||
@@ -154,6 +167,7 @@ export interface OpenAPISecurityConfig {
|
||||
// Information about a server's status and tools
|
||||
export interface ServerInfo {
|
||||
name: string; // Unique name of the server
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
|
||||
error: string | null; // Error message if any
|
||||
tools: ToolInfo[]; // List of tools available on the server
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
// Get current file's directory
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
// Project root directory should be the parent directory of src
|
||||
const rootDir = dirname(dirname(__dirname));
|
||||
// Project root directory - use process.cwd() as a simpler alternative
|
||||
const rootDir = process.cwd();
|
||||
|
||||
/**
|
||||
* Find the path to a configuration file by checking multiple potential locations.
|
||||
@@ -24,7 +20,7 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
|
||||
// Use path relative to the root directory
|
||||
path.join(rootDir, filename),
|
||||
// If installed with npx, may need to look one level up
|
||||
path.join(dirname(rootDir), filename)
|
||||
path.join(dirname(rootDir), filename),
|
||||
];
|
||||
|
||||
for (const filePath of potentialPaths) {
|
||||
@@ -38,6 +34,8 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
|
||||
// even if the configuration file is missing. This fallback is particularly useful in
|
||||
// development environments or when the file is optional.
|
||||
const defaultPath = path.resolve(process.cwd(), filename);
|
||||
console.debug(`${description} file not found at any expected location, using default path: ${defaultPath}`);
|
||||
console.debug(
|
||||
`${description} file not found at any expected location, using default path: ${defaultPath}`,
|
||||
);
|
||||
return defaultPath;
|
||||
};
|
||||
};
|
||||
|
||||
72
src/utils/serialization.ts
Normal file
72
src/utils/serialization.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Utility functions for safe JSON serialization
|
||||
* Handles circular references and provides type-safe serialization
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a JSON-safe copy of an object by removing circular references
|
||||
* Uses a replacer function with WeakSet to efficiently track visited objects
|
||||
*
|
||||
* @param obj - The object to make JSON-safe
|
||||
* @returns A new object that can be safely serialized to JSON
|
||||
*/
|
||||
export const createSafeJSON = <T>(obj: T): T => {
|
||||
const seen = new WeakSet();
|
||||
|
||||
return JSON.parse(
|
||||
JSON.stringify(obj, (key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular Reference]';
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe JSON stringifier that handles circular references
|
||||
* Useful for logging or debugging purposes
|
||||
*
|
||||
* @param obj - The object to stringify
|
||||
* @param space - Number of spaces to use for indentation (optional)
|
||||
* @returns JSON string representation of the object
|
||||
*/
|
||||
export const safeStringify = (obj: any, space?: number): string => {
|
||||
const seen = new WeakSet();
|
||||
|
||||
return JSON.stringify(
|
||||
obj,
|
||||
(key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular Reference]';
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
space,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes specific properties that might contain circular references
|
||||
* More targeted approach for known problematic properties
|
||||
*
|
||||
* @param obj - The object to clean
|
||||
* @param excludeProps - Array of property names to exclude
|
||||
* @returns A new object without the specified properties
|
||||
*/
|
||||
export const excludeCircularProps = <T extends Record<string, any>>(
|
||||
obj: T,
|
||||
excludeProps: string[],
|
||||
): Omit<T, keyof (typeof excludeProps)[number]> => {
|
||||
const result = { ...obj };
|
||||
excludeProps.forEach((prop) => {
|
||||
delete result[prop];
|
||||
});
|
||||
return result;
|
||||
};
|
||||
@@ -1,10 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Get the directory name in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/**
|
||||
* Gets the package version from package.json
|
||||
@@ -12,7 +7,7 @@ const __dirname = path.dirname(__filename);
|
||||
*/
|
||||
export const getPackageVersion = (): string => {
|
||||
try {
|
||||
const packageJsonPath = path.resolve(__dirname, '../../package.json');
|
||||
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
return packageJson.version || 'dev';
|
||||
|
||||
Reference in New Issue
Block a user