mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-04 01:28:51 +01:00
🏗️ feat: Dynamic MCP Server Infrastructure with Access Control (#10787)
* Feature: Dynamic MCP Server with Full UI Management * 🚦 feat: Add MCP Connection Status icons to MCPBuilder panel (#10805) * feature: Add MCP server connection status icons to MCPBuilder panel * refactor: Simplify MCPConfigDialog rendering in MCPBuilderPanel --------- Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com> Co-authored-by: Danny Avila <danny@librechat.ai> * fix: address code review feedback for MCP server management - Fix OAuth secret preservation to avoid mutating input parameter by creating a merged config copy in ServerConfigsDB.update() - Improve error handling in getResourcePermissionsMap to propagate critical errors instead of silently returning empty Map - Extract duplicated MCP server filter logic by exposing selectableServers from useMCPServerManager hook and using it in MCPSelect component * test: Update PermissionService tests to throw errors on invalid resource types - Changed the test for handling invalid resource types to ensure it throws an error instead of returning an empty permissions map. - Updated the expectation to check for the specific error message when an invalid resource type is provided. * feat: Implement retry logic for MCP server creation to handle race conditions - Enhanced the createMCPServer method to include retry logic with exponential backoff for handling duplicate key errors during concurrent server creation. - Updated tests to verify that all concurrent requests succeed and that unique server names are generated. - Added a helper function to identify MongoDB duplicate key errors, improving error handling during server creation. * refactor: StatusIcon to use CircleCheck for connected status - Replaced the PlugZap icon with CircleCheck in the ConnectedStatusIcon component to better represent the connected state. - Ensured consistent icon usage across the component for improved visual clarity. * test: Update AccessControlService tests to throw errors on invalid resource types - Modified the test for invalid resource types to ensure it throws an error with a specific message instead of returning an empty permissions map. - This change enhances error handling and improves test coverage for the AccessControlService. * fix: Update error message for missing server name in MCP server retrieval - Changed the error message returned when the server name is not provided from 'MCP ID is required' to 'Server name is required' for better clarity and accuracy in the API response. --------- Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com> Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
41c0a96d39
commit
99f8bd2ce6
103 changed files with 7978 additions and 1003 deletions
|
|
@ -200,6 +200,9 @@ describe('AccessRole Model Tests', () => {
|
|||
AccessRoleIds.PROMPTGROUP_EDITOR,
|
||||
AccessRoleIds.PROMPTGROUP_OWNER,
|
||||
AccessRoleIds.PROMPTGROUP_VIEWER,
|
||||
AccessRoleIds.MCPSERVER_EDITOR,
|
||||
AccessRoleIds.MCPSERVER_OWNER,
|
||||
AccessRoleIds.MCPSERVER_VIEWER,
|
||||
].sort(),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -146,6 +146,27 @@ export function createAccessRoleMethods(mongoose: typeof import('mongoose')) {
|
|||
resourceType: ResourceType.PROMPTGROUP,
|
||||
permBits: RoleBits.OWNER,
|
||||
},
|
||||
{
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
|
||||
name: 'com_ui_mcp_server_role_viewer',
|
||||
description: 'com_ui_mcp_server_role_viewer_desc',
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
permBits: RoleBits.VIEWER,
|
||||
},
|
||||
{
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
|
||||
name: 'com_ui_mcp_server_role_editor',
|
||||
description: 'com_ui_mcp_server_role_editor_desc',
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
permBits: RoleBits.EDITOR,
|
||||
},
|
||||
{
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||||
name: 'com_ui_mcp_server_role_owner',
|
||||
description: 'com_ui_mcp_server_role_owner_desc',
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
permBits: RoleBits.OWNER,
|
||||
},
|
||||
];
|
||||
|
||||
const result: Record<string, IAccessRole> = {};
|
||||
|
|
|
|||
|
|
@ -679,4 +679,276 @@ describe('AclEntry Model Tests', () => {
|
|||
expect(effective).toBe(PermissionBits.VIEW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Permission Queries', () => {
|
||||
test('should get effective permissions for multiple resources in single query', async () => {
|
||||
const resource1 = new mongoose.Types.ObjectId();
|
||||
const resource2 = new mongoose.Types.ObjectId();
|
||||
const resource3 = new mongoose.Types.ObjectId();
|
||||
|
||||
/** Grant different permissions to different resources */
|
||||
await methods.grantPermission(
|
||||
PrincipalType.USER,
|
||||
userId,
|
||||
ResourceType.MCPSERVER,
|
||||
resource1,
|
||||
PermissionBits.VIEW,
|
||||
grantedById,
|
||||
);
|
||||
|
||||
await methods.grantPermission(
|
||||
PrincipalType.USER,
|
||||
userId,
|
||||
ResourceType.MCPSERVER,
|
||||
resource2,
|
||||
PermissionBits.VIEW | PermissionBits.EDIT,
|
||||
grantedById,
|
||||
);
|
||||
|
||||
await methods.grantPermission(
|
||||
PrincipalType.GROUP,
|
||||
groupId,
|
||||
ResourceType.MCPSERVER,
|
||||
resource3,
|
||||
PermissionBits.DELETE,
|
||||
grantedById,
|
||||
);
|
||||
|
||||
/** Get permissions for all resources */
|
||||
const permissionsMap = await methods.getEffectivePermissionsForResources(
|
||||
[{ principalType: PrincipalType.USER, principalId: userId }],
|
||||
ResourceType.MCPSERVER,
|
||||
[resource1, resource2, resource3],
|
||||
);
|
||||
|
||||
expect(permissionsMap.size).toBe(2); // Only resource1 and resource2 for user
|
||||
expect(permissionsMap.get(resource1.toString())).toBe(PermissionBits.VIEW);
|
||||
expect(permissionsMap.get(resource2.toString())).toBe(PermissionBits.VIEW | PermissionBits.EDIT);
|
||||
expect(permissionsMap.get(resource3.toString())).toBeUndefined(); // User has no access
|
||||
});
|
||||
|
||||
test('should combine permissions from multiple principals in batch query', async () => {
|
||||
const resource1 = new mongoose.Types.ObjectId();
|
||||
const resource2 = new mongoose.Types.ObjectId();
|
||||
|
||||
/** User has VIEW on both resources */
|
||||
await methods.grantPermission(
|
||||
PrincipalType.USER,
|
||||
userId,
|
||||
ResourceType.MCPSERVER,
|
||||
resource1,
|
||||
PermissionBits.VIEW,
|
||||
grantedById,
|
||||
);
|
||||
|
||||
await methods.grantPermission(
|
||||
PrincipalType.USER,
|
||||
userId,
|
||||
ResourceType.MCPSERVER,
|
||||
resource2,
|
||||
PermissionBits.VIEW,
|
||||
grantedById,
|
||||
);
|
||||
|
||||
/** Group has EDIT on resource1 */
|
||||
await methods.grantPermission(
|
||||
PrincipalType.GROUP,
|
||||
groupId,
|
||||
ResourceType.MCPSERVER,
|
||||
resource1,
|
||||
PermissionBits.EDIT,
|
||||
grantedById,
|
||||
);
|
||||
|
||||
/** Get combined permissions for user + group */
|
||||
const permissionsMap = await methods.getEffectivePermissionsForResources(
|
||||
[
|
||||
{ principalType: PrincipalType.USER, principalId: userId },
|
||||
{ principalType: PrincipalType.GROUP, principalId: groupId },
|
||||
],
|
||||
ResourceType.MCPSERVER,
|
||||
[resource1, resource2],
|
||||
);
|
||||
|
||||
expect(permissionsMap.size).toBe(2);
|
||||
/** Resource1 should have VIEW | EDIT (from user + group) */
|
||||
expect(permissionsMap.get(resource1.toString())).toBe(PermissionBits.VIEW | PermissionBits.EDIT);
|
||||
/** Resource2 should have only VIEW (from user) */
|
||||
expect(permissionsMap.get(resource2.toString())).toBe(PermissionBits.VIEW);
|
||||
});
|
||||
|
||||
test('should handle empty resource list', async () => {
|
||||
const permissionsMap = await methods.getEffectivePermissionsForResources(
|
||||
[{ principalType: PrincipalType.USER, principalId: userId }],
|
||||
ResourceType.MCPSERVER,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(permissionsMap.size).toBe(0);
|
||||
});
|
||||
|
||||
test('should handle resources with no permissions', async () => {
|
||||
const resource1 = new mongoose.Types.ObjectId();
|
||||
const resource2 = new mongoose.Types.ObjectId();
|
||||
|
||||
/** Only grant permission to resource1 */
|
||||
await methods.grantPermission(
|
||||
PrincipalType.USER,
|
||||
userId,
|
||||
ResourceType.MCPSERVER,
|
||||
resource1,
|
||||
PermissionBits.VIEW,
|
||||
grantedById,
|
||||
);
|
||||
|
||||
const permissionsMap = await methods.getEffectivePermissionsForResources(
|
||||
[{ principalType: PrincipalType.USER, principalId: userId }],
|
||||
ResourceType.MCPSERVER,
|
||||
[resource1, resource2], // resource2 has no permissions
|
||||
);
|
||||
|
||||
expect(permissionsMap.size).toBe(1);
|
||||
expect(permissionsMap.get(resource1.toString())).toBe(PermissionBits.VIEW);
|
||||
expect(permissionsMap.get(resource2.toString())).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should include public permissions in batch query', async () => {
|
||||
const resource1 = new mongoose.Types.ObjectId();
|
||||
const resource2 = new mongoose.Types.ObjectId();
|
||||
|
||||
/** User has VIEW on resource1 */
|
||||
await methods.grantPermission(
|
||||
PrincipalType.USER,
|
||||
userId,
|
||||
ResourceType.MCPSERVER,
|
||||
resource1,
|
||||
PermissionBits.VIEW | PermissionBits.EDIT,
|
||||
grantedById,
|
||||
);
|
||||
|
||||
/** Public has VIEW on resource2 */
|
||||
await methods.grantPermission(
|
||||
PrincipalType.PUBLIC,
|
||||
null,
|
||||
ResourceType.MCPSERVER,
|
||||
resource2,
|
||||
PermissionBits.VIEW,
|
||||
grantedById,
|
||||
);
|
||||
|
||||
/** Query with user + public principals */
|
||||
const permissionsMap = await methods.getEffectivePermissionsForResources(
|
||||
[
|
||||
{ principalType: PrincipalType.USER, principalId: userId },
|
||||
{ principalType: PrincipalType.PUBLIC },
|
||||
],
|
||||
ResourceType.MCPSERVER,
|
||||
[resource1, resource2],
|
||||
);
|
||||
|
||||
expect(permissionsMap.size).toBe(2);
|
||||
expect(permissionsMap.get(resource1.toString())).toBe(PermissionBits.VIEW | PermissionBits.EDIT);
|
||||
expect(permissionsMap.get(resource2.toString())).toBe(PermissionBits.VIEW);
|
||||
});
|
||||
|
||||
test('should handle large batch efficiently', async () => {
|
||||
/** Create 50 resources with various permissions */
|
||||
const resources = Array.from({ length: 50 }, () => new mongoose.Types.ObjectId());
|
||||
|
||||
/** Grant permissions to first 30 resources */
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await methods.grantPermission(
|
||||
PrincipalType.USER,
|
||||
userId,
|
||||
ResourceType.MCPSERVER,
|
||||
resources[i],
|
||||
PermissionBits.VIEW,
|
||||
grantedById,
|
||||
);
|
||||
}
|
||||
|
||||
/** Grant group permissions to resources 20-40 (overlap with user) */
|
||||
for (let i = 20; i < 40; i++) {
|
||||
await methods.grantPermission(
|
||||
PrincipalType.GROUP,
|
||||
groupId,
|
||||
ResourceType.MCPSERVER,
|
||||
resources[i],
|
||||
PermissionBits.EDIT,
|
||||
grantedById,
|
||||
);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const permissionsMap = await methods.getEffectivePermissionsForResources(
|
||||
[
|
||||
{ principalType: PrincipalType.USER, principalId: userId },
|
||||
{ principalType: PrincipalType.GROUP, principalId: groupId },
|
||||
],
|
||||
ResourceType.MCPSERVER,
|
||||
resources,
|
||||
);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
/** Should be reasonably fast (under 1 second for 50 resources) */
|
||||
expect(duration).toBeLessThan(1000);
|
||||
|
||||
/** Verify results */
|
||||
expect(permissionsMap.size).toBe(40); // Resources 0-39 have permissions
|
||||
|
||||
/** Resources 0-19: USER VIEW only */
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(permissionsMap.get(resources[i].toString())).toBe(PermissionBits.VIEW);
|
||||
}
|
||||
|
||||
/** Resources 20-29: USER VIEW | GROUP EDIT */
|
||||
for (let i = 20; i < 30; i++) {
|
||||
expect(permissionsMap.get(resources[i].toString())).toBe(PermissionBits.VIEW | PermissionBits.EDIT);
|
||||
}
|
||||
|
||||
/** Resources 30-39: GROUP EDIT only */
|
||||
for (let i = 30; i < 40; i++) {
|
||||
expect(permissionsMap.get(resources[i].toString())).toBe(PermissionBits.EDIT);
|
||||
}
|
||||
|
||||
/** Resources 40-49: No permissions */
|
||||
for (let i = 40; i < 50; i++) {
|
||||
expect(permissionsMap.get(resources[i].toString())).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle mixed ObjectId and string resource IDs', async () => {
|
||||
const resource1 = new mongoose.Types.ObjectId();
|
||||
const resource2 = new mongoose.Types.ObjectId();
|
||||
|
||||
await methods.grantPermission(
|
||||
PrincipalType.USER,
|
||||
userId,
|
||||
ResourceType.MCPSERVER,
|
||||
resource1,
|
||||
PermissionBits.VIEW,
|
||||
grantedById,
|
||||
);
|
||||
|
||||
await methods.grantPermission(
|
||||
PrincipalType.USER,
|
||||
userId,
|
||||
ResourceType.MCPSERVER,
|
||||
resource2,
|
||||
PermissionBits.EDIT,
|
||||
grantedById,
|
||||
);
|
||||
|
||||
/** Pass mix of ObjectId and string */
|
||||
const permissionsMap = await methods.getEffectivePermissionsForResources(
|
||||
[{ principalType: PrincipalType.USER, principalId: userId }],
|
||||
ResourceType.MCPSERVER,
|
||||
[resource1, resource2.toString()], // Mix of ObjectId and string
|
||||
);
|
||||
|
||||
expect(permissionsMap.size).toBe(2);
|
||||
expect(permissionsMap.get(resource1.toString())).toBe(PermissionBits.VIEW);
|
||||
expect(permissionsMap.get(resource2.toString())).toBe(PermissionBits.EDIT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -118,6 +118,58 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
|
|||
return effectiveBits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective permissions for multiple resources in a single query (BATCH)
|
||||
* Returns a map of resourceId → effectivePermissionBits
|
||||
*
|
||||
* @param principalsList - List of principals (user + groups + public)
|
||||
* @param resourceType - The type of resource ('MCPSERVER', 'AGENT', etc.)
|
||||
* @param resourceIds - Array of resource IDs to check
|
||||
* @returns {Promise<Map<string, number>>} Map of resourceId → permission bits
|
||||
*
|
||||
* @example
|
||||
* const principals = await getUserPrincipals({ userId, role });
|
||||
* const serverIds = [id1, id2, id3];
|
||||
* const permMap = await getEffectivePermissionsForResources(
|
||||
* principals,
|
||||
* ResourceType.MCPSERVER,
|
||||
* serverIds
|
||||
* );
|
||||
* // permMap.get(id1.toString()) → 7 (VIEW|EDIT|DELETE)
|
||||
*/
|
||||
async function getEffectivePermissionsForResources(
|
||||
principalsList: Array<{ principalType: string; principalId?: string | Types.ObjectId }>,
|
||||
resourceType: string,
|
||||
resourceIds: Array<string | Types.ObjectId>,
|
||||
): Promise<Map<string, number>> {
|
||||
if (!Array.isArray(resourceIds) || resourceIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
|
||||
const principalsQuery = principalsList.map((p) => ({
|
||||
principalType: p.principalType,
|
||||
...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }),
|
||||
}));
|
||||
|
||||
// Batch query for all resources at once
|
||||
const aclEntries = await AclEntry.find({
|
||||
$or: principalsQuery,
|
||||
resourceType,
|
||||
resourceId: { $in: resourceIds },
|
||||
}).lean();
|
||||
|
||||
// Compute effective permissions per resource
|
||||
const permissionsMap = new Map<string, number>();
|
||||
for (const entry of aclEntries) {
|
||||
const rid = entry.resourceId.toString();
|
||||
const currentBits = permissionsMap.get(rid) || 0;
|
||||
permissionsMap.set(rid, currentBits | entry.permBits);
|
||||
}
|
||||
|
||||
return permissionsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant permission to a principal for a resource
|
||||
* @param principalType - The type of principal ('user', 'group', 'public')
|
||||
|
|
@ -301,6 +353,7 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
|
|||
findEntriesByPrincipalsAndResource,
|
||||
hasPermission,
|
||||
getEffectivePermissions,
|
||||
getEffectivePermissionsForResources,
|
||||
grantPermission,
|
||||
revokePermission,
|
||||
modifyPermissionBits,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { createFileMethods, type FileMethods } from './file';
|
|||
import { createMemoryMethods, type MemoryMethods } from './memory';
|
||||
/* Agent Categories */
|
||||
import { createAgentCategoryMethods, type AgentCategoryMethods } from './agentCategory';
|
||||
/* MCP Servers */
|
||||
import { createMCPServerMethods, type MCPServerMethods } from './mcpServer';
|
||||
/* Plugin Auth */
|
||||
import { createPluginAuthMethods, type PluginAuthMethods } from './pluginAuth';
|
||||
/* Permissions */
|
||||
|
|
@ -24,6 +26,7 @@ export type AllMethods = UserMethods &
|
|||
FileMethods &
|
||||
MemoryMethods &
|
||||
AgentCategoryMethods &
|
||||
MCPServerMethods &
|
||||
UserGroupMethods &
|
||||
AclEntryMethods &
|
||||
ShareMethods &
|
||||
|
|
@ -44,6 +47,7 @@ export function createMethods(mongoose: typeof import('mongoose')): AllMethods {
|
|||
...createFileMethods(mongoose),
|
||||
...createMemoryMethods(mongoose),
|
||||
...createAgentCategoryMethods(mongoose),
|
||||
...createMCPServerMethods(mongoose),
|
||||
...createAccessRoleMethods(mongoose),
|
||||
...createUserGroupMethods(mongoose),
|
||||
...createAclEntryMethods(mongoose),
|
||||
|
|
@ -61,6 +65,7 @@ export type {
|
|||
FileMethods,
|
||||
MemoryMethods,
|
||||
AgentCategoryMethods,
|
||||
MCPServerMethods,
|
||||
UserGroupMethods,
|
||||
AclEntryMethods,
|
||||
ShareMethods,
|
||||
|
|
|
|||
827
packages/data-schemas/src/methods/mcpServer.spec.ts
Normal file
827
packages/data-schemas/src/methods/mcpServer.spec.ts
Normal file
|
|
@ -0,0 +1,827 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
324
packages/data-schemas/src/methods/mcpServer.ts
Normal file
324
packages/data-schemas/src/methods/mcpServer.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import type { Model, RootFilterQuery, Types } from 'mongoose';
|
||||
import type { MCPServerDocument } from '../types';
|
||||
import type { MCPOptions } from 'librechat-data-provider';
|
||||
import logger from '~/config/winston';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
const NORMALIZED_LIMIT_DEFAULT = 20;
|
||||
const MAX_CREATE_RETRIES = 3;
|
||||
const RETRY_BASE_DELAY_MS = 10;
|
||||
|
||||
/**
|
||||
* Helper to check if an error is a MongoDB duplicate key error.
|
||||
* Since serverName is the only unique index on MCPServer, any E11000 error
|
||||
* during creation is necessarily a serverName collision.
|
||||
*/
|
||||
function isDuplicateKeyError(error: unknown): boolean {
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const mongoError = error as { code: number };
|
||||
return mongoError.code === 11000;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes special regex characters in a string so they are treated literally.
|
||||
*/
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a URL-friendly server name from a title.
|
||||
* Converts to lowercase, replaces spaces with hyphens, removes special characters.
|
||||
*/
|
||||
function generateServerNameFromTitle(title: string): string {
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars except spaces and hyphens
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Remove consecutive hyphens
|
||||
.replace(/^-|-$/g, ''); // Trim leading/trailing hyphens
|
||||
|
||||
return slug || 'mcp-server'; // Fallback if empty
|
||||
}
|
||||
|
||||
export function createMCPServerMethods(mongoose: typeof import('mongoose')) {
|
||||
/**
|
||||
* Finds the next available server name by checking for duplicates.
|
||||
* If baseName exists, returns baseName-2, baseName-3, etc.
|
||||
*/
|
||||
async function findNextAvailableServerName(baseName: string): Promise<string> {
|
||||
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
|
||||
|
||||
// Find all servers with matching base name pattern (baseName or baseName-N)
|
||||
const escapedBaseName = escapeRegex(baseName);
|
||||
const existing = await MCPServer.find({
|
||||
serverName: { $regex: `^${escapedBaseName}(-\\d+)?$` },
|
||||
})
|
||||
.select('serverName')
|
||||
.lean();
|
||||
|
||||
if (existing.length === 0) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// Extract numbers from existing names
|
||||
const numbers = existing.map((s) => {
|
||||
const match = s.serverName.match(/-(\d+)$/);
|
||||
return match ? parseInt(match[1], 10) : 1;
|
||||
});
|
||||
|
||||
const maxNumber = Math.max(...numbers);
|
||||
return `${baseName}-${maxNumber + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new MCP server with retry logic for handling race conditions.
|
||||
* When multiple requests try to create servers with the same title simultaneously,
|
||||
* they may get the same serverName from findNextAvailableServerName() before any
|
||||
* creates the record (TOCTOU race condition). This is handled by retrying with
|
||||
* exponential backoff when a duplicate key error occurs.
|
||||
* @param data - Object containing config (with title, description, url, etc.) and author
|
||||
* @returns The created MCP server document
|
||||
*/
|
||||
async function createMCPServer(data: {
|
||||
config: MCPOptions;
|
||||
author: string | Types.ObjectId;
|
||||
}): Promise<MCPServerDocument> {
|
||||
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_CREATE_RETRIES; attempt++) {
|
||||
try {
|
||||
// Generate serverName from title, with fallback to nanoid if no title
|
||||
// Important: regenerate on each attempt to get fresh available name
|
||||
let serverName: string;
|
||||
if (data.config.title) {
|
||||
const baseSlug = generateServerNameFromTitle(data.config.title);
|
||||
serverName = await findNextAvailableServerName(baseSlug);
|
||||
} else {
|
||||
serverName = `mcp-${nanoid(16)}`;
|
||||
}
|
||||
|
||||
const newServer = await MCPServer.create({
|
||||
serverName,
|
||||
config: data.config,
|
||||
author: data.author,
|
||||
});
|
||||
|
||||
return newServer.toObject() as MCPServerDocument;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Only retry on duplicate key errors (serverName collision)
|
||||
if (isDuplicateKeyError(error) && attempt < MAX_CREATE_RETRIES - 1) {
|
||||
// Exponential backoff: 10ms, 20ms, 40ms
|
||||
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
|
||||
logger.debug(
|
||||
`[createMCPServer] Duplicate serverName detected, retrying (attempt ${attempt + 2}/${MAX_CREATE_RETRIES}) after ${delay}ms`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a duplicate key error or out of retries - throw immediately
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here, but TypeScript requires a return
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an MCP server by serverName
|
||||
* @param serverName - The MCP server ID
|
||||
* @returns The MCP server document or null
|
||||
*/
|
||||
async function findMCPServerById(serverName: string): Promise<MCPServerDocument | null> {
|
||||
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
|
||||
return await MCPServer.findOne({ serverName }).lean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an MCP server by MongoDB ObjectId
|
||||
* @param _id - The MongoDB ObjectId
|
||||
* @returns The MCP server document or null
|
||||
*/
|
||||
async function findMCPServerByObjectId(
|
||||
_id: string | Types.ObjectId,
|
||||
): Promise<MCPServerDocument | null> {
|
||||
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
|
||||
return await MCPServer.findById(_id).lean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find MCP servers by author
|
||||
* @param authorId - The author's ObjectId or string
|
||||
* @returns Array of MCP server documents
|
||||
*/
|
||||
async function findMCPServersByAuthor(
|
||||
authorId: string | Types.ObjectId,
|
||||
): Promise<MCPServerDocument[]> {
|
||||
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
|
||||
return await MCPServer.find({ author: authorId }).sort({ updatedAt: -1 }).lean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a paginated list of MCP servers by IDs with filtering and search
|
||||
* @param ids - Array of ObjectIds to include
|
||||
* @param otherParams - Additional filter parameters (e.g., search)
|
||||
* @param limit - Page size limit (null for no pagination)
|
||||
* @param after - Cursor for pagination
|
||||
* @returns Paginated list of MCP servers
|
||||
*/
|
||||
async function getListMCPServersByIds({
|
||||
ids = [],
|
||||
otherParams = {},
|
||||
limit = null,
|
||||
after = null,
|
||||
}: {
|
||||
ids?: Types.ObjectId[];
|
||||
otherParams?: RootFilterQuery<MCPServerDocument>;
|
||||
limit?: number | null;
|
||||
after?: string | null;
|
||||
}): Promise<{
|
||||
data: MCPServerDocument[];
|
||||
has_more: boolean;
|
||||
after: string | null;
|
||||
}> {
|
||||
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
|
||||
const isPaginated = limit !== null && limit !== undefined;
|
||||
const normalizedLimit = isPaginated
|
||||
? Math.min(Math.max(1, parseInt(String(limit)) || NORMALIZED_LIMIT_DEFAULT), 100)
|
||||
: null;
|
||||
|
||||
// Build base query combining accessible servers with other filters
|
||||
const baseQuery: RootFilterQuery<MCPServerDocument> = { ...otherParams, _id: { $in: ids } };
|
||||
|
||||
// Add cursor condition
|
||||
if (after) {
|
||||
try {
|
||||
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
|
||||
const { updatedAt, _id } = cursor;
|
||||
|
||||
const cursorCondition = {
|
||||
$or: [
|
||||
{ updatedAt: { $lt: new Date(updatedAt) } },
|
||||
{ updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } },
|
||||
],
|
||||
};
|
||||
|
||||
// Merge cursor condition with base query
|
||||
if (Object.keys(baseQuery).length > 0) {
|
||||
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
|
||||
// Remove the original conditions from baseQuery to avoid duplication
|
||||
Object.keys(baseQuery).forEach((key) => {
|
||||
if (key !== '$and') {
|
||||
delete baseQuery[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid cursor, ignore
|
||||
logger.warn('[getListMCPServersByIds] Invalid cursor provided', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedLimit === null) {
|
||||
// No pagination - return all matching servers
|
||||
const servers = await MCPServer.find(baseQuery).sort({ updatedAt: -1, _id: 1 }).lean();
|
||||
|
||||
return {
|
||||
data: servers,
|
||||
has_more: false,
|
||||
after: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Paginated query - assign to const to help TypeScript
|
||||
const servers = await MCPServer.find(baseQuery)
|
||||
.sort({ updatedAt: -1, _id: 1 })
|
||||
.limit(normalizedLimit + 1)
|
||||
.lean();
|
||||
|
||||
const hasMore = servers.length > normalizedLimit;
|
||||
const data = hasMore ? servers.slice(0, normalizedLimit) : servers;
|
||||
|
||||
let nextCursor = null;
|
||||
if (hasMore && data.length > 0) {
|
||||
const lastItem = data[data.length - 1];
|
||||
nextCursor = Buffer.from(
|
||||
JSON.stringify({
|
||||
updatedAt: lastItem.updatedAt,
|
||||
_id: lastItem._id,
|
||||
}),
|
||||
).toString('base64');
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
has_more: hasMore,
|
||||
after: nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an MCP server
|
||||
* @param serverName - The MCP server ID
|
||||
* @param updateData - Object containing config to update
|
||||
* @returns The updated MCP server document or null
|
||||
*/
|
||||
async function updateMCPServer(
|
||||
serverName: string,
|
||||
updateData: { config?: MCPOptions },
|
||||
): Promise<MCPServerDocument | null> {
|
||||
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
|
||||
return await MCPServer.findOneAndUpdate(
|
||||
{ serverName },
|
||||
{ $set: updateData },
|
||||
{ new: true, runValidators: true },
|
||||
).lean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an MCP server
|
||||
* @param serverName - The MCP server ID
|
||||
* @returns The deleted MCP server document or null
|
||||
*/
|
||||
async function deleteMCPServer(serverName: string): Promise<MCPServerDocument | null> {
|
||||
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
|
||||
return await MCPServer.findOneAndDelete({ serverName }).lean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP servers by their serverName strings
|
||||
* @param names - Array of serverName strings to fetch
|
||||
* @returns Object containing array of MCP server documents
|
||||
*/
|
||||
async function getListMCPServersByNames({ names = [] }: { names: string[] }): Promise<{
|
||||
data: MCPServerDocument[];
|
||||
}> {
|
||||
if (names.length === 0) {
|
||||
return { data: [] };
|
||||
}
|
||||
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
|
||||
const servers = await MCPServer.find({ serverName: { $in: names } }).lean();
|
||||
return { data: servers };
|
||||
}
|
||||
|
||||
return {
|
||||
createMCPServer,
|
||||
findMCPServerById,
|
||||
findMCPServerByObjectId,
|
||||
findMCPServersByAuthor,
|
||||
getListMCPServersByIds,
|
||||
getListMCPServersByNames,
|
||||
updateMCPServer,
|
||||
deleteMCPServer,
|
||||
};
|
||||
}
|
||||
|
||||
export type MCPServerMethods = ReturnType<typeof createMCPServerMethods>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue