mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 09:50:15 +01:00
828 lines
28 KiB
TypeScript
828 lines
28 KiB
TypeScript
|
|
import mongoose from 'mongoose';
|
||
|
|
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||
|
|
import type { MCPOptions } from 'librechat-data-provider';
|
||
|
|
import type * as t from '~/types';
|
||
|
|
import { createMCPServerMethods } from './mcpServer';
|
||
|
|
import mcpServerSchema from '~/schema/mcpServer';
|
||
|
|
|
||
|
|
let mongoServer: MongoMemoryServer;
|
||
|
|
let MCPServer: mongoose.Model<t.MCPServerDocument>;
|
||
|
|
let methods: ReturnType<typeof createMCPServerMethods>;
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
mongoServer = await MongoMemoryServer.create();
|
||
|
|
const mongoUri = mongoServer.getUri();
|
||
|
|
MCPServer = mongoose.models.MCPServer || mongoose.model('MCPServer', mcpServerSchema);
|
||
|
|
methods = createMCPServerMethods(mongoose);
|
||
|
|
await mongoose.connect(mongoUri);
|
||
|
|
});
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
await mongoose.disconnect();
|
||
|
|
await mongoServer.stop();
|
||
|
|
});
|
||
|
|
|
||
|
|
beforeEach(async () => {
|
||
|
|
await mongoose.connection.dropDatabase();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('MCPServer Model Tests', () => {
|
||
|
|
const authorId = new mongoose.Types.ObjectId();
|
||
|
|
const authorId2 = new mongoose.Types.ObjectId();
|
||
|
|
|
||
|
|
const createSSEConfig = (title?: string, description?: string): MCPOptions => ({
|
||
|
|
type: 'sse',
|
||
|
|
url: 'https://example.com/mcp',
|
||
|
|
...(title && { title }),
|
||
|
|
...(description && { description }),
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('createMCPServer', () => {
|
||
|
|
test('should create server with title and generate slug from title', async () => {
|
||
|
|
const config = createSSEConfig('My Test Server', 'A test server');
|
||
|
|
const server = await methods.createMCPServer({ config, author: authorId });
|
||
|
|
|
||
|
|
expect(server).toBeDefined();
|
||
|
|
expect(server.serverName).toBe('my-test-server');
|
||
|
|
expect(server.config.title).toBe('My Test Server');
|
||
|
|
expect(server.config.description).toBe('A test server');
|
||
|
|
expect(server.author.toString()).toBe(authorId.toString());
|
||
|
|
expect(server.createdAt).toBeInstanceOf(Date);
|
||
|
|
expect(server.updatedAt).toBeInstanceOf(Date);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should create server without title and use nanoid', async () => {
|
||
|
|
const config: MCPOptions = {
|
||
|
|
type: 'sse',
|
||
|
|
url: 'https://example.com/mcp',
|
||
|
|
};
|
||
|
|
const server = await methods.createMCPServer({ config, author: authorId });
|
||
|
|
|
||
|
|
expect(server).toBeDefined();
|
||
|
|
expect(server.serverName).toMatch(/^mcp-[a-zA-Z0-9_-]{16}$/);
|
||
|
|
expect(server.config.title).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle title with special characters', async () => {
|
||
|
|
const config = createSSEConfig('My @#$% Server!!! 123');
|
||
|
|
const server = await methods.createMCPServer({ config, author: authorId });
|
||
|
|
|
||
|
|
expect(server.serverName).toBe('my-server-123');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle title with only spaces and special chars', async () => {
|
||
|
|
const config = createSSEConfig(' @#$% ');
|
||
|
|
const server = await methods.createMCPServer({ config, author: authorId });
|
||
|
|
|
||
|
|
// Should fallback to 'mcp-server'
|
||
|
|
expect(server.serverName).toBe('mcp-server');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle title with multiple spaces', async () => {
|
||
|
|
const config = createSSEConfig('My Multiple Spaces Server');
|
||
|
|
const server = await methods.createMCPServer({ config, author: authorId });
|
||
|
|
|
||
|
|
expect(server.serverName).toBe('my-multiple-spaces-server');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle string author ID', async () => {
|
||
|
|
const config = createSSEConfig('String Author Test');
|
||
|
|
const server = await methods.createMCPServer({
|
||
|
|
config,
|
||
|
|
author: authorId.toString(),
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(server).toBeDefined();
|
||
|
|
expect(server.author.toString()).toBe(authorId.toString());
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should create server with stdio config', async () => {
|
||
|
|
const config: MCPOptions = {
|
||
|
|
type: 'stdio',
|
||
|
|
command: 'node',
|
||
|
|
args: ['server.js'],
|
||
|
|
title: 'Stdio Server',
|
||
|
|
};
|
||
|
|
const server = await methods.createMCPServer({ config, author: authorId });
|
||
|
|
|
||
|
|
expect(server.serverName).toBe('stdio-server');
|
||
|
|
expect(server.config.type).toBe('stdio');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('findNextAvailableServerName', () => {
|
||
|
|
test('should return base name when no duplicates exist', async () => {
|
||
|
|
// Create server directly via model to set up initial state
|
||
|
|
await MCPServer.create({
|
||
|
|
serverName: 'other-server',
|
||
|
|
config: createSSEConfig('Other Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const config = createSSEConfig('Test Server');
|
||
|
|
const server = await methods.createMCPServer({ config, author: authorId });
|
||
|
|
|
||
|
|
expect(server.serverName).toBe('test-server');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should append -2 when base name exists', async () => {
|
||
|
|
// Create first server
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create second server with same title
|
||
|
|
const server = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(server.serverName).toBe('test-server-2');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should find next available number in sequence', async () => {
|
||
|
|
// Create servers with sequential names
|
||
|
|
await MCPServer.create({
|
||
|
|
serverName: 'test-server',
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
await MCPServer.create({
|
||
|
|
serverName: 'test-server-2',
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
await MCPServer.create({
|
||
|
|
serverName: 'test-server-3',
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const server = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(server.serverName).toBe('test-server-4');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle gaps in sequence', async () => {
|
||
|
|
// Create servers with gaps: test, test-2, test-5
|
||
|
|
await MCPServer.create({
|
||
|
|
serverName: 'test-server',
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
await MCPServer.create({
|
||
|
|
serverName: 'test-server-2',
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
await MCPServer.create({
|
||
|
|
serverName: 'test-server-5',
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const server = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should append -6 (max + 1)
|
||
|
|
expect(server.serverName).toBe('test-server-6');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should not match partial names', async () => {
|
||
|
|
// Create 'test-server-extra' which shouldn't affect 'test-server' sequence
|
||
|
|
await MCPServer.create({
|
||
|
|
serverName: 'test-server-extra',
|
||
|
|
config: createSSEConfig('Test Server Extra'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const server = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
// 'test-server' is available, so should use it
|
||
|
|
expect(server.serverName).toBe('test-server');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle special regex characters in base name', async () => {
|
||
|
|
// The slug generation removes special characters, but test the regex escaping
|
||
|
|
await MCPServer.create({
|
||
|
|
serverName: 'test-server',
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const server = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Test Server'),
|
||
|
|
author: authorId2,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(server.serverName).toBe('test-server-2');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('findMCPServerById', () => {
|
||
|
|
test('should find server by serverName', async () => {
|
||
|
|
const created = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Find By Id Test'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const found = await methods.findMCPServerById(created.serverName);
|
||
|
|
|
||
|
|
expect(found).toBeDefined();
|
||
|
|
expect(found?.serverName).toBe('find-by-id-test');
|
||
|
|
expect(found?.config.title).toBe('Find By Id Test');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return null when server not found', async () => {
|
||
|
|
const found = await methods.findMCPServerById('non-existent-server');
|
||
|
|
|
||
|
|
expect(found).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return lean document', async () => {
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Lean Test'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const found = await methods.findMCPServerById('lean-test');
|
||
|
|
|
||
|
|
// Lean documents don't have mongoose methods
|
||
|
|
expect(found).toBeDefined();
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
expect(typeof (found as any).save).toBe('undefined');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('findMCPServerByObjectId', () => {
|
||
|
|
test('should find server by MongoDB ObjectId', async () => {
|
||
|
|
const created = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Object Id Test'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const found = await methods.findMCPServerByObjectId(created._id);
|
||
|
|
|
||
|
|
expect(found).toBeDefined();
|
||
|
|
expect(found?.serverName).toBe('object-id-test');
|
||
|
|
expect(found?._id.toString()).toBe(created._id.toString());
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should find server by string ObjectId', async () => {
|
||
|
|
const created = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('String Object Id Test'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const found = await methods.findMCPServerByObjectId(created._id.toString());
|
||
|
|
|
||
|
|
expect(found).toBeDefined();
|
||
|
|
expect(found?.serverName).toBe('string-object-id-test');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return null when ObjectId not found', async () => {
|
||
|
|
const randomId = new mongoose.Types.ObjectId();
|
||
|
|
const found = await methods.findMCPServerByObjectId(randomId);
|
||
|
|
|
||
|
|
expect(found).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return null for invalid ObjectId string', async () => {
|
||
|
|
await expect(methods.findMCPServerByObjectId('invalid-id')).rejects.toThrow();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('findMCPServersByAuthor', () => {
|
||
|
|
test('should find all servers by author', async () => {
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Author Server 1'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Author Server 2'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Other Author Server'),
|
||
|
|
author: authorId2,
|
||
|
|
});
|
||
|
|
|
||
|
|
const servers = await methods.findMCPServersByAuthor(authorId);
|
||
|
|
|
||
|
|
expect(servers).toHaveLength(2);
|
||
|
|
expect(servers.every((s) => s.author.toString() === authorId.toString())).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return empty array when author has no servers', async () => {
|
||
|
|
const servers = await methods.findMCPServersByAuthor(new mongoose.Types.ObjectId());
|
||
|
|
|
||
|
|
expect(servers).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should sort by updatedAt descending', async () => {
|
||
|
|
// Create servers with slight delay to ensure different timestamps
|
||
|
|
const server1 = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('First Created'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Update first server to make it most recently updated
|
||
|
|
await MCPServer.findByIdAndUpdate(server1._id, {
|
||
|
|
$set: { 'config.description': 'Updated' },
|
||
|
|
});
|
||
|
|
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Second Created'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const servers = await methods.findMCPServersByAuthor(authorId);
|
||
|
|
|
||
|
|
expect(servers).toHaveLength(2);
|
||
|
|
// Most recently updated should come first
|
||
|
|
expect(servers[0].serverName).toBe('second-created');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle string author ID', async () => {
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('String Author Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const servers = await methods.findMCPServersByAuthor(authorId.toString());
|
||
|
|
|
||
|
|
expect(servers).toHaveLength(1);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getListMCPServersByIds', () => {
|
||
|
|
let server1: t.MCPServerDocument;
|
||
|
|
let server2: t.MCPServerDocument;
|
||
|
|
let server3: t.MCPServerDocument;
|
||
|
|
|
||
|
|
beforeEach(async () => {
|
||
|
|
server1 = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Server One'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
server2 = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Server Two'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
server3 = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Server Three'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return servers matching provided IDs', async () => {
|
||
|
|
const result = await methods.getListMCPServersByIds({
|
||
|
|
ids: [server1._id, server2._id],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.data).toHaveLength(2);
|
||
|
|
expect(result.has_more).toBe(false);
|
||
|
|
expect(result.after).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return empty data for empty IDs array', async () => {
|
||
|
|
const result = await methods.getListMCPServersByIds({ ids: [] });
|
||
|
|
|
||
|
|
expect(result.data).toEqual([]);
|
||
|
|
expect(result.has_more).toBe(false);
|
||
|
|
expect(result.after).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle pagination with limit', async () => {
|
||
|
|
const result = await methods.getListMCPServersByIds({
|
||
|
|
ids: [server1._id, server2._id, server3._id],
|
||
|
|
limit: 2,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.data).toHaveLength(2);
|
||
|
|
expect(result.has_more).toBe(true);
|
||
|
|
expect(result.after).not.toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should paginate using cursor', async () => {
|
||
|
|
// Get first page
|
||
|
|
const firstPage = await methods.getListMCPServersByIds({
|
||
|
|
ids: [server1._id, server2._id, server3._id],
|
||
|
|
limit: 2,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(firstPage.has_more).toBe(true);
|
||
|
|
expect(firstPage.after).not.toBeNull();
|
||
|
|
|
||
|
|
// Get second page using cursor
|
||
|
|
const secondPage = await methods.getListMCPServersByIds({
|
||
|
|
ids: [server1._id, server2._id, server3._id],
|
||
|
|
limit: 2,
|
||
|
|
after: firstPage.after,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(secondPage.data).toHaveLength(1);
|
||
|
|
expect(secondPage.has_more).toBe(false);
|
||
|
|
expect(secondPage.after).toBeNull();
|
||
|
|
|
||
|
|
// Ensure no duplicates between pages
|
||
|
|
const firstPageIds = firstPage.data.map((s) => s._id.toString());
|
||
|
|
const secondPageIds = secondPage.data.map((s) => s._id.toString());
|
||
|
|
const intersection = firstPageIds.filter((id) => secondPageIds.includes(id));
|
||
|
|
expect(intersection).toHaveLength(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle invalid cursor gracefully', async () => {
|
||
|
|
const result = await methods.getListMCPServersByIds({
|
||
|
|
ids: [server1._id, server2._id],
|
||
|
|
after: 'invalid-cursor',
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should still return results, ignoring invalid cursor
|
||
|
|
expect(result.data).toHaveLength(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return all when limit is null', async () => {
|
||
|
|
const result = await methods.getListMCPServersByIds({
|
||
|
|
ids: [server1._id, server2._id, server3._id],
|
||
|
|
limit: null,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.data).toHaveLength(3);
|
||
|
|
expect(result.has_more).toBe(false);
|
||
|
|
expect(result.after).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should apply additional filters via otherParams', async () => {
|
||
|
|
// Create a server with different config
|
||
|
|
const serverWithDesc = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Filtered Server', 'Has description'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await methods.getListMCPServersByIds({
|
||
|
|
ids: [server1._id, server2._id, serverWithDesc._id],
|
||
|
|
otherParams: { 'config.description': 'Has description' },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.data).toHaveLength(1);
|
||
|
|
expect(result.data[0].serverName).toBe('filtered-server');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should normalize limit to valid range', async () => {
|
||
|
|
// Limit should be clamped to 1-100
|
||
|
|
const resultLow = await methods.getListMCPServersByIds({
|
||
|
|
ids: [server1._id, server2._id, server3._id],
|
||
|
|
limit: 0,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(resultLow.data.length).toBeGreaterThanOrEqual(1);
|
||
|
|
|
||
|
|
const resultHigh = await methods.getListMCPServersByIds({
|
||
|
|
ids: [server1._id, server2._id, server3._id],
|
||
|
|
limit: 200,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(resultHigh.data).toHaveLength(3); // All 3 servers (less than 100)
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should sort by updatedAt descending, _id ascending', async () => {
|
||
|
|
const result = await methods.getListMCPServersByIds({
|
||
|
|
ids: [server1._id, server2._id, server3._id],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.data).toHaveLength(3);
|
||
|
|
// Most recently created/updated should come first
|
||
|
|
for (let i = 0; i < result.data.length - 1; i++) {
|
||
|
|
const current = new Date(result.data[i].updatedAt!).getTime();
|
||
|
|
const next = new Date(result.data[i + 1].updatedAt!).getTime();
|
||
|
|
expect(current).toBeGreaterThanOrEqual(next);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('updateMCPServer', () => {
|
||
|
|
test('should update server config', async () => {
|
||
|
|
const created = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Update Test', 'Original description'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const updated = await methods.updateMCPServer(created.serverName, {
|
||
|
|
config: createSSEConfig('Update Test', 'Updated description'),
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(updated).toBeDefined();
|
||
|
|
expect(updated?.config.description).toBe('Updated description');
|
||
|
|
expect(updated?.serverName).toBe('update-test'); // serverName shouldn't change
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return null when server not found', async () => {
|
||
|
|
const updated = await methods.updateMCPServer('non-existent', {
|
||
|
|
config: createSSEConfig('Test'),
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(updated).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return updated document (new: true)', async () => {
|
||
|
|
const created = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Return Test'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const updated = await methods.updateMCPServer(created.serverName, {
|
||
|
|
config: createSSEConfig('Return Test', 'New description'),
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(updated?.config.description).toBe('New description');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should run validators on update', async () => {
|
||
|
|
const created = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Validation Test'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
// The update should succeed with valid config
|
||
|
|
const updated = await methods.updateMCPServer(created.serverName, {
|
||
|
|
config: createSSEConfig('Validation Test', 'Valid config'),
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(updated).toBeDefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should update timestamps', async () => {
|
||
|
|
const created = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Timestamp Test'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const originalUpdatedAt = created.updatedAt;
|
||
|
|
|
||
|
|
// Wait a bit to ensure timestamp difference
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
|
|
|
||
|
|
const updated = await methods.updateMCPServer(created.serverName, {
|
||
|
|
config: createSSEConfig('Timestamp Test', 'Updated'),
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(updated?.updatedAt).toBeDefined();
|
||
|
|
expect(new Date(updated!.updatedAt!).getTime()).toBeGreaterThan(
|
||
|
|
new Date(originalUpdatedAt!).getTime(),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle partial config updates', async () => {
|
||
|
|
const created = await methods.createMCPServer({
|
||
|
|
config: {
|
||
|
|
type: 'sse',
|
||
|
|
url: 'https://example.com/mcp',
|
||
|
|
title: 'Partial Update Test',
|
||
|
|
description: 'Original',
|
||
|
|
},
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const updated = await methods.updateMCPServer(created.serverName, {
|
||
|
|
config: {
|
||
|
|
type: 'sse',
|
||
|
|
url: 'https://example.com/mcp',
|
||
|
|
title: 'Partial Update Test',
|
||
|
|
description: 'New description',
|
||
|
|
iconPath: '/icons/new-icon.png',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(updated?.config.description).toBe('New description');
|
||
|
|
expect(updated?.config.iconPath).toBe('/icons/new-icon.png');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('deleteMCPServer', () => {
|
||
|
|
test('should delete existing server', async () => {
|
||
|
|
const created = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Delete Test'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const deleted = await methods.deleteMCPServer(created.serverName);
|
||
|
|
|
||
|
|
expect(deleted).toBeDefined();
|
||
|
|
expect(deleted?.serverName).toBe('delete-test');
|
||
|
|
|
||
|
|
// Verify it's actually deleted
|
||
|
|
const found = await methods.findMCPServerById('delete-test');
|
||
|
|
expect(found).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return null when server does not exist', async () => {
|
||
|
|
const deleted = await methods.deleteMCPServer('non-existent-server');
|
||
|
|
|
||
|
|
expect(deleted).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return the deleted document', async () => {
|
||
|
|
const created = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Delete Return Test', 'Will be deleted'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const deleted = await methods.deleteMCPServer(created.serverName);
|
||
|
|
|
||
|
|
expect(deleted?.config.description).toBe('Will be deleted');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getListMCPServersByNames', () => {
|
||
|
|
test('should return empty data for empty names array', async () => {
|
||
|
|
const result = await methods.getListMCPServersByNames({ names: [] });
|
||
|
|
|
||
|
|
expect(result.data).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should find servers by serverName strings', async () => {
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Name Query One'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Name Query Two'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Name Query Three'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await methods.getListMCPServersByNames({
|
||
|
|
names: ['name-query-one', 'name-query-two'],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.data).toHaveLength(2);
|
||
|
|
const serverNames = result.data.map((s) => s.serverName);
|
||
|
|
expect(serverNames).toContain('name-query-one');
|
||
|
|
expect(serverNames).toContain('name-query-two');
|
||
|
|
expect(serverNames).not.toContain('name-query-three');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle non-existent names gracefully', async () => {
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Existing Server'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await methods.getListMCPServersByNames({
|
||
|
|
names: ['existing-server', 'non-existent-1', 'non-existent-2'],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.data).toHaveLength(1);
|
||
|
|
expect(result.data[0].serverName).toBe('existing-server');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should return all matching servers for multiple names', async () => {
|
||
|
|
const server1 = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Multi Name 1'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
const server2 = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Multi Name 2'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
const server3 = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Multi Name 3'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await methods.getListMCPServersByNames({
|
||
|
|
names: [server1.serverName, server2.serverName, server3.serverName],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.data).toHaveLength(3);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle duplicate names in input', async () => {
|
||
|
|
await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Duplicate Test'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await methods.getListMCPServersByNames({
|
||
|
|
names: ['duplicate-test', 'duplicate-test', 'duplicate-test'],
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should only return one server (unique by serverName)
|
||
|
|
expect(result.data).toHaveLength(1);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Edge Cases', () => {
|
||
|
|
test('should handle concurrent creation with retry logic for race conditions', async () => {
|
||
|
|
// Ensure indexes are created before concurrent test
|
||
|
|
await MCPServer.ensureIndexes();
|
||
|
|
|
||
|
|
// Create multiple servers with same title concurrently
|
||
|
|
// The retry logic handles TOCTOU race conditions by retrying with
|
||
|
|
// exponential backoff when duplicate key errors occur
|
||
|
|
const promises = Array.from({ length: 5 }, () =>
|
||
|
|
methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Concurrent Test'),
|
||
|
|
author: authorId,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
|
||
|
|
const results = await Promise.allSettled(promises);
|
||
|
|
|
||
|
|
const successes = results.filter(
|
||
|
|
(r): r is PromiseFulfilledResult<t.MCPServerDocument> => r.status === 'fulfilled',
|
||
|
|
);
|
||
|
|
const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
|
||
|
|
|
||
|
|
// With retry logic, all concurrent requests should succeed
|
||
|
|
// Each will get a unique serverName (concurrent-test, concurrent-test-2, etc.)
|
||
|
|
expect(successes.length).toBe(5);
|
||
|
|
expect(failures.length).toBe(0);
|
||
|
|
|
||
|
|
// Verify all servers have unique names
|
||
|
|
const serverNames = successes.map((s) => s.value.serverName);
|
||
|
|
const uniqueNames = new Set(serverNames);
|
||
|
|
expect(uniqueNames.size).toBe(5);
|
||
|
|
|
||
|
|
// Verify all servers exist in the database
|
||
|
|
const dbServers = await MCPServer.find({
|
||
|
|
serverName: { $regex: /^concurrent-test/ },
|
||
|
|
}).lean();
|
||
|
|
expect(dbServers.length).toBe(5);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle sequential creation with same title - no race condition', async () => {
|
||
|
|
// Create multiple servers with same title sequentially
|
||
|
|
// Each creation completes before the next one starts, so no race condition
|
||
|
|
const results: t.MCPServerDocument[] = [];
|
||
|
|
for (let i = 0; i < 5; i++) {
|
||
|
|
const server = await methods.createMCPServer({
|
||
|
|
config: createSSEConfig('Sequential Test'),
|
||
|
|
author: authorId,
|
||
|
|
});
|
||
|
|
results.push(server);
|
||
|
|
}
|
||
|
|
|
||
|
|
// All should succeed with unique serverNames
|
||
|
|
const serverNames = results.map((r) => r.serverName);
|
||
|
|
const uniqueNames = new Set(serverNames);
|
||
|
|
expect(uniqueNames.size).toBe(5);
|
||
|
|
expect(serverNames).toContain('sequential-test');
|
||
|
|
expect(serverNames).toContain('sequential-test-2');
|
||
|
|
expect(serverNames).toContain('sequential-test-3');
|
||
|
|
expect(serverNames).toContain('sequential-test-4');
|
||
|
|
expect(serverNames).toContain('sequential-test-5');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle very long titles', async () => {
|
||
|
|
const longTitle = 'A'.repeat(200) + ' Server';
|
||
|
|
const config = createSSEConfig(longTitle);
|
||
|
|
const server = await methods.createMCPServer({ config, author: authorId });
|
||
|
|
|
||
|
|
expect(server).toBeDefined();
|
||
|
|
expect(server.serverName).toBe('a'.repeat(200) + '-server');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle unicode in title', async () => {
|
||
|
|
// Unicode characters should be stripped, leaving only alphanumeric
|
||
|
|
const config = createSSEConfig('Serveur Français 日本語');
|
||
|
|
const server = await methods.createMCPServer({ config, author: authorId });
|
||
|
|
|
||
|
|
expect(server.serverName).toBe('serveur-franais');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle empty string title', async () => {
|
||
|
|
const config: MCPOptions = {
|
||
|
|
type: 'sse',
|
||
|
|
url: 'https://example.com/mcp',
|
||
|
|
title: '',
|
||
|
|
};
|
||
|
|
const server = await methods.createMCPServer({ config, author: authorId });
|
||
|
|
|
||
|
|
// Empty title should fallback to nanoid
|
||
|
|
expect(server.serverName).toMatch(/^mcp-[a-zA-Z0-9_-]{16}$/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle whitespace-only title', async () => {
|
||
|
|
const config = createSSEConfig(' ');
|
||
|
|
const server = await methods.createMCPServer({ config, author: authorId });
|
||
|
|
|
||
|
|
// Whitespace-only title after trimming results in fallback
|
||
|
|
expect(server.serverName).toBe('mcp-server');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|