feat: enhance JSON serialization safety & add dxt upload limit (#230)

This commit is contained in:
samanhappy
2025-07-20 19:18:10 +08:00
committed by GitHub
parent 4388084704
commit 20fd355b87
49 changed files with 3068 additions and 378 deletions

View File

@@ -0,0 +1,13 @@
import { DataService } from './dataService.js';
import { getDataService } from './services.js';
import './services.js';
describe('DataService', () => {
test('should get default implementation and call foo method', async () => {
const dataService: DataService = await getDataService();
const consoleSpy = jest.spyOn(console, 'log');
dataService.foo();
expect(consoleSpy).toHaveBeenCalledWith('default implementation');
consoleSpy.mockRestore();
});
});

View File

@@ -0,0 +1,31 @@
import { IUser, McpSettings } from '../types/index.js';
export interface DataService {
foo(): void;
filterData(data: any[]): any[];
filterSettings(settings: McpSettings): McpSettings;
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings;
getPermissions(user: IUser): string[];
}
export class DataServiceImpl implements DataService {
foo() {
console.log('default implementation');
}
filterData(data: any[]): any[] {
return data;
}
filterSettings(settings: McpSettings): McpSettings {
return settings;
}
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings {
return newSettings;
}
getPermissions(_user: IUser): string[] {
return ['*'];
}
}

View File

@@ -2,11 +2,15 @@ import { v4 as uuidv4 } from 'uuid';
import { IGroup } from '../types/index.js';
import { loadSettings, saveSettings } from '../config/index.js';
import { notifyToolChanged } from './mcpService.js';
import { getDataService } from './services.js';
// Get all groups
export const getAllGroups = (): IGroup[] => {
const settings = loadSettings();
return settings.groups || [];
const dataService = getDataService();
return dataService.filterData
? dataService.filterData(settings.groups || [])
: settings.groups || [];
};
// Get group by ID or name
@@ -29,6 +33,7 @@ export const createGroup = (
name: string,
description?: string,
servers: string[] = [],
owner?: string,
): IGroup | null => {
try {
const settings = loadSettings();
@@ -47,6 +52,7 @@ export const createGroup = (
name,
description,
servers: validServers,
owner: owner || 'admin',
};
// Initialize groups array if it doesn't exist

View File

@@ -11,6 +11,7 @@ import { getGroup } from './sseService.js';
import { getServersInGroup } from './groupService.js';
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
import { OpenAPIClient } from '../clients/openapi.js';
import { getDataService } from './services.js';
const servers: { [sessionId: string]: Server } = {};
@@ -101,6 +102,33 @@ export const syncToolEmbedding = async (serverName: string, toolName: string) =>
// Store all server information
let serverInfos: ServerInfo[] = [];
// Returns true if all servers are connected
export const connected = (): boolean => {
return serverInfos.every((serverInfo) => serverInfo.status === 'connected');
};
// Global cleanup function to close all connections
export const cleanupAllServers = (): void => {
for (const serverInfo of serverInfos) {
try {
if (serverInfo.client) {
serverInfo.client.close();
}
if (serverInfo.transport) {
serverInfo.transport.close();
}
} catch (error) {
console.warn(`Error closing server ${serverInfo.name}:`, error);
}
}
serverInfos = [];
// Clear session servers as well
Object.keys(servers).forEach((sessionId) => {
delete servers[sessionId];
});
};
// Helper function to create transport based on server configuration
const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
let transport;
@@ -294,6 +322,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
console.log(`Skipping disabled server: ${name}`);
serverInfos.push({
name,
owner: conf.owner,
status: 'disconnected',
error: null,
tools: [],
@@ -327,6 +356,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
);
serverInfos.push({
name,
owner: conf.owner,
status: 'disconnected',
error: 'Missing OpenAPI specification URL or schema',
tools: [],
@@ -338,6 +368,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
// Create server info first and keep reference to it
const serverInfo: ServerInfo = {
name,
owner: conf.owner,
status: 'connecting',
error: null,
tools: [],
@@ -418,6 +449,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
// Create server info first and keep reference to it
const serverInfo: ServerInfo = {
name,
owner: conf.owner,
status: 'connecting',
error: null,
tools: [],
@@ -480,7 +512,11 @@ export const registerAllTools = async (isInit: boolean): Promise<void> => {
// Get all server information
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
const settings = loadSettings();
const infos = serverInfos.map(({ name, status, tools, createTime, error }) => {
const dataService = getDataService();
const filterServerInfos: ServerInfo[] = dataService.filterData
? dataService.filterData(serverInfos)
: serverInfos;
const infos = filterServerInfos.map(({ name, status, tools, createTime, error }) => {
const serverConfig = settings.mcpServers[name];
const enabled = serverConfig ? serverConfig.enabled !== false : true;
@@ -774,13 +810,15 @@ Available servers: ${serversList}`;
};
}
const allServerInfos = serverInfos.filter((serverInfo) => {
if (serverInfo.enabled === false) return false;
if (!group) return true;
const serversInGroup = getServersInGroup(group);
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
return serversInGroup.includes(serverInfo.name);
});
const allServerInfos = getDataService()
.filterData(serverInfos)
.filter((serverInfo) => {
if (serverInfo.enabled === false) return false;
if (!group) return true;
const serversInGroup = getServersInGroup(group);
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
return serversInGroup.includes(serverInfo.name);
});
const allTools = [];
for (const serverInfo of allServerInfos) {

37
src/services/registry.ts Normal file
View File

@@ -0,0 +1,37 @@
type Class<T> = new (...args: any[]) => T;
interface Service<T> {
defaultImpl: Class<T>;
}
const registry = new Map<string, Service<any>>();
const instances = new Map<string, unknown>();
export function registerService<T>(key: string, entry: Service<T>) {
registry.set(key, entry);
}
export function getService<T>(key: string): T {
if (instances.has(key)) {
return instances.get(key) as T;
}
const entry = registry.get(key);
if (!entry) throw new Error(`Service not registered for key: ${key.toString()}`);
let Impl = entry.defaultImpl;
const overridePath = './' + key + 'x.js';
import(overridePath)
.then((mod) => {
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'] ?? Impl.name;
if (typeof override === 'function') {
Impl = override;
}
})
.catch(() => {});
const instance = new Impl();
instances.set(key, instance);
return instance;
}

10
src/services/services.ts Normal file
View File

@@ -0,0 +1,10 @@
import { registerService, getService } from './registry.js';
import { DataService, DataServiceImpl } from './dataService.js';
registerService('dataService', {
defaultImpl: DataServiceImpl,
});
export function getDataService(): DataService {
return getService<DataService>('dataService');
}

View File

@@ -0,0 +1,482 @@
import { Request, Response } from 'express';
import { jest } from '@jest/globals';
import {
handleSseConnection,
handleSseMessage,
handleMcpPostRequest,
handleMcpOtherRequest,
getGroup,
getConnectionCount,
} from './sseService.js';
// Mock dependencies
jest.mock('./mcpService.js', () => ({
deleteMcpServer: jest.fn(),
getMcpServer: jest.fn(() => ({
connect: jest.fn(),
})),
}));
jest.mock('../config/index.js', () => {
const config = {
basePath: '/test',
};
return {
__esModule: true,
default: config,
loadSettings: jest.fn(() => ({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: 'test-key',
},
},
})),
};
});
jest.mock('./userContextService.js', () => ({
UserContextService: {
getInstance: jest.fn(() => ({
getCurrentUser: jest.fn(() => ({ username: 'testuser' })),
})),
},
}));
jest.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
SSEServerTransport: jest.fn().mockImplementation((_path, _res) => ({
sessionId: 'test-session-id',
connect: jest.fn(),
handlePostMessage: jest.fn(),
})),
}));
jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
StreamableHTTPServerTransport: jest.fn().mockImplementation(() => ({
sessionId: 'test-session-id',
connect: jest.fn(),
handleRequest: jest.fn(),
onclose: null,
})),
}));
jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
isInitializeRequest: jest.fn(() => true),
}));
// Import mocked modules
import { getMcpServer } from './mcpService.js';
import { loadSettings } from '../config/index.js';
import { UserContextService } from './userContextService.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
// Mock Express Request and Response
const createMockRequest = (overrides: Partial<Request> = {}): Request =>
({
headers: {},
params: {},
query: {},
body: {},
...overrides,
}) as Request;
const createMockResponse = (): Response => {
const res = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
on: jest.fn(),
} as unknown as Response;
return res;
};
describe('sseService', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset settings cache
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: 'test-key',
},
},
});
});
describe('bearer authentication', () => {
it('should pass when bearer auth is disabled', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
});
const res = createMockResponse();
await handleSseConnection(req, res);
expect(res.status).not.toHaveBeenCalledWith(401);
expect(SSEServerTransport).toHaveBeenCalled();
});
it('should return 401 when bearer auth is enabled but no authorization header', async () => {
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
},
});
const req = createMockRequest();
const res = createMockResponse();
await handleSseConnection(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
});
it('should return 401 when bearer auth is enabled with invalid token', async () => {
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
},
});
const req = createMockRequest({
headers: { authorization: 'Bearer invalid-token' },
});
const res = createMockResponse();
await handleSseConnection(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
});
it('should pass when bearer auth is enabled with valid token', async () => {
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
},
});
const req = createMockRequest({
headers: { authorization: 'Bearer test-key' },
params: { group: 'test-group' },
});
const res = createMockResponse();
await handleSseConnection(req, res);
expect(res.status).not.toHaveBeenCalledWith(401);
expect(SSEServerTransport).toHaveBeenCalled();
});
});
describe('getGroup', () => {
it('should return empty string for non-existent session', () => {
const result = getGroup('non-existent-session');
expect(result).toBe('');
});
it('should return group for existing session', () => {
// This would need to be tested after a connection is established
// For now, testing the default behavior
const result = getGroup('test-session');
expect(result).toBe('');
});
});
describe('getConnectionCount', () => {
it('should return current number of connections', () => {
const count = getConnectionCount();
// The count may be > 0 due to previous tests since transports is module-level
expect(typeof count).toBe('number');
expect(count).toBeGreaterThanOrEqual(0);
});
});
describe('handleSseConnection', () => {
it('should reject global routes when disabled', async () => {
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: false,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
},
},
});
const req = createMockRequest(); // No group in params
const res = createMockResponse();
await handleSseConnection(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith(
'Global routes are disabled. Please specify a group ID.',
);
});
it('should create SSE transport for valid request', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
});
const res = createMockResponse();
await handleSseConnection(req, res);
expect(SSEServerTransport).toHaveBeenCalledWith('/test/testuser/messages', res);
expect(getMcpServer).toHaveBeenCalledWith('test-session-id', 'test-group');
});
it('should handle user context correctly', async () => {
const mockGetCurrentUser = jest.fn(() => ({ username: 'testuser2' }));
(UserContextService.getInstance as jest.MockedFunction<any>).mockReturnValue({
getCurrentUser: mockGetCurrentUser,
});
const req = createMockRequest({
params: { group: 'test-group' },
});
const res = createMockResponse();
await handleSseConnection(req, res);
expect(mockGetCurrentUser).toHaveBeenCalled();
expect(SSEServerTransport).toHaveBeenCalledWith('/test/testuser2/messages', res);
});
it('should handle anonymous user correctly', async () => {
const mockGetCurrentUser = jest.fn(() => null);
(UserContextService.getInstance as jest.MockedFunction<any>).mockReturnValue({
getCurrentUser: mockGetCurrentUser,
});
const req = createMockRequest({
params: { group: 'test-group' },
});
const res = createMockResponse();
await handleSseConnection(req, res);
expect(mockGetCurrentUser).toHaveBeenCalled();
expect(SSEServerTransport).toHaveBeenCalledWith('/test/messages', res);
});
});
describe('handleSseMessage', () => {
it('should return 400 when sessionId is missing', async () => {
const req = createMockRequest({
query: {}, // No sessionId
});
const res = createMockResponse();
await handleSseMessage(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.send).toHaveBeenCalledWith('Missing sessionId parameter');
});
it('should return 404 when transport not found', async () => {
const req = createMockRequest({
query: { sessionId: 'non-existent-session' },
});
const res = createMockResponse();
await handleSseMessage(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith('No transport found for sessionId');
});
it('should return 401 when bearer auth fails', async () => {
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
},
});
const req = createMockRequest({
query: { sessionId: 'test-session' },
});
const res = createMockResponse();
await handleSseMessage(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
});
});
describe('handleMcpPostRequest', () => {
it('should reject global routes when disabled', async () => {
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: false,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
},
},
});
const req = createMockRequest({
params: {}, // No group
body: { method: 'initialize' },
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith(
'Global routes are disabled. Please specify a group ID.',
);
});
it('should create new transport for initialize request without sessionId', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
body: { method: 'initialize' },
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
expect(StreamableHTTPServerTransport).toHaveBeenCalled();
expect(getMcpServer).toHaveBeenCalled();
});
it('should return error for invalid session', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
headers: { 'mcp-session-id': 'invalid-session' },
body: { method: 'someMethod' },
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
});
});
it('should return 401 when bearer auth fails', async () => {
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
},
});
const req = createMockRequest({
params: { group: 'test-group' },
body: { method: 'initialize' },
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
});
});
describe('handleMcpOtherRequest', () => {
it('should return 400 for missing session ID', async () => {
const req = createMockRequest({
headers: {}, // No mcp-session-id
});
const res = createMockResponse();
await handleMcpOtherRequest(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.send).toHaveBeenCalledWith('Invalid or missing session ID');
});
it('should return 400 for invalid session ID', async () => {
const req = createMockRequest({
headers: { 'mcp-session-id': 'invalid-session' },
});
const res = createMockResponse();
await handleMcpOtherRequest(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.send).toHaveBeenCalledWith('Invalid or missing session ID');
});
it('should return 401 when bearer auth fails', async () => {
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
},
});
const req = createMockRequest({
headers: { 'mcp-session-id': 'test-session' },
});
const res = createMockResponse();
await handleMcpOtherRequest(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
});
});
});

View File

@@ -7,6 +7,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { deleteMcpServer, getMcpServer } from './mcpService.js';
import { loadSettings } from '../config/index.js';
import config from '../config/index.js';
import { UserContextService } from './userContextService.js';
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
@@ -38,8 +39,14 @@ const validateBearerAuth = (req: Request): boolean => {
};
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
// Check bearer auth
// User context is now set by sseUserContextMiddleware
const userContextService = UserContextService.getInstance();
const currentUser = userContextService.getCurrentUser();
const username = currentUser?.username;
// Check bearer auth using filtered settings
if (!validateBearerAuth(req)) {
console.warn('Bearer authentication failed or not provided');
res.status(401).send('Bearer authentication required or invalid token');
return;
}
@@ -55,11 +62,25 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
// Check if this is a global route (no group) and if it's allowed
if (!group && !routingConfig.enableGlobalRoute) {
console.warn('Global routes are disabled, group ID is required');
res.status(403).send('Global routes are disabled. Please specify a group ID.');
return;
}
const transport = new SSEServerTransport(`${config.basePath}/messages`, res);
// For user-scoped routes, validate that the user has access to the requested group
if (username && group) {
// Additional validation can be added here to check if user has access to the group
console.log(`User ${username} accessing group: ${group}`);
}
// Construct the appropriate messages path based on user context
const messagesPath = username
? `${config.basePath}/${username}/messages`
: `${config.basePath}/messages`;
console.log(`Creating SSE transport with messages path: ${messagesPath}`);
const transport = new SSEServerTransport(messagesPath, res);
transports[transport.sessionId] = { transport, group: group };
res.on('close', () => {
@@ -69,13 +90,18 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
});
console.log(
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}`,
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}${username ? ` for user: ${username}` : ''}`,
);
await getMcpServer(transport.sessionId, group).connect(transport);
};
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
// Check bearer auth
// User context is now set by sseUserContextMiddleware
const userContextService = UserContextService.getInstance();
const currentUser = userContextService.getCurrentUser();
const username = currentUser?.username;
// Check bearer auth using filtered settings
if (!validateBearerAuth(req)) {
res.status(401).send('Bearer authentication required or invalid token');
return;
@@ -101,24 +127,31 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
const { transport, group } = transportData;
req.params.group = group;
req.query.group = group;
console.log(`Received message for sessionId: ${sessionId} in group: ${group}`);
console.log(`Received message for sessionId: ${sessionId} in group: ${group}${username ? ` for user: ${username}` : ''}`);
await (transport as SSEServerTransport).handlePostMessage(req, res);
};
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
// User context is now set by sseUserContextMiddleware
const userContextService = UserContextService.getInstance();
const currentUser = userContextService.getCurrentUser();
const username = currentUser?.username;
const sessionId = req.headers['mcp-session-id'] as string | undefined;
const group = req.params.group;
const body = req.body;
console.log(
`Handling MCP post request for sessionId: ${sessionId} and group: ${group} with body: ${JSON.stringify(body)}`,
`Handling MCP post request for sessionId: ${sessionId} and group: ${group}${username ? ` for user: ${username}` : ''} with body: ${JSON.stringify(body)}`,
);
// Check bearer auth
// Check bearer auth using filtered settings
if (!validateBearerAuth(req)) {
res.status(401).send('Bearer authentication required or invalid token');
return;
}
// Get filtered settings based on user context (after setting user context)
const settings = loadSettings();
const routingConfig = settings.systemConfig?.routing || {
enableGlobalRoute: true,
@@ -150,7 +183,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
}
};
console.log(`MCP connection established: ${transport.sessionId}`);
console.log(`MCP connection established: ${transport.sessionId}${username ? ` for user: ${username}` : ''}`);
await getMcpServer(transport.sessionId, group).connect(transport);
} else {
res.status(400).json({
@@ -169,8 +202,14 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
};
export const handleMcpOtherRequest = async (req: Request, res: Response) => {
console.log('Handling MCP other request');
// Check bearer auth
// User context is now set by sseUserContextMiddleware
const userContextService = UserContextService.getInstance();
const currentUser = userContextService.getCurrentUser();
const username = currentUser?.username;
console.log(`Handling MCP other request${username ? ` for user: ${username}` : ''}`);
// Check bearer auth using filtered settings
if (!validateBearerAuth(req)) {
res.status(401).send('Bearer authentication required or invalid token');
return;

View File

@@ -0,0 +1,59 @@
import { IUser } from '../types/index.js';
// User context storage
class UserContext {
private static instance: UserContext;
private currentUser: IUser | null = null;
static getInstance(): UserContext {
if (!UserContext.instance) {
UserContext.instance = new UserContext();
}
return UserContext.instance;
}
setUser(user: IUser): void {
this.currentUser = user;
}
getUser(): IUser | null {
return this.currentUser;
}
clearUser(): void {
this.currentUser = null;
}
}
export class UserContextService {
private static instance: UserContextService;
private userContext = UserContext.getInstance();
static getInstance(): UserContextService {
if (!UserContextService.instance) {
UserContextService.instance = new UserContextService();
}
return UserContextService.instance;
}
getCurrentUser(): IUser | null {
return this.userContext.getUser();
}
setCurrentUser(user: IUser): void {
this.userContext.setUser(user);
}
clearCurrentUser(): void {
this.userContext.clearUser();
}
isAdmin(): boolean {
const user = this.getCurrentUser();
return user?.isAdmin || false;
}
hasUser(): boolean {
return this.getCurrentUser() !== null;
}
}

126
src/services/userService.ts Normal file
View File

@@ -0,0 +1,126 @@
import { IUser } from '../types/index.js';
import { getUsers, createUser, findUserByUsername } from '../models/User.js';
import { saveSettings, loadSettings } from '../config/index.js';
import bcrypt from 'bcryptjs';
// Get all users
export const getAllUsers = (): IUser[] => {
return getUsers();
};
// Get user by username
export const getUserByUsername = (username: string): IUser | undefined => {
return findUserByUsername(username);
};
// Create a new user
export const createNewUser = async (
username: string,
password: string,
isAdmin: boolean = false,
): Promise<IUser | null> => {
try {
const existingUser = findUserByUsername(username);
if (existingUser) {
return null; // User already exists
}
const userData: IUser = {
username,
password,
isAdmin,
};
return await createUser(userData);
} catch (error) {
console.error('Failed to create user:', error);
return null;
}
};
// Update user information
export const updateUser = async (
username: string,
data: { isAdmin?: boolean; newPassword?: string },
): Promise<IUser | null> => {
try {
const users = getUsers();
const userIndex = users.findIndex((user) => user.username === username);
if (userIndex === -1) {
return null;
}
const user = users[userIndex];
// Update admin status if provided
if (data.isAdmin !== undefined) {
user.isAdmin = data.isAdmin;
}
// Update password if provided
if (data.newPassword) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(data.newPassword, salt);
}
// Save users array back to settings
const { saveSettings, loadSettings } = await import('../config/index.js');
const settings = loadSettings();
settings.users = users;
if (!saveSettings(settings)) {
return null;
}
return user;
} catch (error) {
console.error('Failed to update user:', error);
return null;
}
};
// Delete a user
export const deleteUser = (username: string): boolean => {
try {
// Cannot delete the last admin user
const users = getUsers();
const adminUsers = users.filter((user) => user.isAdmin);
const userToDelete = users.find((user) => user.username === username);
if (userToDelete?.isAdmin && adminUsers.length === 1) {
return false; // Cannot delete the last admin
}
const filteredUsers = users.filter((user) => user.username !== username);
if (filteredUsers.length === users.length) {
return false; // User not found
}
// Save filtered users back to settings
const settings = loadSettings();
settings.users = filteredUsers;
return saveSettings(settings);
} catch (error) {
console.error('Failed to delete user:', error);
return false;
}
};
// Check if user has admin permissions
export const isUserAdmin = (username: string): boolean => {
const user = findUserByUsername(username);
return user?.isAdmin || false;
};
// Get user count
export const getUserCount = (): number => {
return getUsers().length;
};
// Get admin count
export const getAdminCount = (): number => {
return getUsers().filter((user) => user.isAdmin).length;
};