Add PostgreSQL-backed data storage support (#444)

Co-authored-by: samanhappy <samanhappy@gmail.com>
This commit is contained in:
Copilot
2025-11-29 17:45:25 +08:00
committed by GitHub
parent 73ae33e777
commit 063b081297
57 changed files with 3147 additions and 783 deletions

View File

@@ -6,11 +6,11 @@ import {
SystemConfigDao,
UserConfigDao,
ServerConfigWithName,
UserDaoImpl,
ServerDaoImpl,
GroupDaoImpl,
SystemConfigDaoImpl,
UserConfigDaoImpl,
getUserDao,
getServerDao,
getGroupDao,
getSystemConfigDao,
getUserConfigDao,
} from '../dao/index.js';
/**
@@ -252,14 +252,14 @@ export class DaoConfigService {
}
/**
* Create a DaoConfigService with default DAO implementations
* Create a DaoConfigService with DAO implementations from factory
*/
export function createDaoConfigService(): DaoConfigService {
return new DaoConfigService(
new UserDaoImpl(),
new ServerDaoImpl(),
new GroupDaoImpl(),
new SystemConfigDaoImpl(),
new UserConfigDaoImpl(),
getUserDao(),
getServerDao(),
getGroupDao(),
getSystemConfigDao(),
getUserConfigDao(),
);
}

View File

@@ -37,7 +37,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
try {
// Find user by username
const user = findUserByUsername(username);
const user = await findUserByUsername(username);
if (!user) {
res.status(401).json({
@@ -192,7 +192,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
}
// Find user by username
const user = findUserByUsername(username);
const user = await findUserByUsername(username);
if (!user) {
res.status(404).json({ success: false, message: 'User not found' });

View File

@@ -15,9 +15,9 @@ import {
} from '../services/groupService.js';
// Get all groups
export const getGroups = (_: Request, res: Response): void => {
export const getGroups = async (_: Request, res: Response): Promise<void> => {
try {
const groups = getAllGroups();
const groups = await getAllGroups();
const response: ApiResponse = {
success: true,
data: groups,
@@ -32,7 +32,7 @@ export const getGroups = (_: Request, res: Response): void => {
};
// Get a specific group by ID
export const getGroup = (req: Request, res: Response): void => {
export const getGroup = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
if (!id) {
@@ -43,7 +43,7 @@ export const getGroup = (req: Request, res: Response): void => {
return;
}
const group = getGroupByIdOrName(id);
const group = await getGroupByIdOrName(id);
if (!group) {
res.status(404).json({
success: false,
@@ -66,7 +66,7 @@ export const getGroup = (req: Request, res: Response): void => {
};
// Create a new group
export const createNewGroup = (req: Request, res: Response): void => {
export const createNewGroup = async (req: Request, res: Response): Promise<void> => {
try {
const { name, description, servers } = req.body;
if (!name) {
@@ -83,7 +83,7 @@ export const createNewGroup = (req: Request, res: Response): void => {
const currentUser = (req as any).user;
const owner = currentUser?.username || 'admin';
const newGroup = createGroup(name, description, serverList, owner);
const newGroup = await createGroup(name, description, serverList, owner);
if (!newGroup) {
res.status(400).json({
success: false,
@@ -107,7 +107,7 @@ export const createNewGroup = (req: Request, res: Response): void => {
};
// Update an existing group
export const updateExistingGroup = (req: Request, res: Response): void => {
export const updateExistingGroup = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { name, description, servers } = req.body;
@@ -133,7 +133,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
return;
}
const updatedGroup = updateGroup(id, updateData);
const updatedGroup = await updateGroup(id, updateData);
if (!updatedGroup) {
res.status(404).json({
success: false,
@@ -157,7 +157,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
};
// Update servers in a group (batch update) - supports both string[] and server config format
export const updateGroupServersBatch = (req: Request, res: Response): void => {
export const updateGroupServersBatch = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { servers } = req.body;
@@ -203,7 +203,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
}
}
const updatedGroup = updateGroupServers(id, servers);
const updatedGroup = await updateGroupServers(id, servers);
if (!updatedGroup) {
res.status(404).json({
success: false,
@@ -227,7 +227,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
};
// Delete a group
export const deleteExistingGroup = (req: Request, res: Response): void => {
export const deleteExistingGroup = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
if (!id) {
@@ -238,7 +238,7 @@ export const deleteExistingGroup = (req: Request, res: Response): void => {
return;
}
const success = deleteGroup(id);
const success = await deleteGroup(id);
if (!success) {
res.status(404).json({
success: false,
@@ -260,7 +260,7 @@ export const deleteExistingGroup = (req: Request, res: Response): void => {
};
// Add server to a group
export const addServerToExistingGroup = (req: Request, res: Response): void => {
export const addServerToExistingGroup = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { serverName } = req.body;
@@ -280,7 +280,7 @@ export const addServerToExistingGroup = (req: Request, res: Response): void => {
return;
}
const updatedGroup = addServerToGroup(id, serverName);
const updatedGroup = await addServerToGroup(id, serverName);
if (!updatedGroup) {
res.status(404).json({
success: false,
@@ -304,7 +304,7 @@ export const addServerToExistingGroup = (req: Request, res: Response): void => {
};
// Remove server from a group
export const removeServerFromExistingGroup = (req: Request, res: Response): void => {
export const removeServerFromExistingGroup = async (req: Request, res: Response): Promise<void> => {
try {
const { id, serverName } = req.params;
if (!id || !serverName) {
@@ -315,7 +315,7 @@ export const removeServerFromExistingGroup = (req: Request, res: Response): void
return;
}
const updatedGroup = removeServerFromGroup(id, serverName);
const updatedGroup = await removeServerFromGroup(id, serverName);
if (!updatedGroup) {
res.status(404).json({
success: false,
@@ -339,7 +339,7 @@ export const removeServerFromExistingGroup = (req: Request, res: Response): void
};
// Get servers in a group
export const getGroupServers = (req: Request, res: Response): void => {
export const getGroupServers = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
if (!id) {
@@ -350,7 +350,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
return;
}
const group = getGroupByIdOrName(id);
const group = await getGroupByIdOrName(id);
if (!group) {
res.status(404).json({
success: false,
@@ -373,7 +373,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
};
// Get server configurations in a group (including tool selections)
export const getGroupServerConfigs = (req: Request, res: Response): void => {
export const getGroupServerConfigs = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
if (!id) {
@@ -384,7 +384,7 @@ export const getGroupServerConfigs = (req: Request, res: Response): void => {
return;
}
const serverConfigs = getServerConfigsInGroup(id);
const serverConfigs = await getServerConfigsInGroup(id);
const response: ApiResponse = {
success: true,
data: serverConfigs,
@@ -399,7 +399,7 @@ export const getGroupServerConfigs = (req: Request, res: Response): void => {
};
// Get specific server configuration in a group
export const getGroupServerConfig = (req: Request, res: Response): void => {
export const getGroupServerConfig = async (req: Request, res: Response): Promise<void> => {
try {
const { id, serverName } = req.params;
if (!id || !serverName) {
@@ -410,7 +410,7 @@ export const getGroupServerConfig = (req: Request, res: Response): void => {
return;
}
const serverConfig = getServerConfigInGroup(id, serverName);
const serverConfig = await getServerConfigInGroup(id, serverName);
if (!serverConfig) {
res.status(404).json({
success: false,
@@ -433,7 +433,7 @@ export const getGroupServerConfig = (req: Request, res: Response): void => {
};
// Update tools for a specific server in a group
export const updateGroupServerTools = (req: Request, res: Response): void => {
export const updateGroupServerTools = async (req: Request, res: Response): Promise<void> => {
try {
const { id, serverName } = req.params;
const { tools } = req.body;
@@ -458,7 +458,7 @@ export const updateGroupServerTools = (req: Request, res: Response): void => {
return;
}
const updatedGroup = updateServerToolsInGroup(id, serverName, tools);
const updatedGroup = await updateServerToolsInGroup(id, serverName, tools);
if (!updatedGroup) {
res.status(404).json({
success: false,

View File

@@ -208,7 +208,7 @@ export const getGroupOpenAPISpec = async (req: Request, res: Response): Promise<
const { name } = req.params;
// Check if group exists
const group = getGroupByIdOrName(name);
const group = await getGroupByIdOrName(name);
if (!group) {
getServerOpenAPISpec(req, res);
return;

View File

@@ -1,5 +1,5 @@
import { Request, Response } from 'express';
import { ApiResponse, AddServerRequest } from '../types/index.js';
import { ApiResponse, AddServerRequest, McpSettings } from '../types/index.js';
import {
getServersInfo,
addServer,
@@ -13,6 +13,7 @@ import { loadSettings, saveSettings } from '../config/index.js';
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
import { createSafeJSON } from '../utils/serialization.js';
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js';
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
try {
@@ -31,15 +32,45 @@ export const getAllServers = async (_: Request, res: Response): Promise<void> =>
}
};
export const getAllSettings = (_: Request, res: Response): void => {
export const getAllSettings = async (_: Request, res: Response): Promise<void> => {
try {
const settings = loadSettings();
// Get base settings from file (for OAuth clients, tokens, users, etc.)
const fileSettings = loadSettings();
// Get servers from DAO (supports both file and database modes)
const serverDao = getServerDao();
const servers = await serverDao.findAll();
// Convert servers array to mcpServers map format
const mcpServers: McpSettings['mcpServers'] = {};
for (const server of servers) {
const { name, ...config } = server;
mcpServers[name] = config;
}
// Get groups from DAO
const groupDao = getGroupDao();
const groups = await groupDao.findAll();
// Get system config from DAO
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
// Merge all data into settings object
const settings: McpSettings = {
...fileSettings,
mcpServers,
groups,
systemConfig,
};
const response: ApiResponse = {
success: true,
data: createSafeJSON(settings),
};
res.json(response);
} catch (error) {
console.error('Failed to get server settings:', error);
res.status(500).json({
success: false,
message: 'Failed to get server settings',
@@ -303,9 +334,12 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
export const getServerConfig = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
const allServers = await getServersInfo();
const serverInfo = allServers.find((s) => s.name === name);
if (!serverInfo) {
// Get server configuration from DAO (supports both file and database modes)
const serverDao = getServerDao();
const serverConfig = await serverDao.findById(name);
if (!serverConfig) {
res.status(404).json({
success: false,
message: 'Server not found',
@@ -313,18 +347,26 @@ export const getServerConfig = async (req: Request, res: Response): Promise<void
return;
}
// Get runtime info (status, tools) from getServersInfo
const allServers = await getServersInfo();
const serverInfo = allServers.find((s) => s.name === name);
// Extract config without the name field
const { name: serverName, ...config } = serverConfig;
const response: ApiResponse = {
success: true,
data: {
name,
status: serverInfo ? serverInfo.status : 'disconnected',
tools: serverInfo ? serverInfo.tools : [],
config: serverInfo,
name: serverName,
status: serverInfo?.status || 'disconnected',
tools: serverInfo?.tools || [],
config,
},
};
res.json(response);
} catch (error) {
console.error('Failed to get server configuration:', error);
res.status(500).json({
success: false,
message: 'Failed to get server configuration',
@@ -507,10 +549,17 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
}
};
export const updateSystemConfig = (req: Request, res: Response): void => {
export const updateSystemConfig = async (req: Request, res: Response): Promise<void> => {
try {
const { routing, install, smartRouting, mcpRouter, nameSeparator, enableSessionRebuild, oauthServer } = req.body;
const currentUser = (req as any).user;
const {
routing,
install,
smartRouting,
mcpRouter,
nameSeparator,
enableSessionRebuild,
oauthServer,
} = req.body;
const hasRoutingUpdate =
routing &&
@@ -542,7 +591,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
typeof mcpRouter.baseUrl === 'string');
const hasNameSeparatorUpdate = typeof nameSeparator === 'string';
const hasSessionRebuildUpdate = typeof enableSessionRebuild === 'boolean';
const hasOAuthServerUpdate =
@@ -575,9 +624,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
return;
}
const settings = loadSettings();
if (!settings.systemConfig) {
settings.systemConfig = {
// Get system config from DAO (supports both file and database modes)
const systemConfigDao = getSystemConfigDao();
let systemConfig = await systemConfigDao.get();
if (!systemConfig) {
systemConfig = {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
@@ -607,8 +659,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
};
}
if (!settings.systemConfig.routing) {
settings.systemConfig.routing = {
if (!systemConfig.routing) {
systemConfig.routing = {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
@@ -617,16 +669,16 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
};
}
if (!settings.systemConfig.install) {
settings.systemConfig.install = {
if (!systemConfig.install) {
systemConfig.install = {
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
};
}
if (!settings.systemConfig.smartRouting) {
settings.systemConfig.smartRouting = {
if (!systemConfig.smartRouting) {
systemConfig.smartRouting = {
enabled: false,
dbUrl: '',
openaiApiBaseUrl: '',
@@ -635,8 +687,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
};
}
if (!settings.systemConfig.mcpRouter) {
settings.systemConfig.mcpRouter = {
if (!systemConfig.mcpRouter) {
systemConfig.mcpRouter = {
apiKey: '',
referer: 'https://www.mcphubx.com',
title: 'MCPHub',
@@ -644,18 +696,18 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
};
}
if (!settings.systemConfig.oauthServer) {
settings.systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
if (!systemConfig.oauthServer) {
systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
}
if (!settings.systemConfig.oauthServer.dynamicRegistration) {
if (!systemConfig.oauthServer.dynamicRegistration) {
const defaultConfig = cloneDefaultOAuthServerConfig();
const defaultDynamic = defaultConfig.dynamicRegistration ?? {
enabled: false,
allowedGrantTypes: [],
requiresAuthentication: false,
};
settings.systemConfig.oauthServer.dynamicRegistration = {
systemConfig.oauthServer.dynamicRegistration = {
enabled: defaultDynamic.enabled ?? false,
allowedGrantTypes: [
...(Array.isArray(defaultDynamic.allowedGrantTypes)
@@ -668,50 +720,50 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
if (routing) {
if (typeof routing.enableGlobalRoute === 'boolean') {
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
}
if (typeof routing.enableGroupNameRoute === 'boolean') {
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
}
if (typeof routing.enableBearerAuth === 'boolean') {
settings.systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
}
if (typeof routing.bearerAuthKey === 'string') {
settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
}
if (typeof routing.skipAuth === 'boolean') {
settings.systemConfig.routing.skipAuth = routing.skipAuth;
systemConfig.routing.skipAuth = routing.skipAuth;
}
}
if (install) {
if (typeof install.pythonIndexUrl === 'string') {
settings.systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
}
if (typeof install.npmRegistry === 'string') {
settings.systemConfig.install.npmRegistry = install.npmRegistry;
systemConfig.install.npmRegistry = install.npmRegistry;
}
if (typeof install.baseUrl === 'string') {
settings.systemConfig.install.baseUrl = install.baseUrl;
systemConfig.install.baseUrl = install.baseUrl;
}
}
// Track smartRouting state and configuration changes
const wasSmartRoutingEnabled = settings.systemConfig.smartRouting.enabled || false;
const previousSmartRoutingConfig = { ...settings.systemConfig.smartRouting };
const wasSmartRoutingEnabled = systemConfig.smartRouting.enabled || false;
const previousSmartRoutingConfig = { ...systemConfig.smartRouting };
let needsSync = false;
if (smartRouting) {
if (typeof smartRouting.enabled === 'boolean') {
// If enabling Smart Routing, validate required fields
if (smartRouting.enabled) {
const currentDbUrl = smartRouting.dbUrl || settings.systemConfig.smartRouting.dbUrl;
const currentDbUrl = smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
const currentOpenaiApiKey =
smartRouting.openaiApiKey || settings.systemConfig.smartRouting.openaiApiKey;
smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey;
if (!currentDbUrl || !currentOpenaiApiKey) {
const missingFields = [];
@@ -725,32 +777,30 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
return;
}
}
settings.systemConfig.smartRouting.enabled = smartRouting.enabled;
systemConfig.smartRouting.enabled = smartRouting.enabled;
}
if (typeof smartRouting.dbUrl === 'string') {
settings.systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
}
if (typeof smartRouting.openaiApiBaseUrl === 'string') {
settings.systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
}
if (typeof smartRouting.openaiApiKey === 'string') {
settings.systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
}
if (typeof smartRouting.openaiApiEmbeddingModel === 'string') {
settings.systemConfig.smartRouting.openaiApiEmbeddingModel =
smartRouting.openaiApiEmbeddingModel;
systemConfig.smartRouting.openaiApiEmbeddingModel = smartRouting.openaiApiEmbeddingModel;
}
// Check if we need to sync embeddings
const isNowEnabled = settings.systemConfig.smartRouting.enabled || false;
const isNowEnabled = systemConfig.smartRouting.enabled || false;
const hasConfigChanged =
previousSmartRoutingConfig.dbUrl !== settings.systemConfig.smartRouting.dbUrl ||
previousSmartRoutingConfig.dbUrl !== systemConfig.smartRouting.dbUrl ||
previousSmartRoutingConfig.openaiApiBaseUrl !==
settings.systemConfig.smartRouting.openaiApiBaseUrl ||
previousSmartRoutingConfig.openaiApiKey !==
settings.systemConfig.smartRouting.openaiApiKey ||
systemConfig.smartRouting.openaiApiBaseUrl ||
previousSmartRoutingConfig.openaiApiKey !== systemConfig.smartRouting.openaiApiKey ||
previousSmartRoutingConfig.openaiApiEmbeddingModel !==
settings.systemConfig.smartRouting.openaiApiEmbeddingModel;
systemConfig.smartRouting.openaiApiEmbeddingModel;
// Sync if: first time enabling OR smart routing is enabled and any config changed
needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged);
@@ -758,21 +808,21 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
if (mcpRouter) {
if (typeof mcpRouter.apiKey === 'string') {
settings.systemConfig.mcpRouter.apiKey = mcpRouter.apiKey;
systemConfig.mcpRouter.apiKey = mcpRouter.apiKey;
}
if (typeof mcpRouter.referer === 'string') {
settings.systemConfig.mcpRouter.referer = mcpRouter.referer;
systemConfig.mcpRouter.referer = mcpRouter.referer;
}
if (typeof mcpRouter.title === 'string') {
settings.systemConfig.mcpRouter.title = mcpRouter.title;
systemConfig.mcpRouter.title = mcpRouter.title;
}
if (typeof mcpRouter.baseUrl === 'string') {
settings.systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl;
systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl;
}
}
if (oauthServer) {
const target = settings.systemConfig.oauthServer;
const target = systemConfig.oauthServer;
if (typeof oauthServer.enabled === 'boolean') {
target.enabled = oauthServer.enabled;
}
@@ -826,17 +876,19 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
}
if (typeof nameSeparator === 'string') {
settings.systemConfig.nameSeparator = nameSeparator;
systemConfig.nameSeparator = nameSeparator;
}
if (typeof enableSessionRebuild === 'boolean') {
settings.systemConfig.enableSessionRebuild = enableSessionRebuild;
systemConfig.enableSessionRebuild = enableSessionRebuild;
}
if (saveSettings(settings, currentUser)) {
// Save using DAO (supports both file and database modes)
try {
await systemConfigDao.update(systemConfig);
res.json({
success: true,
data: settings.systemConfig,
data: systemConfig,
message: 'System configuration updated successfully',
});
@@ -848,7 +900,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
console.error('Failed to sync server tools embeddings:', error);
});
}
} else {
} catch (saveError) {
console.error('Failed to save system configuration:', saveError);
res.status(500).json({
success: false,
message: 'Failed to save system configuration',

View File

@@ -9,13 +9,14 @@ import {
getUserCount,
getAdminCount,
} from '../services/userService.js';
import { loadSettings } from '../config/index.js';
import { getSystemConfigDao } from '../dao/index.js';
import { validatePasswordStrength } from '../utils/passwordValidation.js';
// Admin permission check middleware function
const requireAdmin = (req: Request, res: Response): boolean => {
const settings = loadSettings();
if (settings.systemConfig?.routing?.skipAuth) {
const requireAdmin = async (req: Request, res: Response): Promise<boolean> => {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
if (systemConfig?.routing?.skipAuth) {
return true;
}
@@ -31,11 +32,11 @@ const requireAdmin = (req: Request, res: Response): boolean => {
};
// Get all users (admin only)
export const getUsers = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
export const getUsers = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
try {
const users = getAllUsers().map(({ password: _, ...user }) => user); // Remove password from response
const users = (await getAllUsers()).map(({ password: _, ...user }) => user); // Remove password from response
const response: ApiResponse = {
success: true,
data: users,
@@ -50,8 +51,8 @@ export const getUsers = (req: Request, res: Response): void => {
};
// Get a specific user by username (admin only)
export const getUser = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
export const getUser = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
try {
const { username } = req.params;
@@ -63,7 +64,7 @@ export const getUser = (req: Request, res: Response): void => {
return;
}
const user = getUserByUsername(username);
const user = await getUserByUsername(username);
if (!user) {
res.status(404).json({
success: false,
@@ -88,7 +89,7 @@ export const getUser = (req: Request, res: Response): void => {
// Create a new user (admin only)
export const createUser = async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!(await requireAdmin(req, res))) return;
try {
const { username, password, isAdmin } = req.body;
@@ -138,7 +139,7 @@ export const createUser = async (req: Request, res: Response): Promise<void> =>
// Update an existing user (admin only)
export const updateExistingUser = async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!(await requireAdmin(req, res))) return;
try {
const { username } = req.params;
@@ -154,7 +155,7 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
// Check if trying to change admin status
if (isAdmin !== undefined) {
const currentUser = getUserByUsername(username);
const currentUser = await getUserByUsername(username);
if (!currentUser) {
res.status(404).json({
success: false,
@@ -164,7 +165,7 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
}
// Prevent removing admin status from the last admin
if (currentUser.isAdmin && !isAdmin && getAdminCount() === 1) {
if (currentUser.isAdmin && !isAdmin && (await getAdminCount()) === 1) {
res.status(400).json({
success: false,
message: 'Cannot remove admin status from the last admin user',
@@ -222,8 +223,8 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
};
// Delete a user (admin only)
export const deleteExistingUser = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
export const deleteExistingUser = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
try {
const { username } = req.params;
@@ -245,7 +246,7 @@ export const deleteExistingUser = (req: Request, res: Response): void => {
return;
}
const success = deleteUser(username);
const success = await deleteUser(username);
if (!success) {
res.status(400).json({
success: false,
@@ -267,12 +268,12 @@ export const deleteExistingUser = (req: Request, res: Response): void => {
};
// Get user statistics (admin only)
export const getUserStats = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
export const getUserStats = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
try {
const totalUsers = getUserCount();
const adminUsers = getAdminCount();
const totalUsers = await getUserCount();
const adminUsers = await getAdminCount();
const regularUsers = totalUsers - adminUsers;
const response: ApiResponse = {

View File

@@ -107,6 +107,26 @@ export function getDaoFactory(): DaoFactory {
return daoFactory;
}
/**
* Switch to database-backed DAOs based on environment variable
* This is synchronous and should be called during app initialization
*/
export function initializeDaoFactory(): void {
// If USE_DB is explicitly set, use its value; otherwise, auto-detect based on DB_URL presence
const useDatabase =
process.env.USE_DB !== undefined ? process.env.USE_DB === 'true' : !!process.env.DB_URL;
if (useDatabase) {
console.log('Using database-backed DAO implementations');
// Dynamic import to avoid circular dependencies
// eslint-disable-next-line @typescript-eslint/no-var-requires
const DatabaseDaoFactoryModule = require('./DatabaseDaoFactory.js');
setDaoFactory(DatabaseDaoFactoryModule.DatabaseDaoFactory.getInstance());
} else {
console.log('Using file-based DAO implementations');
setDaoFactory(JsonFileDaoFactory.getInstance());
}
}
/**
* Convenience functions to get specific DAOs
*/

View File

@@ -0,0 +1,79 @@
import { DaoFactory, UserDao, ServerDao, GroupDao, SystemConfigDao, UserConfigDao } from './index.js';
import { UserDaoDbImpl } from './UserDaoDbImpl.js';
import { ServerDaoDbImpl } from './ServerDaoDbImpl.js';
import { GroupDaoDbImpl } from './GroupDaoDbImpl.js';
import { SystemConfigDaoDbImpl } from './SystemConfigDaoDbImpl.js';
import { UserConfigDaoDbImpl } from './UserConfigDaoDbImpl.js';
/**
* Database-backed DAO factory implementation
*/
export class DatabaseDaoFactory implements DaoFactory {
private static instance: DatabaseDaoFactory;
private userDao: UserDao | null = null;
private serverDao: ServerDao | null = null;
private groupDao: GroupDao | null = null;
private systemConfigDao: SystemConfigDao | null = null;
private userConfigDao: UserConfigDao | null = null;
/**
* Get singleton instance
*/
public static getInstance(): DatabaseDaoFactory {
if (!DatabaseDaoFactory.instance) {
DatabaseDaoFactory.instance = new DatabaseDaoFactory();
}
return DatabaseDaoFactory.instance;
}
private constructor() {
// Private constructor for singleton
}
getUserDao(): UserDao {
if (!this.userDao) {
this.userDao = new UserDaoDbImpl();
}
return this.userDao!;
}
getServerDao(): ServerDao {
if (!this.serverDao) {
this.serverDao = new ServerDaoDbImpl();
}
return this.serverDao!;
}
getGroupDao(): GroupDao {
if (!this.groupDao) {
this.groupDao = new GroupDaoDbImpl();
}
return this.groupDao!;
}
getSystemConfigDao(): SystemConfigDao {
if (!this.systemConfigDao) {
this.systemConfigDao = new SystemConfigDaoDbImpl();
}
return this.systemConfigDao!;
}
getUserConfigDao(): UserConfigDao {
if (!this.userConfigDao) {
this.userConfigDao = new UserConfigDaoDbImpl();
}
return this.userConfigDao!;
}
/**
* Reset all cached DAO instances (useful for testing)
*/
public resetInstances(): void {
this.userDao = null;
this.serverDao = null;
this.groupDao = null;
this.systemConfigDao = null;
this.userConfigDao = null;
}
}

154
src/dao/GroupDaoDbImpl.ts Normal file
View File

@@ -0,0 +1,154 @@
import { GroupDao } from './index.js';
import { IGroup } from '../types/index.js';
import { GroupRepository } from '../db/repositories/GroupRepository.js';
/**
* Database-backed implementation of GroupDao
*/
export class GroupDaoDbImpl implements GroupDao {
private repository: GroupRepository;
constructor() {
this.repository = new GroupRepository();
}
async findAll(): Promise<IGroup[]> {
const groups = await this.repository.findAll();
return groups.map((g) => ({
id: g.id,
name: g.name,
description: g.description,
servers: g.servers as any,
owner: g.owner,
}));
}
async findById(id: string): Promise<IGroup | null> {
const group = await this.repository.findById(id);
if (!group) return null;
return {
id: group.id,
name: group.name,
description: group.description,
servers: group.servers as any,
owner: group.owner,
};
}
async create(entity: Omit<IGroup, 'id'>): Promise<IGroup> {
const group = await this.repository.create({
name: entity.name,
description: entity.description,
servers: entity.servers as any,
owner: entity.owner,
});
return {
id: group.id,
name: group.name,
description: group.description,
servers: group.servers as any,
owner: group.owner,
};
}
async update(id: string, entity: Partial<IGroup>): Promise<IGroup | null> {
const group = await this.repository.update(id, {
name: entity.name,
description: entity.description,
servers: entity.servers as any,
owner: entity.owner,
});
if (!group) return null;
return {
id: group.id,
name: group.name,
description: group.description,
servers: group.servers as any,
owner: group.owner,
};
}
async delete(id: string): Promise<boolean> {
return await this.repository.delete(id);
}
async exists(id: string): Promise<boolean> {
return await this.repository.exists(id);
}
async count(): Promise<number> {
return await this.repository.count();
}
async findByOwner(owner: string): Promise<IGroup[]> {
const groups = await this.repository.findByOwner(owner);
return groups.map((g) => ({
id: g.id,
name: g.name,
description: g.description,
servers: g.servers as any,
owner: g.owner,
}));
}
async findByServer(serverName: string): Promise<IGroup[]> {
const allGroups = await this.repository.findAll();
return allGroups
.filter((g) =>
g.servers.some((s) => (typeof s === 'string' ? s === serverName : s.name === serverName)),
)
.map((g) => ({
id: g.id,
name: g.name,
description: g.description,
servers: g.servers as any,
owner: g.owner,
}));
}
async addServerToGroup(groupId: string, serverName: string): Promise<boolean> {
const group = await this.repository.findById(groupId);
if (!group) return false;
// Check if server already exists
const serverExists = group.servers.some((s) =>
typeof s === 'string' ? s === serverName : s.name === serverName,
);
if (!serverExists) {
group.servers.push(serverName);
await this.update(groupId, { servers: group.servers as any });
}
return true;
}
async removeServerFromGroup(groupId: string, serverName: string): Promise<boolean> {
const group = await this.repository.findById(groupId);
if (!group) return false;
group.servers = group.servers.filter((s) =>
typeof s === 'string' ? s !== serverName : s.name !== serverName,
) as any;
await this.update(groupId, { servers: group.servers as any });
return true;
}
async updateServers(groupId: string, servers: string[] | IGroup['servers']): Promise<boolean> {
const result = await this.update(groupId, { servers: servers as any });
return result !== null;
}
async findByName(name: string): Promise<IGroup | null> {
const group = await this.repository.findByName(name);
if (!group) return null;
return {
id: group.id,
name: group.name,
description: group.description,
servers: group.servers as any,
owner: group.owner,
};
}
}

144
src/dao/ServerDaoDbImpl.ts Normal file
View File

@@ -0,0 +1,144 @@
import { ServerDao, ServerConfigWithName } from './index.js';
import { ServerRepository } from '../db/repositories/ServerRepository.js';
/**
* Database-backed implementation of ServerDao
*/
export class ServerDaoDbImpl implements ServerDao {
private repository: ServerRepository;
constructor() {
this.repository = new ServerRepository();
}
async findAll(): Promise<ServerConfigWithName[]> {
const servers = await this.repository.findAll();
return servers.map((s) => this.mapToServerConfig(s));
}
async findById(name: string): Promise<ServerConfigWithName | null> {
const server = await this.repository.findByName(name);
return server ? this.mapToServerConfig(server) : null;
}
async create(entity: ServerConfigWithName): Promise<ServerConfigWithName> {
const server = await this.repository.create({
name: entity.name,
type: entity.type,
url: entity.url,
command: entity.command,
args: entity.args,
env: entity.env,
headers: entity.headers,
enabled: entity.enabled !== undefined ? entity.enabled : true,
owner: entity.owner,
keepAliveInterval: entity.keepAliveInterval,
tools: entity.tools,
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
});
return this.mapToServerConfig(server);
}
async update(name: string, entity: Partial<ServerConfigWithName>): Promise<ServerConfigWithName | null> {
const server = await this.repository.update(name, {
type: entity.type,
url: entity.url,
command: entity.command,
args: entity.args,
env: entity.env,
headers: entity.headers,
enabled: entity.enabled,
owner: entity.owner,
keepAliveInterval: entity.keepAliveInterval,
tools: entity.tools,
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
});
return server ? this.mapToServerConfig(server) : null;
}
async delete(name: string): Promise<boolean> {
return await this.repository.delete(name);
}
async exists(name: string): Promise<boolean> {
return await this.repository.exists(name);
}
async count(): Promise<number> {
return await this.repository.count();
}
async findByOwner(owner: string): Promise<ServerConfigWithName[]> {
const servers = await this.repository.findByOwner(owner);
return servers.map((s) => this.mapToServerConfig(s));
}
async findEnabled(): Promise<ServerConfigWithName[]> {
const servers = await this.repository.findEnabled();
return servers.map((s) => this.mapToServerConfig(s));
}
async findByType(type: string): Promise<ServerConfigWithName[]> {
const allServers = await this.repository.findAll();
return allServers.filter((s) => s.type === type).map((s) => this.mapToServerConfig(s));
}
async setEnabled(name: string, enabled: boolean): Promise<boolean> {
const server = await this.repository.setEnabled(name, enabled);
return server !== null;
}
async updateTools(
name: string,
tools: Record<string, { enabled: boolean; description?: string }>,
): Promise<boolean> {
const result = await this.update(name, { tools });
return result !== null;
}
async updatePrompts(
name: string,
prompts: Record<string, { enabled: boolean; description?: string }>,
): Promise<boolean> {
const result = await this.update(name, { prompts });
return result !== null;
}
private mapToServerConfig(server: {
name: string;
type?: string;
url?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
headers?: Record<string, string>;
enabled: boolean;
owner?: string;
keepAliveInterval?: number;
tools?: Record<string, { enabled: boolean; description?: string }>;
prompts?: Record<string, { enabled: boolean; description?: string }>;
options?: Record<string, any>;
oauth?: Record<string, any>;
}): ServerConfigWithName {
return {
name: server.name,
type: server.type as 'stdio' | 'sse' | 'streamable-http' | 'openapi' | undefined,
url: server.url,
command: server.command,
args: server.args,
env: server.env,
headers: server.headers,
enabled: server.enabled,
owner: server.owner,
keepAliveInterval: server.keepAliveInterval,
tools: server.tools,
prompts: server.prompts,
options: server.options,
oauth: server.oauth,
};
}
}

View File

@@ -0,0 +1,68 @@
import { SystemConfigDao } from './index.js';
import { SystemConfig } from '../types/index.js';
import { SystemConfigRepository } from '../db/repositories/SystemConfigRepository.js';
/**
* Database-backed implementation of SystemConfigDao
*/
export class SystemConfigDaoDbImpl implements SystemConfigDao {
private repository: SystemConfigRepository;
constructor() {
this.repository = new SystemConfigRepository();
}
async get(): Promise<SystemConfig> {
const config = await this.repository.get();
return {
routing: config.routing as any,
install: config.install as any,
smartRouting: config.smartRouting as any,
mcpRouter: config.mcpRouter as any,
nameSeparator: config.nameSeparator,
oauth: config.oauth as any,
oauthServer: config.oauthServer as any,
enableSessionRebuild: config.enableSessionRebuild,
};
}
async update(config: Partial<SystemConfig>): Promise<SystemConfig> {
const updated = await this.repository.update(config as any);
return {
routing: updated.routing as any,
install: updated.install as any,
smartRouting: updated.smartRouting as any,
mcpRouter: updated.mcpRouter as any,
nameSeparator: updated.nameSeparator,
oauth: updated.oauth as any,
oauthServer: updated.oauthServer as any,
enableSessionRebuild: updated.enableSessionRebuild,
};
}
async reset(): Promise<SystemConfig> {
const config = await this.repository.reset();
return {
routing: config.routing as any,
install: config.install as any,
smartRouting: config.smartRouting as any,
mcpRouter: config.mcpRouter as any,
nameSeparator: config.nameSeparator,
oauth: config.oauth as any,
oauthServer: config.oauthServer as any,
enableSessionRebuild: config.enableSessionRebuild,
};
}
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K]> {
return (await this.repository.getSection(section)) as any;
}
async updateSection<K extends keyof SystemConfig>(
section: K,
value: SystemConfig[K],
): Promise<boolean> {
await this.repository.updateSection(section, value as any);
return true;
}
}

View File

@@ -0,0 +1,79 @@
import { UserConfigDao } from './index.js';
import { UserConfig } from '../types/index.js';
import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js';
/**
* Database-backed implementation of UserConfigDao
*/
export class UserConfigDaoDbImpl implements UserConfigDao {
private repository: UserConfigRepository;
constructor() {
this.repository = new UserConfigRepository();
}
async getAll(): Promise<Record<string, UserConfig>> {
const configs = await this.repository.getAll();
const result: Record<string, UserConfig> = {};
for (const [username, config] of Object.entries(configs)) {
result[username] = {
routing: config.routing,
...config.additionalConfig,
};
}
return result;
}
async get(username: string): Promise<UserConfig> {
const config = await this.repository.get(username);
if (!config) {
return { routing: {} };
}
return {
routing: config.routing,
...config.additionalConfig,
};
}
async update(username: string, config: Partial<UserConfig>): Promise<UserConfig> {
const { routing, ...additionalConfig } = config;
const updated = await this.repository.update(username, {
routing,
additionalConfig,
});
return {
routing: updated.routing,
...updated.additionalConfig,
};
}
async delete(username: string): Promise<boolean> {
return await this.repository.delete(username);
}
async getSection<K extends keyof UserConfig>(username: string, section: K): Promise<UserConfig[K]> {
const config = await this.get(username);
return config[section];
}
async updateSection<K extends keyof UserConfig>(
username: string,
section: K,
value: UserConfig[K],
): Promise<boolean> {
await this.update(username, { [section]: value } as Partial<UserConfig>);
return true;
}
async exists(username: string): Promise<boolean> {
const config = await this.repository.get(username);
return config !== null;
}
async reset(username: string): Promise<UserConfig> {
await this.repository.delete(username);
return { routing: {} };
}
}

108
src/dao/UserDaoDbImpl.ts Normal file
View File

@@ -0,0 +1,108 @@
import bcrypt from 'bcrypt';
import { UserDao } from './index.js';
import { IUser } from '../types/index.js';
import { UserRepository } from '../db/repositories/UserRepository.js';
/**
* Database-backed implementation of UserDao
*/
export class UserDaoDbImpl implements UserDao {
private repository: UserRepository;
constructor() {
this.repository = new UserRepository();
}
async findAll(): Promise<IUser[]> {
const users = await this.repository.findAll();
return users.map((u) => ({
username: u.username,
password: u.password,
isAdmin: u.isAdmin,
}));
}
async findById(username: string): Promise<IUser | null> {
const user = await this.repository.findByUsername(username);
if (!user) return null;
return {
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
}
async findByUsername(username: string): Promise<IUser | null> {
return await this.findById(username);
}
async create(entity: Omit<IUser, 'id'>): Promise<IUser> {
const user = await this.repository.create({
username: entity.username,
password: entity.password,
isAdmin: entity.isAdmin || false,
});
return {
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
}
async createWithHashedPassword(
username: string,
password: string,
isAdmin: boolean,
): Promise<IUser> {
const hashedPassword = await bcrypt.hash(password, 10);
return await this.create({ username, password: hashedPassword, isAdmin });
}
async update(username: string, entity: Partial<IUser>): Promise<IUser | null> {
const user = await this.repository.update(username, {
password: entity.password,
isAdmin: entity.isAdmin,
});
if (!user) return null;
return {
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
}
async delete(username: string): Promise<boolean> {
return await this.repository.delete(username);
}
async exists(username: string): Promise<boolean> {
return await this.repository.exists(username);
}
async count(): Promise<number> {
return await this.repository.count();
}
async validateCredentials(username: string, password: string): Promise<boolean> {
const user = await this.findByUsername(username);
if (!user) {
return false;
}
return await bcrypt.compare(password, user.password);
}
async updatePassword(username: string, newPassword: string): Promise<boolean> {
const hashedPassword = await bcrypt.hash(newPassword, 10);
const result = await this.update(username, { password: hashedPassword });
return result !== null;
}
async findAdmins(): Promise<IUser[]> {
const users = await this.repository.findAdmins();
return users.map((u) => ({
username: u.username,
password: u.password,
isAdmin: u.isAdmin,
}));
}
}

View File

@@ -7,5 +7,13 @@ export * from './GroupDao.js';
export * from './SystemConfigDao.js';
export * from './UserConfigDao.js';
// Export database implementations
export * from './UserDaoDbImpl.js';
export * from './ServerDaoDbImpl.js';
export * from './GroupDaoDbImpl.js';
export * from './SystemConfigDaoDbImpl.js';
export * from './UserConfigDaoDbImpl.js';
// Export the DAO factory and convenience functions
export * from './DaoFactory.js';
export * from './DatabaseDaoFactory.js';

36
src/db/entities/Group.ts Normal file
View File

@@ -0,0 +1,36 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* Group entity for database storage
*/
@Entity({ name: 'groups' })
export class Group {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ type: 'simple-json' })
servers: Array<string | { name: string; tools?: string[] | 'all' }>;
@Column({ type: 'varchar', length: 255, nullable: true })
owner?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default Group;

66
src/db/entities/Server.ts Normal file
View File

@@ -0,0 +1,66 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* Server configuration entity for database storage
*/
@Entity({ name: 'servers' })
export class Server {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, unique: true })
name: string;
@Column({ type: 'varchar', length: 50, nullable: true })
type?: string; // 'stdio', 'sse', 'streamable-http', 'openapi'
@Column({ type: 'text', nullable: true })
url?: string;
@Column({ type: 'varchar', length: 500, nullable: true })
command?: string;
@Column({ type: 'simple-json', nullable: true })
args?: string[];
@Column({ type: 'simple-json', nullable: true })
env?: Record<string, string>;
@Column({ type: 'simple-json', nullable: true })
headers?: Record<string, string>;
@Column({ type: 'boolean', default: true })
enabled: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
owner?: string;
@Column({ type: 'int', nullable: true })
keepAliveInterval?: number;
@Column({ type: 'simple-json', nullable: true })
tools?: Record<string, { enabled: boolean; description?: string }>;
@Column({ type: 'simple-json', nullable: true })
prompts?: Record<string, { enabled: boolean; description?: string }>;
@Column({ type: 'simple-json', nullable: true })
options?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
oauth?: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default Server;

View File

@@ -0,0 +1,43 @@
import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
/**
* System configuration entity for database storage
* Using singleton pattern - only one record with id = 'default'
*/
@Entity({ name: 'system_config' })
export class SystemConfig {
@PrimaryColumn({ type: 'varchar', length: 50, default: 'default' })
id: string;
@Column({ type: 'simple-json', nullable: true })
routing?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
install?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
smartRouting?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
mcpRouter?: Record<string, any>;
@Column({ type: 'varchar', length: 10, nullable: true })
nameSeparator?: string;
@Column({ type: 'simple-json', nullable: true })
oauth?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
oauthServer?: Record<string, any>;
@Column({ type: 'boolean', nullable: true })
enableSessionRebuild?: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default SystemConfig;

33
src/db/entities/User.ts Normal file
View File

@@ -0,0 +1,33 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* User entity for database storage
*/
@Entity({ name: 'users' })
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, unique: true })
username: string;
@Column({ type: 'varchar', length: 255 })
password: string;
@Column({ type: 'boolean', default: false })
isAdmin: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default User;

View File

@@ -0,0 +1,33 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* User configuration entity for database storage
*/
@Entity({ name: 'user_configs' })
export class UserConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, unique: true })
username: string;
@Column({ type: 'simple-json', nullable: true })
routing?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
additionalConfig?: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default UserConfig;

View File

@@ -1,7 +1,12 @@
import { VectorEmbedding } from './VectorEmbedding.js';
import User from './User.js';
import Server from './Server.js';
import Group from './Group.js';
import SystemConfig from './SystemConfig.js';
import UserConfig from './UserConfig.js';
// Export all entities
export default [VectorEmbedding];
export default [VectorEmbedding, User, Server, Group, SystemConfig, UserConfig];
// Export individual entities for direct use
export { VectorEmbedding };
export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig };

View File

@@ -0,0 +1,95 @@
import { Repository } from 'typeorm';
import { Group } from '../entities/Group.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for Group entity
*/
export class GroupRepository {
private repository: Repository<Group>;
constructor() {
this.repository = getAppDataSource().getRepository(Group);
}
/**
* Find all groups
*/
async findAll(): Promise<Group[]> {
return await this.repository.find();
}
/**
* Find group by ID
*/
async findById(id: string): Promise<Group | null> {
return await this.repository.findOne({ where: { id } });
}
/**
* Find group by name
*/
async findByName(name: string): Promise<Group | null> {
return await this.repository.findOne({ where: { name } });
}
/**
* Create a new group
*/
async create(group: Omit<Group, 'id' | 'createdAt' | 'updatedAt'>): Promise<Group> {
const newGroup = this.repository.create(group);
return await this.repository.save(newGroup);
}
/**
* Update an existing group
*/
async update(id: string, groupData: Partial<Group>): Promise<Group | null> {
const group = await this.findById(id);
if (!group) {
return null;
}
const updated = this.repository.merge(group, groupData);
return await this.repository.save(updated);
}
/**
* Delete a group
*/
async delete(id: string): Promise<boolean> {
const result = await this.repository.delete({ id });
return (result.affected ?? 0) > 0;
}
/**
* Check if group exists by ID
*/
async exists(id: string): Promise<boolean> {
const count = await this.repository.count({ where: { id } });
return count > 0;
}
/**
* Check if group exists by name
*/
async existsByName(name: string): Promise<boolean> {
const count = await this.repository.count({ where: { name } });
return count > 0;
}
/**
* Count total groups
*/
async count(): Promise<number> {
return await this.repository.count();
}
/**
* Find groups by owner
*/
async findByOwner(owner: string): Promise<Group[]> {
return await this.repository.find({ where: { owner } });
}
}
export default GroupRepository;

View File

@@ -0,0 +1,94 @@
import { Repository } from 'typeorm';
import { Server } from '../entities/Server.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for Server entity
*/
export class ServerRepository {
private repository: Repository<Server>;
constructor() {
this.repository = getAppDataSource().getRepository(Server);
}
/**
* Find all servers
*/
async findAll(): Promise<Server[]> {
return await this.repository.find();
}
/**
* Find server by name
*/
async findByName(name: string): Promise<Server | null> {
return await this.repository.findOne({ where: { name } });
}
/**
* Create a new server
*/
async create(server: Omit<Server, 'id' | 'createdAt' | 'updatedAt'>): Promise<Server> {
const newServer = this.repository.create(server);
return await this.repository.save(newServer);
}
/**
* Update an existing server
*/
async update(name: string, serverData: Partial<Server>): Promise<Server | null> {
const server = await this.findByName(name);
if (!server) {
return null;
}
const updated = this.repository.merge(server, serverData);
return await this.repository.save(updated);
}
/**
* Delete a server
*/
async delete(name: string): Promise<boolean> {
const result = await this.repository.delete({ name });
return (result.affected ?? 0) > 0;
}
/**
* Check if server exists
*/
async exists(name: string): Promise<boolean> {
const count = await this.repository.count({ where: { name } });
return count > 0;
}
/**
* Count total servers
*/
async count(): Promise<number> {
return await this.repository.count();
}
/**
* Find servers by owner
*/
async findByOwner(owner: string): Promise<Server[]> {
return await this.repository.find({ where: { owner } });
}
/**
* Find enabled servers
*/
async findEnabled(): Promise<Server[]> {
return await this.repository.find({ where: { enabled: true } });
}
/**
* Set server enabled status
*/
async setEnabled(name: string, enabled: boolean): Promise<Server | null> {
return await this.update(name, { enabled });
}
}
export default ServerRepository;

View File

@@ -0,0 +1,78 @@
import { Repository } from 'typeorm';
import { SystemConfig } from '../entities/SystemConfig.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for SystemConfig entity
* Uses singleton pattern with id = 'default'
*/
export class SystemConfigRepository {
private repository: Repository<SystemConfig>;
private readonly DEFAULT_ID = 'default';
constructor() {
this.repository = getAppDataSource().getRepository(SystemConfig);
}
/**
* Get system configuration (singleton)
*/
async get(): Promise<SystemConfig> {
let config = await this.repository.findOne({ where: { id: this.DEFAULT_ID } });
// Create default if doesn't exist
if (!config) {
config = this.repository.create({
id: this.DEFAULT_ID,
routing: {},
install: {},
smartRouting: {},
mcpRouter: {},
nameSeparator: '-',
oauth: {},
oauthServer: {},
enableSessionRebuild: false,
});
config = await this.repository.save(config);
}
return config;
}
/**
* Update system configuration
*/
async update(configData: Partial<SystemConfig>): Promise<SystemConfig> {
const config = await this.get();
const updated = this.repository.merge(config, configData);
return await this.repository.save(updated);
}
/**
* Reset system configuration to defaults
*/
async reset(): Promise<SystemConfig> {
await this.repository.delete({ id: this.DEFAULT_ID });
return await this.get();
}
/**
* Get a specific configuration section
*/
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K]> {
const config = await this.get();
return config[section];
}
/**
* Update a specific configuration section
*/
async updateSection<K extends keyof SystemConfig>(
section: K,
value: SystemConfig[K],
): Promise<SystemConfig> {
return await this.update({ [section]: value } as Partial<SystemConfig>);
}
}
export default SystemConfigRepository;

View File

@@ -0,0 +1,84 @@
import { Repository } from 'typeorm';
import { UserConfig } from '../entities/UserConfig.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for UserConfig entity
*/
export class UserConfigRepository {
private repository: Repository<UserConfig>;
constructor() {
this.repository = getAppDataSource().getRepository(UserConfig);
}
/**
* Get all user configs
*/
async getAll(): Promise<Record<string, UserConfig>> {
const configs = await this.repository.find();
const result: Record<string, UserConfig> = {};
for (const config of configs) {
result[config.username] = config;
}
return result;
}
/**
* Get user config by username
*/
async get(username: string): Promise<UserConfig | null> {
return await this.repository.findOne({ where: { username } });
}
/**
* Update user config
*/
async update(username: string, configData: Partial<UserConfig>): Promise<UserConfig> {
let config = await this.get(username);
if (!config) {
// Create new config if doesn't exist
config = this.repository.create({
username,
routing: {},
additionalConfig: {},
...configData,
});
} else {
// Merge with existing config
config = this.repository.merge(config, configData);
}
return await this.repository.save(config);
}
/**
* Delete user config
*/
async delete(username: string): Promise<boolean> {
const result = await this.repository.delete({ username });
return (result.affected ?? 0) > 0;
}
/**
* Get a specific configuration section for a user
*/
async getSection<K extends keyof UserConfig>(username: string, section: K): Promise<UserConfig[K] | null> {
const config = await this.get(username);
return config ? config[section] : null;
}
/**
* Update a specific configuration section for a user
*/
async updateSection<K extends keyof UserConfig>(
username: string,
section: K,
value: UserConfig[K],
): Promise<UserConfig> {
return await this.update(username, { [section]: value } as Partial<UserConfig>);
}
}
export default UserConfigRepository;

View File

@@ -0,0 +1,80 @@
import { Repository } from 'typeorm';
import { User } from '../entities/User.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for User entity
*/
export class UserRepository {
private repository: Repository<User>;
constructor() {
this.repository = getAppDataSource().getRepository(User);
}
/**
* Find all users
*/
async findAll(): Promise<User[]> {
return await this.repository.find();
}
/**
* Find user by username
*/
async findByUsername(username: string): Promise<User | null> {
return await this.repository.findOne({ where: { username } });
}
/**
* Create a new user
*/
async create(user: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
const newUser = this.repository.create(user);
return await this.repository.save(newUser);
}
/**
* Update an existing user
*/
async update(username: string, userData: Partial<User>): Promise<User | null> {
const user = await this.findByUsername(username);
if (!user) {
return null;
}
const updated = this.repository.merge(user, userData);
return await this.repository.save(updated);
}
/**
* Delete a user
*/
async delete(username: string): Promise<boolean> {
const result = await this.repository.delete({ username });
return (result.affected ?? 0) > 0;
}
/**
* Check if user exists
*/
async exists(username: string): Promise<boolean> {
const count = await this.repository.count({ where: { username } });
return count > 0;
}
/**
* Count total users
*/
async count(): Promise<number> {
return await this.repository.count();
}
/**
* Find all admin users
*/
async findAdmins(): Promise<User[]> {
return await this.repository.find({ where: { isAdmin: true } });
}
}
export default UserRepository;

View File

@@ -1,4 +1,16 @@
import VectorEmbeddingRepository from './VectorEmbeddingRepository.js';
import { UserRepository } from './UserRepository.js';
import { ServerRepository } from './ServerRepository.js';
import { GroupRepository } from './GroupRepository.js';
import { SystemConfigRepository } from './SystemConfigRepository.js';
import { UserConfigRepository } from './UserConfigRepository.js';
// Export all repositories
export { VectorEmbeddingRepository };
export {
VectorEmbeddingRepository,
UserRepository,
ServerRepository,
GroupRepository,
SystemConfigRepository,
UserConfigRepository,
};

View File

@@ -1,10 +1,24 @@
import 'reflect-metadata';
import AppServer from './server.js';
import { initializeDatabaseMode } from './utils/migration.js';
const appServer = new AppServer();
async function boot() {
try {
// Check if database mode is enabled
// If USE_DB is explicitly set, use its value; otherwise, auto-detect based on DB_URL presence
const useDatabase =
process.env.USE_DB !== undefined ? process.env.USE_DB === 'true' : !!process.env.DB_URL;
if (useDatabase) {
console.log('Database mode enabled, initializing...');
const dbInitialized = await initializeDatabaseMode();
if (!dbInitialized) {
console.error('Failed to initialize database mode');
process.exit(1);
}
}
await appServer.initialize();
appServer.start();
} catch (error) {

View File

@@ -72,8 +72,8 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro
if (oauthToken && oauthToken.accessToken === accessToken) {
// Valid OAuth token - look up user to get admin status
const { findUserByUsername } = await import('../models/User.js');
const user = findUserByUsername(oauthToken.username);
const user = await findUserByUsername(oauthToken.username);
// Set user context with proper admin status
(req as any).user = {
username: oauthToken.username,

View File

@@ -76,7 +76,7 @@ export const sseUserContextMiddleware = async (
const rawAuthHeader = Array.isArray(req.headers.authorization)
? req.headers.authorization[0]
: req.headers.authorization;
const bearerUser = resolveOAuthUserFromAuthHeader(rawAuthHeader);
const bearerUser = await resolveOAuthUserFromAuthHeader(rawAuthHeader);
if (bearerUser) {
userContextService.setCurrentUser(bearerUser);

View File

@@ -1,58 +1,43 @@
import bcrypt from 'bcryptjs';
import { IUser } from '../types/index.js';
import { loadSettings, saveSettings } from '../config/index.js';
import { getUserDao } from '../dao/index.js';
// Get all users
export const getUsers = (): IUser[] => {
export const getUsers = async (): Promise<IUser[]> => {
try {
const settings = loadSettings();
return settings.users || [];
const userDao = getUserDao();
return await userDao.findAll();
} catch (error) {
console.error('Error reading users from settings:', error);
console.error('Error reading users:', error);
return [];
}
};
// Save users to settings
const saveUsers = (users: IUser[]): void => {
try {
const settings = loadSettings();
settings.users = users;
saveSettings(settings);
} catch (error) {
console.error('Error saving users to settings:', error);
}
};
// Create a new user
export const createUser = async (userData: IUser): Promise<IUser | null> => {
const users = getUsers();
// Check if username already exists
if (users.some((user) => user.username === userData.username)) {
try {
const userDao = getUserDao();
return await userDao.createWithHashedPassword(
userData.username,
userData.password,
userData.isAdmin,
);
} catch (error) {
console.error('Error creating user:', error);
return null;
}
// Hash the password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(userData.password, salt);
const newUser = {
username: userData.username,
password: hashedPassword,
isAdmin: userData.isAdmin || false,
};
users.push(newUser);
saveUsers(users);
return newUser;
};
// Find user by username
export const findUserByUsername = (username: string): IUser | undefined => {
const users = getUsers();
return users.find((user) => user.username === username);
export const findUserByUsername = async (username: string): Promise<IUser | undefined> => {
try {
const userDao = getUserDao();
const user = await userDao.findByUsername(username);
return user || undefined;
} catch (error) {
console.error('Error finding user:', error);
return undefined;
}
};
// Verify user password
@@ -68,34 +53,22 @@ export const updateUserPassword = async (
username: string,
newPassword: string,
): Promise<boolean> => {
const users = getUsers();
const userIndex = users.findIndex((user) => user.username === username);
if (userIndex === -1) {
try {
const userDao = getUserDao();
return await userDao.updatePassword(username, newPassword);
} catch (error) {
console.error('Error updating password:', error);
return false;
}
// Hash the new password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(newPassword, salt);
// Update the user's password
users[userIndex].password = hashedPassword;
saveUsers(users);
return true;
};
// Initialize with default admin user if no users exist
export const initializeDefaultUser = async (): Promise<void> => {
const users = getUsers();
const userDao = getUserDao();
const users = await userDao.findAll();
if (users.length === 0) {
await createUser({
username: 'admin',
password: 'admin123',
isAdmin: true,
});
await userDao.createWithHashedPassword('admin', 'admin123', true);
console.log('Default admin user created');
}
};

View File

@@ -4,6 +4,7 @@ import config from '../config/index.js';
import {
getAllServers,
getAllSettings,
getServerConfig,
createServer,
updateServer,
deleteServer,
@@ -129,6 +130,7 @@ export const initRoutes = (app: express.Application): void => {
// API routes protected by auth middleware in middlewares/index.ts
router.get('/servers', getAllServers);
router.get('/servers/:name', getServerConfig);
router.get('/settings', getAllSettings);
router.post('/servers', createServer);
router.put('/servers/:name', updateServer);

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env node
import 'reflect-metadata';
import { runMigrationCli } from '../utils/migration.js';
runMigrationCli();

View File

@@ -61,7 +61,7 @@ export class AppServer {
await initializeDefaultUser();
// Initialize OAuth provider if configured (for proxying upstream MCP OAuth)
initOAuthProvider();
await initOAuthProvider();
const oauthRouter = getOAuthRouter();
if (oauthRouter) {
// Mount OAuth router at the root level (before other routes)
@@ -71,7 +71,7 @@ export class AppServer {
}
// Initialize OAuth authorization server (for MCPHub's own OAuth)
initOAuthServer();
await initOAuthServer();
initMiddlewares(this.app);
initRoutes(this.app);
@@ -103,8 +103,10 @@ export class AppServer {
);
// User-scoped routes with user context middleware
this.app.get(`${this.basePath}/:user/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
handleSseConnection(req, res),
this.app.get(
`${this.basePath}/:user/sse/:group(.*)?`,
sseUserContextMiddleware,
(req, res) => handleSseConnection(req, res),
);
this.app.post(
`${this.basePath}/:user/messages`,

View File

@@ -1,8 +1,8 @@
import { v4 as uuidv4 } from 'uuid';
import { IGroup, IGroupServerConfig } from '../types/index.js';
import { loadSettings, saveSettings } from '../config/index.js';
import { notifyToolChanged } from './mcpService.js';
import { getDataService } from './services.js';
import { getGroupDao, getServerDao, getSystemConfigDao } from '../dao/index.js';
// Helper function to normalize group servers configuration
const normalizeGroupServers = (servers: string[] | IGroupServerConfig[]): IGroupServerConfig[] => {
@@ -17,22 +17,24 @@ const normalizeGroupServers = (servers: string[] | IGroupServerConfig[]): IGroup
};
// Get all groups
export const getAllGroups = (): IGroup[] => {
const settings = loadSettings();
export const getAllGroups = async (): Promise<IGroup[]> => {
const groupDao = getGroupDao();
const groups = await groupDao.findAll();
const dataService = getDataService();
return dataService.filterData
? dataService.filterData(settings.groups || [])
: settings.groups || [];
return dataService.filterData ? dataService.filterData(groups) : groups;
};
// Get group by ID or name
export const getGroupByIdOrName = (key: string): IGroup | undefined => {
const settings = loadSettings();
const routingConfig = settings.systemConfig?.routing || {
export const getGroupByIdOrName = async (key: string): Promise<IGroup | undefined> => {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const routingConfig = systemConfig?.routing || {
enableGlobalRoute: true,
enableGroupNameRoute: true,
};
const groups = getAllGroups();
const groups = await getAllGroups();
return (
groups.find(
(group) => group.id === key || (group.name === key && routingConfig.enableGroupNameRoute),
@@ -41,25 +43,28 @@ export const getGroupByIdOrName = (key: string): IGroup | undefined => {
};
// Create a new group
export const createGroup = (
export const createGroup = async (
name: string,
description?: string,
servers: string[] | IGroupServerConfig[] = [],
owner?: string,
): IGroup | null => {
): Promise<IGroup | null> => {
try {
const settings = loadSettings();
const groups = settings.groups || [];
const groupDao = getGroupDao();
const serverDao = getServerDao();
// Check if group with same name already exists
if (groups.some((group) => group.name === name)) {
const existingGroup = await groupDao.findByName(name);
if (existingGroup) {
return null;
}
// Normalize servers configuration and filter out non-existent servers
const normalizedServers = normalizeGroupServers(servers);
const validServers: IGroupServerConfig[] = normalizedServers.filter(
(serverConfig) => settings.mcpServers[serverConfig.name],
const allServers = await serverDao.findAll();
const serverNames = new Set(allServers.map((s) => s.name));
const validServers: IGroupServerConfig[] = normalizedServers.filter((serverConfig) =>
serverNames.has(serverConfig.name),
);
const newGroup: IGroup = {
@@ -70,18 +75,8 @@ export const createGroup = (
owner: owner || 'admin',
};
// Initialize groups array if it doesn't exist
if (!settings.groups) {
settings.groups = [];
}
settings.groups.push(newGroup);
if (!saveSettings(settings)) {
return null;
}
return newGroup;
const createdGroup = await groupDao.create(newGroup);
return createdGroup;
} catch (error) {
console.error('Failed to create group:', error);
return null;
@@ -89,43 +84,38 @@ export const createGroup = (
};
// Update an existing group
export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null => {
export const updateGroup = async (id: string, data: Partial<IGroup>): Promise<IGroup | null> => {
try {
const settings = loadSettings();
if (!settings.groups) {
return null;
}
const groupDao = getGroupDao();
const serverDao = getServerDao();
const groupIndex = settings.groups.findIndex((group) => group.id === id);
if (groupIndex === -1) {
const existingGroup = await groupDao.findById(id);
if (!existingGroup) {
return null;
}
// Check for name uniqueness if name is being updated
if (data.name && settings.groups.some((g) => g.name === data.name && g.id !== id)) {
return null;
if (data.name && data.name !== existingGroup.name) {
const groupWithName = await groupDao.findByName(data.name);
if (groupWithName) {
return null;
}
}
// If servers array is provided, validate server existence and normalize format
if (data.servers) {
const normalizedServers = normalizeGroupServers(data.servers);
data.servers = normalizedServers.filter(
(serverConfig) => settings.mcpServers[serverConfig.name],
);
const allServers = await serverDao.findAll();
const serverNames = new Set(allServers.map((s) => s.name));
data.servers = normalizedServers.filter((serverConfig) => serverNames.has(serverConfig.name));
}
const updatedGroup = {
...settings.groups[groupIndex],
...data,
};
const updatedGroup = await groupDao.update(id, data);
settings.groups[groupIndex] = updatedGroup;
if (!saveSettings(settings)) {
return null;
if (updatedGroup) {
notifyToolChanged();
}
notifyToolChanged();
return updatedGroup;
} catch (error) {
console.error(`Failed to update group ${id}:`, error);
@@ -135,35 +125,34 @@ export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null =>
// Update servers in a group (batch update)
// Update group servers (maintaining backward compatibility)
export const updateGroupServers = (
export const updateGroupServers = async (
groupId: string,
servers: string[] | IGroupServerConfig[],
): IGroup | null => {
): Promise<IGroup | null> => {
try {
const settings = loadSettings();
if (!settings.groups) {
return null;
}
const groupDao = getGroupDao();
const serverDao = getServerDao();
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
if (groupIndex === -1) {
const existingGroup = await groupDao.findById(groupId);
if (!existingGroup) {
return null;
}
// Normalize and filter out non-existent servers
const normalizedServers = normalizeGroupServers(servers);
const validServers = normalizedServers.filter(
(serverConfig) => settings.mcpServers[serverConfig.name],
const allServers = await serverDao.findAll();
const serverNames = new Set(allServers.map((s) => s.name));
const validServers = normalizedServers.filter((serverConfig) =>
serverNames.has(serverConfig.name),
);
settings.groups[groupIndex].servers = validServers;
const updatedGroup = await groupDao.update(groupId, { servers: validServers });
if (!saveSettings(settings)) {
return null;
if (updatedGroup) {
notifyToolChanged();
}
notifyToolChanged();
return settings.groups[groupIndex];
return updatedGroup;
} catch (error) {
console.error(`Failed to update servers for group ${groupId}:`, error);
return null;
@@ -171,21 +160,10 @@ export const updateGroupServers = (
};
// Delete a group
export const deleteGroup = (id: string): boolean => {
export const deleteGroup = async (id: string): Promise<boolean> => {
try {
const settings = loadSettings();
if (!settings.groups) {
return false;
}
const initialLength = settings.groups.length;
settings.groups = settings.groups.filter((group) => group.id !== id);
if (settings.groups.length === initialLength) {
return false;
}
return saveSettings(settings);
const groupDao = getGroupDao();
return await groupDao.delete(id);
} catch (error) {
console.error(`Failed to delete group ${id}:`, error);
return false;
@@ -193,34 +171,37 @@ export const deleteGroup = (id: string): boolean => {
};
// Add server to group
export const addServerToGroup = (groupId: string, serverName: string): IGroup | null => {
export const addServerToGroup = async (
groupId: string,
serverName: string,
): Promise<IGroup | null> => {
try {
const settings = loadSettings();
if (!settings.groups) {
return null;
}
const groupDao = getGroupDao();
const serverDao = getServerDao();
// Verify server exists
if (!settings.mcpServers[serverName]) {
const server = await serverDao.findById(serverName);
if (!server) {
return null;
}
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
if (groupIndex === -1) {
const group = await groupDao.findById(groupId);
if (!group) {
return null;
}
const group = settings.groups[groupIndex];
const normalizedServers = normalizeGroupServers(group.servers);
// Add server to group if not already in it
if (!normalizedServers.some((server) => server.name === serverName)) {
if (!normalizedServers.some((s) => s.name === serverName)) {
normalizedServers.push({ name: serverName, tools: 'all' });
group.servers = normalizedServers;
const updatedGroup = await groupDao.update(groupId, { servers: normalizedServers });
if (!saveSettings(settings)) {
return null;
if (updatedGroup) {
notifyToolChanged();
}
return updatedGroup;
}
notifyToolChanged();
@@ -232,27 +213,22 @@ export const addServerToGroup = (groupId: string, serverName: string): IGroup |
};
// Remove server from group
export const removeServerFromGroup = (groupId: string, serverName: string): IGroup | null => {
export const removeServerFromGroup = async (
groupId: string,
serverName: string,
): Promise<IGroup | null> => {
try {
const settings = loadSettings();
if (!settings.groups) {
const groupDao = getGroupDao();
const group = await groupDao.findById(groupId);
if (!group) {
return null;
}
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
if (groupIndex === -1) {
return null;
}
const group = settings.groups[groupIndex];
const normalizedServers = normalizeGroupServers(group.servers);
group.servers = normalizedServers.filter((server) => server.name !== serverName);
const filteredServers = normalizedServers.filter((server) => server.name !== serverName);
if (!saveSettings(settings)) {
return null;
}
return group;
return await groupDao.update(groupId, { servers: filteredServers });
} catch (error) {
console.error(`Failed to remove server ${serverName} from group ${groupId}:`, error);
return null;
@@ -260,71 +236,69 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
};
// Get all servers in a group
export const getServersInGroup = (groupId: string): string[] => {
const group = getGroupByIdOrName(groupId);
export const getServersInGroup = async (groupId: string): Promise<string[]> => {
const group = await getGroupByIdOrName(groupId);
if (!group) return [];
const normalizedServers = normalizeGroupServers(group.servers);
return normalizedServers.map((server) => server.name);
};
// Get server configuration from group (including tool selection)
export const getServerConfigInGroup = (
export const getServerConfigInGroup = async (
groupId: string,
serverName: string,
): IGroupServerConfig | undefined => {
const group = getGroupByIdOrName(groupId);
): Promise<IGroupServerConfig | undefined> => {
const group = await getGroupByIdOrName(groupId);
if (!group) return undefined;
const normalizedServers = normalizeGroupServers(group.servers);
return normalizedServers.find((server) => server.name === serverName);
};
// Get all server configurations in a group
export const getServerConfigsInGroup = (groupId: string): IGroupServerConfig[] => {
const group = getGroupByIdOrName(groupId);
export const getServerConfigsInGroup = async (groupId: string): Promise<IGroupServerConfig[]> => {
const group = await getGroupByIdOrName(groupId);
if (!group) return [];
return normalizeGroupServers(group.servers);
};
// Update tools selection for a specific server in a group
export const updateServerToolsInGroup = (
export const updateServerToolsInGroup = async (
groupId: string,
serverName: string,
tools: string[] | 'all',
): IGroup | null => {
): Promise<IGroup | null> => {
try {
const settings = loadSettings();
if (!settings.groups) {
return null;
}
const groupDao = getGroupDao();
const serverDao = getServerDao();
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
if (groupIndex === -1) {
const group = await groupDao.findById(groupId);
if (!group) {
return null;
}
// Verify server exists
if (!settings.mcpServers[serverName]) {
const server = await serverDao.findById(serverName);
if (!server) {
return null;
}
const group = settings.groups[groupIndex];
const normalizedServers = normalizeGroupServers(group.servers);
const serverIndex = normalizedServers.findIndex((server) => server.name === serverName);
const serverIndex = normalizedServers.findIndex((s) => s.name === serverName);
if (serverIndex === -1) {
return null; // Server not in group
}
// Update the tools configuration for the server
normalizedServers[serverIndex].tools = tools;
group.servers = normalizedServers;
if (!saveSettings(settings)) {
return null;
const updatedGroup = await groupDao.update(groupId, { servers: normalizedServers });
if (updatedGroup) {
notifyToolChanged();
}
notifyToolChanged();
return group;
return updatedGroup;
} catch (error) {
console.error(`Failed to update tools for server ${serverName} in group ${groupId}:`, error);
return null;

View File

@@ -20,7 +20,7 @@ import type {
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import { ServerConfig } from '../types/index.js';
import { loadSettings } from '../config/index.js';
import { getSystemConfigDao } from '../dao/index.js';
import {
initializeOAuthForServer,
getRegisteredClient,
@@ -52,15 +52,29 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
private serverConfig: ServerConfig;
private _codeVerifier?: string;
private _currentState?: string;
private _systemInstallBaseUrl?: string;
constructor(serverName: string, serverConfig: ServerConfig) {
constructor(serverName: string, serverConfig: ServerConfig, systemInstallBaseUrl?: string) {
this.serverName = serverName;
this.serverConfig = serverConfig;
this._systemInstallBaseUrl = systemInstallBaseUrl;
}
/**
* Factory method to create an MCPHubOAuthProvider with async config loading
*/
static async create(
serverName: string,
serverConfig: ServerConfig,
): Promise<MCPHubOAuthProvider> {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const systemInstallBaseUrl = systemConfig?.install?.baseUrl;
return new MCPHubOAuthProvider(serverName, serverConfig, systemInstallBaseUrl);
}
private getSystemInstallBaseUrl(): string | undefined {
const settings = loadSettings();
return settings.systemConfig?.install?.baseUrl;
return this._systemInstallBaseUrl;
}
private sanitizeRedirectUri(input?: string): string | null {
@@ -219,18 +233,9 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
const clientInfo = getRegisteredClient(this.serverName);
if (!clientInfo) {
// Try to use static client configuration from cached serverConfig first
let serverConfig = this.serverConfig;
// If cached config doesn't have clientId, reload from settings
if (!serverConfig?.oauth?.clientId) {
const storedConfig = loadServerConfig(this.serverName);
if (storedConfig) {
this.serverConfig = storedConfig;
serverConfig = storedConfig;
}
}
// Try to use static client configuration from cached serverConfig
// Note: we only use cache here since this is a sync method
const serverConfig = this.serverConfig;
// Try to use static client configuration from serverConfig
if (serverConfig?.oauth?.clientId) {
@@ -288,17 +293,8 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
* Get stored OAuth tokens
*/
tokens(): OAuthTokens | undefined {
// Use cached config first, but reload if needed
let serverConfig = this.serverConfig;
// If cached config doesn't have tokens, try reloading
if (!serverConfig?.oauth?.accessToken) {
const storedConfig = loadServerConfig(this.serverName);
if (storedConfig) {
this.serverConfig = storedConfig;
serverConfig = storedConfig;
}
}
// Use cached config only (tokens are updated via saveTokens which updates cache)
const serverConfig = this.serverConfig;
if (!serverConfig?.oauth?.accessToken) {
return undefined;
@@ -441,7 +437,7 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
return this._codeVerifier;
}
const storedConfig = loadServerConfig(this.serverName);
const storedConfig = await loadServerConfig(this.serverName);
const storedVerifier = storedConfig?.oauth?.pendingAuthorization?.codeVerifier;
if (storedVerifier) {
@@ -458,7 +454,7 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
* This keeps stored configuration in sync and forces a fresh authorization flow.
*/
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> {
const storedConfig = loadServerConfig(this.serverName);
const storedConfig = await loadServerConfig(this.serverName);
if (!storedConfig?.oauth) {
if (scope === 'verifier' || scope === 'all') {
@@ -585,8 +581,8 @@ export const createOAuthProvider = async (
// Continue anyway - the SDK might be able to handle it
}
// Create and return the provider
const provider = new MCPHubOAuthProvider(serverName, serverConfig);
// Create and return the provider using the factory method
const provider = await MCPHubOAuthProvider.create(serverName, serverConfig);
console.log(`Created OAuth provider for server: ${serverName}`);
return provider;

View File

@@ -15,7 +15,7 @@ import {
StreamableHTTPClientTransportOptions,
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
import { loadSettings, expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
import config from '../config/index.js';
import { getGroup } from './sseService.js';
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
@@ -23,14 +23,12 @@ import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearch
import { OpenAPIClient } from '../clients/openapi.js';
import { RequestContextService } from './requestContextService.js';
import { getDataService } from './services.js';
import { getServerDao, ServerConfigWithName } from '../dao/index.js';
import { getServerDao, getSystemConfigDao, ServerConfigWithName } from '../dao/index.js';
import { initializeAllOAuthClients } from './oauthService.js';
import { createOAuthProvider } from './mcpOAuthProvider.js';
const servers: { [sessionId: string]: Server } = {};
const serverDao = getServerDao();
// Helper function to set up keep-alive ping for SSE connections
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
// Only set up keep-alive for SSE connections
@@ -215,24 +213,25 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
};
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
const settings = loadSettings();
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
if (
settings.systemConfig?.install?.pythonIndexUrl &&
systemConfig?.install?.pythonIndexUrl &&
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
) {
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
env['UV_DEFAULT_INDEX'] = systemConfig.install.pythonIndexUrl;
}
if (
settings.systemConfig?.install?.npmRegistry &&
systemConfig?.install?.npmRegistry &&
(conf.command === 'npm' ||
conf.command === 'npx' ||
conf.command === 'pnpm' ||
conf.command === 'yarn' ||
conf.command === 'node')
) {
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
env['npm_config_registry'] = systemConfig.install.npmRegistry;
}
// Expand environment variables in command
@@ -293,7 +292,7 @@ const callToolWithReconnect = async (
serverInfo.client.close();
serverInfo.transport.close();
const server = await serverDao.findById(serverInfo.name);
const server = await getServerDao().findById(serverInfo.name);
if (!server) {
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
}
@@ -373,7 +372,7 @@ export const initializeClientsFromSettings = async (
isInit: boolean,
serverName?: string,
): Promise<ServerInfo[]> => {
const allServers: ServerConfigWithName[] = await serverDao.findAll();
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
const existingServerInfos = serverInfos;
const nextServerInfos: ServerInfo[] = [];
@@ -650,7 +649,7 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
// Get all server information
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
const allServers: ServerConfigWithName[] = await serverDao.findAll();
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
const dataService = getDataService();
const filterServerInfos: ServerInfo[] = dataService.filterData
? dataService.filterData(serverInfos)
@@ -756,7 +755,7 @@ export const reconnectServer = async (serverName: string): Promise<void> => {
// Filter tools by server configuration
const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<Tool[]> => {
const serverConfig = await serverDao.findById(serverName);
const serverConfig = await getServerDao().findById(serverName);
if (!serverConfig || !serverConfig.tools) {
// If no tool configuration exists, all tools are enabled by default
return tools;
@@ -780,7 +779,7 @@ export const addServer = async (
config: ServerConfig,
): Promise<{ success: boolean; message?: string }> => {
const server: ServerConfigWithName = { name, ...config };
const result = await serverDao.create(server);
const result = await getServerDao().create(server);
if (result) {
return { success: true, message: 'Server added successfully' };
} else {
@@ -792,7 +791,7 @@ export const addServer = async (
export const removeServer = async (
name: string,
): Promise<{ success: boolean; message?: string }> => {
const result = await serverDao.delete(name);
const result = await getServerDao().delete(name);
if (!result) {
return { success: false, message: 'Failed to remove server' };
}
@@ -808,7 +807,7 @@ export const addOrUpdateServer = async (
allowOverride: boolean = false,
): Promise<{ success: boolean; message?: string }> => {
try {
const exists = await serverDao.exists(name);
const exists = await getServerDao().exists(name);
if (exists && !allowOverride) {
return { success: false, message: 'Server name already exists' };
}
@@ -823,9 +822,9 @@ export const addOrUpdateServer = async (
}
if (exists) {
await serverDao.update(name, config);
await getServerDao().update(name, config);
} else {
await serverDao.create({ name, ...config });
await getServerDao().create({ name, ...config });
}
const action = exists ? 'updated' : 'added';
@@ -860,7 +859,7 @@ export const toggleServerStatus = async (
enabled: boolean,
): Promise<{ success: boolean; message?: string }> => {
try {
await serverDao.setEnabled(name, enabled);
await getServerDao().setEnabled(name, enabled);
// If disabling, disconnect the server and remove from active servers
if (!enabled) {
closeServer(name);
@@ -893,33 +892,33 @@ export const handleListToolsRequest = async (_: any, extra: any) => {
if (group === '$smart' || group?.startsWith('$smart/')) {
// Extract target group if pattern is $smart/{group}
const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined;
// Get info about available servers, filtered by target group if specified
let availableServers = serverInfos.filter(
(server) => server.status === 'connected' && server.enabled !== false,
);
// If a target group is specified, filter servers to only those in the group
if (targetGroup) {
const serversInGroup = getServersInGroup(targetGroup);
const serversInGroup = await getServersInGroup(targetGroup);
if (serversInGroup && serversInGroup.length > 0) {
availableServers = availableServers.filter((server) =>
serversInGroup.includes(server.name),
);
}
}
// Create simple server information with only server names
const serversList = availableServers
.map((server) => {
return `${server.name}`;
})
.join(', ');
const scopeDescription = targetGroup
? `servers in the "${targetGroup}" group`
: 'all available servers';
return {
tools: [
{
@@ -973,36 +972,44 @@ Available servers: ${serversList}`,
};
}
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);
});
// Need to filter servers based on group asynchronously
const filteredServerInfos = [];
for (const serverInfo of getDataService().filterData(serverInfos)) {
if (serverInfo.enabled === false) continue;
if (!group) {
filteredServerInfos.push(serverInfo);
continue;
}
const serversInGroup = await getServersInGroup(group);
if (!serversInGroup || serversInGroup.length === 0) {
if (serverInfo.name === group) filteredServerInfos.push(serverInfo);
continue;
}
if (serversInGroup.includes(serverInfo.name)) {
filteredServerInfos.push(serverInfo);
}
}
const allTools = [];
for (const serverInfo of allServerInfos) {
for (const serverInfo of filteredServerInfos) {
if (serverInfo.tools && serverInfo.tools.length > 0) {
// Filter tools based on server configuration
let enabledTools = await filterToolsByConfig(serverInfo.name, serverInfo.tools);
// If this is a group request, apply group-level tool filtering
if (group) {
const serverConfig = getServerConfigInGroup(group, serverInfo.name);
const serverConfig = await getServerConfigInGroup(group, serverInfo.name);
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
// Filter tools based on group configuration
const allowedToolNames = serverConfig.tools.map(
(toolName) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
(toolName: string) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
);
enabledTools = enabledTools.filter((tool) => allowedToolNames.includes(tool.name));
}
}
// Apply custom descriptions from server configuration
const serverConfig = await serverDao.findById(serverInfo.name);
const serverConfig = await getServerDao().findById(serverInfo.name);
const toolsWithCustomDescriptions = enabledTools.map((tool) => {
const toolConfig = serverConfig?.tools?.[tool.name];
return {
@@ -1047,20 +1054,22 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
}
console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`);
// Determine server filtering based on group
const sessionId = extra.sessionId || '';
const group = getGroup(sessionId);
let servers: string[] | undefined = undefined; // No server filtering by default
// If group is in format $smart/{group}, filter servers to that group
if (group?.startsWith('$smart/')) {
const targetGroup = group.substring(7);
const serversInGroup = getServersInGroup(targetGroup);
const serversInGroup = await getServersInGroup(targetGroup);
if (serversInGroup !== undefined && serversInGroup !== null) {
servers = serversInGroup;
if (servers.length > 0) {
console.log(`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`);
if (servers && servers.length > 0) {
console.log(
`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`,
);
} else {
console.log(`Group "${targetGroup}" has no servers, search will return no results`);
}
@@ -1088,7 +1097,7 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
const enabledTools = await filterToolsByConfig(server.name, [actualTool]);
if (enabledTools.length > 0) {
// Apply custom description from configuration
const serverConfig = await serverDao.findById(server.name);
const serverConfig = await getServerDao().findById(server.name);
const toolConfig = serverConfig?.tools?.[actualTool.name];
// Return the actual tool info from serverInfos with custom description
@@ -1430,21 +1439,29 @@ export const handleListPromptsRequest = async (_: any, extra: any) => {
const group = getGroup(sessionId);
console.log(`Handling ListPromptsRequest for group: ${group}`);
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);
});
// Need to filter servers based on group asynchronously
const filteredServerInfos = [];
for (const serverInfo of getDataService().filterData(serverInfos)) {
if (serverInfo.enabled === false) continue;
if (!group) {
filteredServerInfos.push(serverInfo);
continue;
}
const serversInGroup = await getServersInGroup(group);
if (!serversInGroup || serversInGroup.length === 0) {
if (serverInfo.name === group) filteredServerInfos.push(serverInfo);
continue;
}
if (serversInGroup.includes(serverInfo.name)) {
filteredServerInfos.push(serverInfo);
}
}
const allPrompts: any[] = [];
for (const serverInfo of allServerInfos) {
for (const serverInfo of filteredServerInfos) {
if (serverInfo.prompts && serverInfo.prompts.length > 0) {
// Filter prompts based on server configuration
const serverConfig = await serverDao.findById(serverInfo.name);
const serverConfig = await getServerDao().findById(serverInfo.name);
let enabledPrompts = serverInfo.prompts;
if (serverConfig && serverConfig.prompts) {
@@ -1457,7 +1474,7 @@ export const handleListPromptsRequest = async (_: any, extra: any) => {
// If this is a group request, apply group-level prompt filtering
if (group) {
const serverConfigInGroup = getServerConfigInGroup(group, serverInfo.name);
const serverConfigInGroup = await getServerConfigInGroup(group, serverInfo.name);
if (
serverConfigInGroup &&
serverConfigInGroup.tools !== 'all' &&
@@ -1492,15 +1509,9 @@ export const createMcpServer = (name: string, version: string, group?: string):
let serverName = name;
if (group) {
// Check if it's a group or a single server
const serversInGroup = getServersInGroup(group);
if (!serversInGroup || serversInGroup.length === 0) {
// Single server routing
serverName = `${name}_${group}`;
} else {
// Group routing
serverName = `${name}_${group}_group`;
}
// For createMcpServer we use sync approach since it's called synchronously
// The actual group validation happens at request time
serverName = `${name}_${group}_group`;
}
// If no group, use default name (global routing)

View File

@@ -1,6 +1,6 @@
import OAuth2Server from '@node-oauth/oauth2-server';
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
import { loadSettings } from '../config/index.js';
import { getSystemConfigDao } from '../dao/index.js';
import { findUserByUsername, verifyPassword } from '../models/User.js';
import {
findOAuthClientById,
@@ -50,8 +50,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
client: OAuth2Server.Client,
user: OAuth2Server.User,
) => {
const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauthServer;
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const oauthConfig = systemConfig?.oauthServer;
const lifetime = oauthConfig?.authorizationCodeLifetime || 300;
const scopeString = Array.isArray(code.scope) ? code.scope.join(' ') : code.scope;
@@ -134,8 +135,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
client: OAuth2Server.Client,
user: OAuth2Server.User,
) => {
const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauthServer;
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const oauthConfig = systemConfig?.oauthServer;
const accessTokenLifetime = oauthConfig?.accessTokenLifetime || 3600;
const refreshTokenLifetime = oauthConfig?.refreshTokenLifetime || 1209600;
@@ -252,7 +254,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
}
const requestedScopes = Array.isArray(scope) ? scope : scope.split(' ');
const tokenScopes = Array.isArray(token.scope) ? token.scope : (token.scope as string).split(' ');
const tokenScopes = Array.isArray(token.scope)
? token.scope
: (token.scope as string).split(' ');
return requestedScopes.every((s) => tokenScopes.includes(s));
},
@@ -261,8 +265,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
* Validate scope
*/
validateScope: async (user: OAuth2Server.User, client: OAuth2Server.Client, scope?: string[]) => {
const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauthServer;
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const oauthConfig = systemConfig?.oauthServer;
const allowedScopes = oauthConfig?.allowedScopes || ['read', 'write'];
if (!scope || scope.length === 0) {
@@ -281,9 +286,10 @@ let oauth: OAuth2Server | null = null;
/**
* Initialize OAuth server
*/
export const initOAuthServer = (): void => {
const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauthServer;
export const initOAuthServer = async (): Promise<void> => {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const oauthConfig = systemConfig?.oauthServer;
const requireState = oauthConfig?.requireState === true;
if (!oauthConfig || !oauthConfig.enabled) {
@@ -333,7 +339,7 @@ export const authenticateUser = async (
username: string,
password: string,
): Promise<OAuth2Server.User | null> => {
const user = findUserByUsername(username);
const user = await findUserByUsername(username);
if (!user) {
return null;
}

View File

@@ -1,7 +1,7 @@
import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js';
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
import { RequestHandler } from 'express';
import { loadSettings } from '../config/index.js';
import { getServerDao, getSystemConfigDao } from '../dao/index.js';
import { initializeOAuthForServer, refreshAccessToken } from './oauthClientRegistration.js';
// Re-export for external use
@@ -22,9 +22,10 @@ let oauthRouter: RequestHandler | null = null;
/**
* Initialize OAuth provider from system configuration
*/
export const initOAuthProvider = (): void => {
const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauth;
export const initOAuthProvider = async (): Promise<void> => {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const oauthConfig = systemConfig?.oauth;
if (!oauthConfig || !oauthConfig.enabled) {
console.log('OAuth provider is disabled or not configured');
@@ -140,8 +141,8 @@ export const isOAuthEnabled = (): boolean => {
* Handles both static tokens and dynamic OAuth flows with automatic token refresh
*/
export const getServerOAuthToken = async (serverName: string): Promise<string | undefined> => {
const settings = loadSettings();
const serverConfig = settings.mcpServers[serverName];
const serverDao = getServerDao();
const serverConfig = await serverDao.findById(serverName);
if (!serverConfig?.oauth) {
return undefined;
@@ -227,15 +228,15 @@ export const addOAuthHeader = async (
* Call this at application startup to pre-register known OAuth servers
*/
export const initializeAllOAuthClients = async (): Promise<void> => {
const settings = loadSettings();
const serverDao = getServerDao();
const allServers = await serverDao.findAll();
console.log('Initializing OAuth clients for explicitly configured servers...');
const serverNames = Object.keys(settings.mcpServers);
const registrationPromises: Promise<void>[] = [];
for (const serverName of serverNames) {
const serverConfig = settings.mcpServers[serverName];
for (const serverConfig of allServers) {
const serverName = serverConfig.name;
// Only initialize servers with explicitly enabled dynamic registration
// Others will be auto-detected and registered on first 401 response

View File

@@ -1,53 +1,58 @@
import { loadSettings, saveSettings } from '../config/index.js';
import { McpSettings, ServerConfig } from '../types/index.js';
import { getServerDao } from '../dao/index.js';
import { ServerConfig } from '../types/index.js';
type OAuthConfig = NonNullable<ServerConfig['oauth']>;
export type ServerConfigWithOAuth = ServerConfig & { oauth: OAuthConfig };
export interface OAuthSettingsContext {
settings: McpSettings;
serverConfig: ServerConfig;
oauth: OAuthConfig;
}
/**
* Load the latest server configuration from disk.
* Load the latest server configuration from DAO.
*/
export const loadServerConfig = (serverName: string): ServerConfig | undefined => {
const settings = loadSettings();
return settings.mcpServers?.[serverName];
export const loadServerConfig = async (serverName: string): Promise<ServerConfig | undefined> => {
const serverDao = getServerDao();
const server = await serverDao.findById(serverName);
if (!server) {
return undefined;
}
const { name: _, ...config } = server;
return config;
};
/**
* Mutate OAuth configuration for a server and persist the updated settings.
* The mutator receives the shared settings object to allow related updates when needed.
* The mutator receives the server config to allow related updates when needed.
*/
export const mutateOAuthSettings = async (
serverName: string,
mutator: (context: OAuthSettingsContext) => void,
): Promise<ServerConfigWithOAuth | undefined> => {
const settings = loadSettings();
const serverConfig = settings.mcpServers?.[serverName];
const serverDao = getServerDao();
const server = await serverDao.findById(serverName);
if (!serverConfig) {
if (!server) {
console.warn(`Server ${serverName} not found while updating OAuth settings`);
return undefined;
}
const { name: _, ...serverConfig } = server;
if (!serverConfig.oauth) {
serverConfig.oauth = {};
}
const context: OAuthSettingsContext = {
settings,
serverConfig,
oauth: serverConfig.oauth,
};
mutator(context);
const saved = saveSettings(settings);
if (!saved) {
const updated = await serverDao.update(serverName, { oauth: serverConfig.oauth });
if (!updated) {
throw new Error(`Failed to persist OAuth settings for server ${serverName}`);
}

View File

@@ -1,8 +1,8 @@
import { OpenAPIV3 } from 'openapi-types';
import { Tool } from '../types/index.js';
import { getServersInfo } from './mcpService.js';
import config from '../config/index.js';
import { loadSettings, getNameSeparator } from '../config/index.js';
import config, { getNameSeparator } from '../config/index.js';
import { getSystemConfigDao } from '../dao/index.js';
/**
* Service for generating OpenAPI 3.x specifications from MCP tools
@@ -174,7 +174,7 @@ export async function generateOpenAPISpec(
const groupConfig: Map<string, string[] | 'all'> = new Map();
if (options.groupFilter) {
const { getGroupByIdOrName } = await import('./groupService.js');
const group = getGroupByIdOrName(options.groupFilter);
const group = await getGroupByIdOrName(options.groupFilter);
if (group) {
// Extract server names and their tool configurations from group
const groupServerNames: string[] = [];
@@ -250,12 +250,11 @@ export async function generateOpenAPISpec(
paths[pathName][method] = operation;
}
const settings = loadSettings();
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
// Get server URL
const baseUrl =
options.serverUrl ||
settings.systemConfig?.install?.baseUrl ||
`http://localhost:${config.port}`;
options.serverUrl || systemConfig?.install?.baseUrl || `http://localhost:${config.port}`;
const serverUrl = `${baseUrl}${config.basePath}/api`;
// Generate OpenAPI document

View File

@@ -10,6 +10,20 @@ import {
transports,
} from './sseService.js';
// Default mock system config
const defaultSystemConfig = {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: 'test-key',
},
enableSessionRebuild: false,
};
// Mutable mock config that can be changed in tests
let currentSystemConfig = { ...defaultSystemConfig };
// Mock dependencies
jest.mock('./mcpService.js', () => ({
deleteMcpServer: jest.fn(),
@@ -25,21 +39,21 @@ jest.mock('../config/index.js', () => {
return {
__esModule: true,
default: config,
loadSettings: jest.fn(() => ({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: 'test-key',
},
enableSessionRebuild: false, // Default to false for tests
},
})),
};
});
// Mock DAO layer
jest.mock('../dao/index.js', () => ({
getSystemConfigDao: jest.fn(() => ({
get: jest.fn().mockImplementation(() => Promise.resolve(currentSystemConfig)),
})),
}));
// Mock oauthBearer
jest.mock('../utils/oauthBearer.js', () => ({
resolveOAuthUserFromToken: jest.fn().mockResolvedValue(null),
}));
jest.mock('./userContextService.js', () => ({
UserContextService: {
getInstance: jest.fn(() => ({
@@ -57,7 +71,9 @@ jest.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
}));
jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
StreamableHTTPServerTransport: jest.fn().mockImplementation(() => mockStreamableHTTPServerTransport),
StreamableHTTPServerTransport: jest
.fn()
.mockImplementation(() => mockStreamableHTTPServerTransport),
}));
jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
@@ -66,11 +82,15 @@ jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
// 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';
// Helper function to update the mock system config
const setMockSystemConfig = (config: typeof defaultSystemConfig) => {
currentSystemConfig = config;
};
type MockResponse = Response & {
status: jest.Mock;
send: jest.Mock;
@@ -79,8 +99,7 @@ type MockResponse = Response & {
headersStore: Record<string, string>;
};
const EXPECTED_METADATA_URL =
'http://localhost:3000/.well-known/oauth-protected-resource/test';
const EXPECTED_METADATA_URL = 'http://localhost:3000/.well-known/oauth-protected-resource/test';
// Create mock instances for testing
const mockStreamableHTTPServerTransport = {
@@ -156,18 +175,15 @@ 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',
},
enableSessionRebuild: false, // Default to false for tests
// Reset settings cache to default
setMockSystemConfig({
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: 'test-key',
},
enableSessionRebuild: false, // Default to false for tests
});
});
@@ -185,15 +201,12 @@ describe('sseService', () => {
});
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',
},
setMockSystemConfig({
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
});
@@ -206,15 +219,12 @@ describe('sseService', () => {
});
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',
},
setMockSystemConfig({
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
});
@@ -229,15 +239,12 @@ describe('sseService', () => {
});
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',
},
setMockSystemConfig({
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
});
@@ -279,15 +286,12 @@ describe('sseService', () => {
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: '',
},
setMockSystemConfig({
routing: {
enableGlobalRoute: false,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
},
});
@@ -375,15 +379,12 @@ describe('sseService', () => {
});
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',
},
setMockSystemConfig({
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
});
@@ -400,15 +401,12 @@ describe('sseService', () => {
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: '',
},
setMockSystemConfig({
routing: {
enableGlobalRoute: false,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
},
});
@@ -463,17 +461,14 @@ describe('sseService', () => {
it('should transparently rebuild invalid session when enabled', async () => {
// Enable session rebuild for this test
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: 'test-key',
},
enableSessionRebuild: true, // Enable session rebuild
setMockSystemConfig({
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: 'test-key',
},
enableSessionRebuild: true, // Enable session rebuild
});
const req = createMockRequest({
@@ -487,20 +482,19 @@ describe('sseService', () => {
// With session rebuild enabled, invalid sessions should be transparently rebuilt
expect(StreamableHTTPServerTransport).toHaveBeenCalled();
const mockInstance = (StreamableHTTPServerTransport as jest.MockedClass<typeof StreamableHTTPServerTransport>).mock.results[0].value;
const mockInstance = (
StreamableHTTPServerTransport as jest.MockedClass<typeof StreamableHTTPServerTransport>
).mock.results[0].value;
expect(mockInstance.handleRequest).toHaveBeenCalledWith(req, res, req.body);
});
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',
},
setMockSystemConfig({
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
});
@@ -530,20 +524,17 @@ describe('sseService', () => {
});
it('should return error when session rebuild is disabled in handleMcpOtherRequest', async () => {
// Clear transports before test
Object.keys(transports).forEach(key => delete transports[key]);
Object.keys(transports).forEach((key) => delete transports[key]);
// Enable bearer auth for this test
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
enableSessionRebuild: false, // Disable session rebuild
setMockSystemConfig({
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
enableSessionRebuild: false, // Disable session rebuild
});
// Mock user context to exist
@@ -555,7 +546,7 @@ describe('sseService', () => {
const req = createMockRequest({
headers: {
'mcp-session-id': 'invalid-session',
'authorization': 'Bearer test-key'
authorization: 'Bearer test-key',
},
params: { group: 'test-group' },
});
@@ -570,23 +561,20 @@ describe('sseService', () => {
it('should transparently rebuild invalid session in handleMcpOtherRequest when enabled', async () => {
// Enable bearer auth and session rebuild for this test
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
enableSessionRebuild: true, // Enable session rebuild
setMockSystemConfig({
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
enableSessionRebuild: true, // Enable session rebuild
});
const req = createMockRequest({
headers: {
'mcp-session-id': 'invalid-session',
'authorization': 'Bearer test-key'
authorization: 'Bearer test-key',
},
});
const res = createMockResponse();
@@ -596,21 +584,18 @@ describe('sseService', () => {
// Should not return 400 error, but instead transparently rebuild the session
expect(res.status).not.toHaveBeenCalledWith(400);
expect(res.send).not.toHaveBeenCalledWith('Invalid or missing session ID');
// Should attempt to handle the request (session was rebuilt)
expect(mockStreamableHTTPServerTransport.handleRequest).toHaveBeenCalled();
});
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',
},
setMockSystemConfig({
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
});

View File

@@ -5,8 +5,8 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
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 { getSystemConfigDao } from '../dao/index.js';
import { UserContextService } from './userContextService.js';
import { RequestContextService } from './requestContextService.js';
import { IUser } from '../types/index.js';
@@ -30,9 +30,10 @@ type BearerAuthResult =
reason: 'missing' | 'invalid';
};
const validateBearerAuth = (req: Request): BearerAuthResult => {
const settings = loadSettings();
const routingConfig = settings.systemConfig?.routing || {
const validateBearerAuth = async (req: Request): Promise<BearerAuthResult> => {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const routingConfig = systemConfig?.routing || {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
@@ -54,7 +55,7 @@ const validateBearerAuth = (req: Request): BearerAuthResult => {
return { valid: true };
}
const oauthUser = resolveOAuthUserFromToken(token);
const oauthUser = await resolveOAuthUserFromToken(token);
if (oauthUser) {
return { valid: true, user: oauthUser };
}
@@ -170,7 +171,7 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
const userContextService = UserContextService.getInstance();
// Check bearer auth using filtered settings
const bearerAuthResult = validateBearerAuth(req);
const bearerAuthResult = await validateBearerAuth(req);
if (!bearerAuthResult.valid) {
sendBearerAuthError(req, res, bearerAuthResult.reason);
return;
@@ -181,8 +182,9 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
const currentUser = userContextService.getCurrentUser();
const username = currentUser?.username;
const settings = loadSettings();
const routingConfig = settings.systemConfig?.routing || {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const routingConfig = systemConfig?.routing || {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
@@ -248,7 +250,7 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
const userContextService = UserContextService.getInstance();
// Check bearer auth using filtered settings
const bearerAuthResult = validateBearerAuth(req);
const bearerAuthResult = await validateBearerAuth(req);
if (!bearerAuthResult.valid) {
sendBearerAuthError(req, res, bearerAuthResult.reason);
return;
@@ -429,7 +431,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
const userContextService = UserContextService.getInstance();
// Check bearer auth using filtered settings
const bearerAuthResult = validateBearerAuth(req);
const bearerAuthResult = await validateBearerAuth(req);
if (!bearerAuthResult.valid) {
sendBearerAuthError(req, res, bearerAuthResult.reason);
return;
@@ -448,8 +450,9 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
);
// Get filtered settings based on user context (after setting user context)
const settings = loadSettings();
const routingConfig = settings.systemConfig?.routing || {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const routingConfig = systemConfig?.routing || {
enableGlobalRoute: true,
enableGroupNameRoute: true,
};
@@ -473,8 +476,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
transport = transportInfo.transport as StreamableHTTPServerTransport;
} else if (sessionId) {
// Case 2: SessionId exists but transport is missing (server restart), check if session rebuild is enabled
const settings = loadSettings();
const enableSessionRebuild = settings.systemConfig?.enableSessionRebuild || false;
const enableSessionRebuild = systemConfig?.enableSessionRebuild || false;
if (enableSessionRebuild) {
console.log(
@@ -680,7 +682,7 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
const userContextService = UserContextService.getInstance();
// Check bearer auth using filtered settings
const bearerAuthResult = validateBearerAuth(req);
const bearerAuthResult = await validateBearerAuth(req);
if (!bearerAuthResult.valid) {
sendBearerAuthError(req, res, bearerAuthResult.reason);
return;
@@ -703,8 +705,9 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
// If session doesn't exist, attempt transparent rebuild if enabled
if (!transportEntry) {
const settings = loadSettings();
const enableSessionRebuild = settings.systemConfig?.enableSessionRebuild || false;
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const enableSessionRebuild = systemConfig?.enableSessionRebuild || false;
if (enableSessionRebuild) {
console.log(

View File

@@ -1,16 +1,17 @@
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';
import { getUserDao } from '../dao/index.js';
// Get all users
export const getAllUsers = (): IUser[] => {
return getUsers();
export const getAllUsers = async (): Promise<IUser[]> => {
const userDao = getUserDao();
return await userDao.findAll();
};
// Get user by username
export const getUserByUsername = (username: string): IUser | undefined => {
return findUserByUsername(username);
export const getUserByUsername = async (username: string): Promise<IUser | undefined> => {
const userDao = getUserDao();
const user = await userDao.findByUsername(username);
return user || undefined;
};
// Create a new user
@@ -20,18 +21,13 @@ export const createNewUser = async (
isAdmin: boolean = false,
): Promise<IUser | null> => {
try {
const existingUser = findUserByUsername(username);
const userDao = getUserDao();
const existingUser = await userDao.findByUsername(username);
if (existingUser) {
return null; // User already exists
}
const userData: IUser = {
username,
password,
isAdmin,
};
return await createUser(userData);
return await userDao.createWithHashedPassword(username, password, isAdmin);
} catch (error) {
console.error('Failed to create user:', error);
return null;
@@ -44,36 +40,31 @@ export const updateUser = async (
data: { isAdmin?: boolean; newPassword?: string },
): Promise<IUser | null> => {
try {
const users = getUsers();
const userIndex = users.findIndex((user) => user.username === username);
const userDao = getUserDao();
const user = await userDao.findByUsername(username);
if (userIndex === -1) {
if (!user) {
return null;
}
const user = users[userIndex];
// Update admin status if provided
if (data.isAdmin !== undefined) {
user.isAdmin = data.isAdmin;
const result = await userDao.update(username, { isAdmin: data.isAdmin });
if (!result) {
return null;
}
}
// Update password if provided
if (data.newPassword) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(data.newPassword, salt);
const success = await userDao.updatePassword(username, data.newPassword);
if (!success) {
return null;
}
}
// 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;
// Return updated user
return await userDao.findByUsername(username);
} catch (error) {
console.error('Failed to update user:', error);
return null;
@@ -81,10 +72,12 @@ export const updateUser = async (
};
// Delete a user
export const deleteUser = (username: string): boolean => {
export const deleteUser = async (username: string): Promise<boolean> => {
try {
const userDao = getUserDao();
// Cannot delete the last admin user
const users = getUsers();
const users = await userDao.findAll();
const adminUsers = users.filter((user) => user.isAdmin);
const userToDelete = users.find((user) => user.username === username);
@@ -92,17 +85,7 @@ export const deleteUser = (username: string): boolean => {
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);
return await userDao.delete(username);
} catch (error) {
console.error('Failed to delete user:', error);
return false;
@@ -110,17 +93,21 @@ export const deleteUser = (username: string): boolean => {
};
// Check if user has admin permissions
export const isUserAdmin = (username: string): boolean => {
const user = findUserByUsername(username);
export const isUserAdmin = async (username: string): Promise<boolean> => {
const userDao = getUserDao();
const user = await userDao.findByUsername(username);
return user?.isAdmin || false;
};
// Get user count
export const getUserCount = (): number => {
return getUsers().length;
export const getUserCount = async (): Promise<number> => {
const userDao = getUserDao();
return await userDao.count();
};
// Get admin count
export const getAdminCount = (): number => {
return getUsers().filter((user) => user.isAdmin).length;
export const getAdminCount = async (): Promise<number> => {
const userDao = getUserDao();
const admins = await userDao.findAdmins();
return admins.length;
};

View File

@@ -175,7 +175,10 @@ export interface SystemConfig {
enableSessionRebuild?: boolean; // Controls whether server session rebuild is enabled
}
export interface UserConfig {}
export interface UserConfig {
routing?: Record<string, any>; // User-specific routing configuration
[key: string]: any; // Allow additional dynamic properties
}
// OAuth Client for MCPHub's own authorization server
export interface IOAuthClient {

194
src/utils/migration.ts Normal file
View File

@@ -0,0 +1,194 @@
import { loadOriginalSettings } from '../config/index.js';
import { initializeDatabase } from '../db/connection.js';
import { setDaoFactory } from '../dao/DaoFactory.js';
import { DatabaseDaoFactory } from '../dao/DatabaseDaoFactory.js';
import { UserRepository } from '../db/repositories/UserRepository.js';
import { ServerRepository } from '../db/repositories/ServerRepository.js';
import { GroupRepository } from '../db/repositories/GroupRepository.js';
import { SystemConfigRepository } from '../db/repositories/SystemConfigRepository.js';
import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js';
/**
* Migrate from file-based configuration to database
*/
export async function migrateToDatabase(): Promise<boolean> {
try {
console.log('Starting migration from file to database...');
// Initialize database connection
await initializeDatabase();
console.log('Database connection established');
// Load current settings from file
const settings = loadOriginalSettings();
console.log('Loaded settings from file');
// Create repositories
const userRepo = new UserRepository();
const serverRepo = new ServerRepository();
const groupRepo = new GroupRepository();
const systemConfigRepo = new SystemConfigRepository();
const userConfigRepo = new UserConfigRepository();
// Migrate users
if (settings.users && settings.users.length > 0) {
console.log(`Migrating ${settings.users.length} users...`);
for (const user of settings.users) {
const exists = await userRepo.exists(user.username);
if (!exists) {
await userRepo.create({
username: user.username,
password: user.password,
isAdmin: user.isAdmin || false,
});
console.log(` - Created user: ${user.username}`);
} else {
console.log(` - User already exists: ${user.username}`);
}
}
}
// Migrate servers
if (settings.mcpServers) {
const serverNames = Object.keys(settings.mcpServers);
console.log(`Migrating ${serverNames.length} servers...`);
for (const [name, config] of Object.entries(settings.mcpServers)) {
const exists = await serverRepo.exists(name);
if (!exists) {
await serverRepo.create({
name,
type: config.type,
url: config.url,
command: config.command,
args: config.args,
env: config.env,
headers: config.headers,
enabled: config.enabled !== undefined ? config.enabled : true,
owner: config.owner,
keepAliveInterval: config.keepAliveInterval,
tools: config.tools,
prompts: config.prompts,
options: config.options,
oauth: config.oauth,
});
console.log(` - Created server: ${name}`);
} else {
console.log(` - Server already exists: ${name}`);
}
}
}
// Migrate groups
if (settings.groups && settings.groups.length > 0) {
console.log(`Migrating ${settings.groups.length} groups...`);
for (const group of settings.groups) {
const exists = await groupRepo.existsByName(group.name);
if (!exists) {
await groupRepo.create({
name: group.name,
description: group.description,
servers: Array.isArray(group.servers) ? group.servers : [],
owner: group.owner,
});
console.log(` - Created group: ${group.name}`);
} else {
console.log(` - Group already exists: ${group.name}`);
}
}
}
// Migrate system config
if (settings.systemConfig) {
console.log('Migrating system configuration...');
const systemConfig = {
routing: settings.systemConfig.routing || {},
install: settings.systemConfig.install || {},
smartRouting: settings.systemConfig.smartRouting || {},
mcpRouter: settings.systemConfig.mcpRouter || {},
nameSeparator: settings.systemConfig.nameSeparator,
oauth: settings.systemConfig.oauth || {},
oauthServer: settings.systemConfig.oauthServer || {},
enableSessionRebuild: settings.systemConfig.enableSessionRebuild,
};
await systemConfigRepo.update(systemConfig);
console.log(' - System configuration updated');
}
// Migrate user configs
if (settings.userConfigs) {
const usernames = Object.keys(settings.userConfigs);
console.log(`Migrating ${usernames.length} user configurations...`);
for (const [username, config] of Object.entries(settings.userConfigs)) {
const userConfig = {
routing: config.routing || {},
additionalConfig: config,
};
await userConfigRepo.update(username, userConfig);
console.log(` - Updated configuration for user: ${username}`);
}
}
console.log('✅ Migration completed successfully');
return true;
} catch (error) {
console.error('❌ Migration failed:', error);
return false;
}
}
/**
* Initialize database mode
* This function should be called during application startup when USE_DB=true
*/
export async function initializeDatabaseMode(): Promise<boolean> {
try {
console.log('Initializing database mode...');
// Initialize database connection
await initializeDatabase();
console.log('Database connection established');
// Switch to database factory
setDaoFactory(DatabaseDaoFactory.getInstance());
console.log('Switched to database-backed DAO implementations');
// Check if migration is needed
const userRepo = new UserRepository();
const userCount = await userRepo.count();
if (userCount === 0) {
console.log('No users found in database, running migration...');
const migrated = await migrateToDatabase();
if (!migrated) {
throw new Error('Migration failed');
}
} else {
console.log(`Database already contains ${userCount} users, skipping migration`);
}
console.log('✅ Database mode initialized successfully');
return true;
} catch (error) {
console.error('❌ Failed to initialize database mode:', error);
return false;
}
}
/**
* CLI tool for migration
*/
export async function runMigrationCli(): Promise<void> {
console.log('MCPHub Configuration Migration Tool');
console.log('====================================\n');
const success = await migrateToDatabase();
if (success) {
console.log('\n✅ Migration completed successfully!');
console.log('You can now set USE_DB=true to use database-backed configuration');
process.exit(0);
} else {
console.log('\n❌ Migration failed!');
process.exit(1);
}
}

View File

@@ -6,7 +6,7 @@ import { IUser } from '../types/index.js';
/**
* Resolve an MCPHub user from a raw OAuth bearer token.
*/
export const resolveOAuthUserFromToken = (token?: string): IUser | null => {
export const resolveOAuthUserFromToken = async (token?: string): Promise<IUser | null> => {
if (!token || !isOAuthServerEnabled()) {
return null;
}
@@ -16,7 +16,7 @@ export const resolveOAuthUserFromToken = (token?: string): IUser | null => {
return null;
}
const dbUser = findUserByUsername(oauthToken.username);
const dbUser = await findUserByUsername(oauthToken.username);
return {
username: oauthToken.username,
@@ -28,7 +28,9 @@ export const resolveOAuthUserFromToken = (token?: string): IUser | null => {
/**
* Resolve an MCPHub user from an Authorization header.
*/
export const resolveOAuthUserFromAuthHeader = (authHeader?: string): IUser | null => {
export const resolveOAuthUserFromAuthHeader = async (
authHeader?: string,
): Promise<IUser | null> => {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}