LibreChat/packages/data-schemas/src/methods/mcpServer.spec.ts
Danny Avila f8774983a0
🪪 fix: Misleading MCP Server Lookup Method Name (#11315)
* 🔧 fix: MCP server ID resolver in access permissions (#11315)

- Replaced `findMCPServerById` with `findMCPServerByObjectId` in access permissions route and corresponding tests for improved clarity and consistency in resource identification.

* 🔧 refactor: Update MCP server resource access methods to use server name

- Replaced instances of `findMCPServerById` with `findMCPServerByServerName` across middleware, database, and test files for improved clarity and consistency in resource identification.
- Updated related comments and test cases to reflect the change in method usage.

* chore: Increase timeout for Redis update in GenerationJobManager integration tests

- Updated the timeout duration from 50ms to 200ms in the GenerationJobManager integration tests to ensure reliable verification of final event data in Redis after emitting the done event.
2026-01-12 21:04:25 -05:00

827 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('findMCPServerByServerName', () => {
test('should find server by serverName', async () => {
const created = await methods.createMCPServer({
config: createSSEConfig('Find By Name Test'),
author: authorId,
});
const found = await methods.findMCPServerByServerName(created.serverName);
expect(found).toBeDefined();
expect(found?.serverName).toBe('find-by-name-test');
expect(found?.config.title).toBe('Find By Name Test');
});
test('should return null when server not found', async () => {
const found = await methods.findMCPServerByServerName('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.findMCPServerByServerName('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.findMCPServerByServerName('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');
});
});
});