mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 10:20:15 +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
|
|
@ -50,12 +50,12 @@ export class ConnectionsRepository {
|
|||
}
|
||||
if (existingConnection) {
|
||||
// Check if config was cached/updated since connection was created
|
||||
if (serverConfig.lastUpdatedAt && existingConnection.isStale(serverConfig.lastUpdatedAt)) {
|
||||
if (serverConfig.updatedAt && existingConnection.isStale(serverConfig.updatedAt)) {
|
||||
logger.info(
|
||||
`${this.prefix(serverName)} Existing connection for ${serverName} is outdated. Recreating a new connection.`,
|
||||
{
|
||||
connectionCreated: new Date(existingConnection.createdAt).toISOString(),
|
||||
configCachedAt: new Date(serverConfig.lastUpdatedAt).toISOString(),
|
||||
configCachedAt: new Date(serverConfig.updatedAt).toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export abstract class UserConnectionManager {
|
|||
}
|
||||
connection = undefined; // Force creation of a new connection
|
||||
} else if (connection) {
|
||||
if (!config || (config.lastUpdatedAt && connection.isStale(config.lastUpdatedAt))) {
|
||||
if (!config || (config.updatedAt && connection.isStale(config.updatedAt))) {
|
||||
if (config) {
|
||||
logger.info(
|
||||
`[MCP][User: ${userId}][${serverName}] Config was updated, disconnecting stale connection`,
|
||||
|
|
|
|||
|
|
@ -145,10 +145,10 @@ describe('ConnectionsRepository', () => {
|
|||
isStale: jest.fn().mockReturnValue(true),
|
||||
} as unknown as jest.Mocked<MCPConnection>;
|
||||
|
||||
// Update server config with lastUpdatedAt timestamp
|
||||
// Update server config with updatedAt timestamp
|
||||
const configWithCachedAt = {
|
||||
...mockServerConfigs.server1,
|
||||
lastUpdatedAt: configCachedAt,
|
||||
updatedAt: configCachedAt,
|
||||
};
|
||||
mockRegistry.getServerConfig.mockResolvedValueOnce(configWithCachedAt);
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ describe('ConnectionsRepository', () => {
|
|||
|
||||
const result = await repository.get('server1');
|
||||
|
||||
// Verify stale check was called with the config's lastUpdatedAt timestamp
|
||||
// Verify stale check was called with the config's updatedAt timestamp
|
||||
expect(staleConnection.isStale).toHaveBeenCalledWith(configCachedAt);
|
||||
|
||||
// Verify old connection was disconnected
|
||||
|
|
@ -190,10 +190,10 @@ describe('ConnectionsRepository', () => {
|
|||
isStale: jest.fn().mockReturnValue(false),
|
||||
} as unknown as jest.Mocked<MCPConnection>;
|
||||
|
||||
// Update server config with lastUpdatedAt timestamp
|
||||
// Update server config with updatedAt timestamp
|
||||
const configWithCachedAt = {
|
||||
...mockServerConfigs.server1,
|
||||
lastUpdatedAt: configCachedAt,
|
||||
updatedAt: configCachedAt,
|
||||
};
|
||||
mockRegistry.getServerConfig.mockResolvedValueOnce(configWithCachedAt);
|
||||
|
||||
|
|
|
|||
|
|
@ -59,12 +59,12 @@ export class MCPServersInitializer {
|
|||
/** Initializes a single server with all its metadata and adds it to appropriate collections */
|
||||
public static async initializeServer(serverName: string, rawConfig: t.MCPOptions): Promise<void> {
|
||||
try {
|
||||
const config = await MCPServersRegistry.getInstance().addServer(
|
||||
const result = await MCPServersRegistry.getInstance().addServer(
|
||||
serverName,
|
||||
rawConfig,
|
||||
'CACHE',
|
||||
);
|
||||
MCPServersInitializer.logParsedConfig(serverName, config);
|
||||
MCPServersInitializer.logParsedConfig(serverName, result.config);
|
||||
} catch (error) {
|
||||
logger.error(`${MCPServersInitializer.prefix(serverName)} Failed to initialize:`, error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,11 +76,10 @@ export class MCPServersRegistry {
|
|||
config: t.MCPOptions,
|
||||
storageLocation: 'CACHE' | 'DB',
|
||||
userId?: string,
|
||||
): Promise<t.ParsedServerConfig> {
|
||||
): Promise<t.AddServerResult> {
|
||||
const configRepo = this.getConfigRepository(storageLocation);
|
||||
const parsedConfig = await MCPServerInspector.inspect(serverName, config);
|
||||
await configRepo.add(serverName, parsedConfig, userId);
|
||||
return parsedConfig;
|
||||
return await configRepo.add(serverName, parsedConfig, userId);
|
||||
}
|
||||
|
||||
public async updateServer(
|
||||
|
|
@ -88,10 +87,11 @@ export class MCPServersRegistry {
|
|||
config: t.MCPOptions,
|
||||
storageLocation: 'CACHE' | 'DB',
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
): Promise<t.ParsedServerConfig> {
|
||||
const configRepo = this.getConfigRepository(storageLocation);
|
||||
const parsedConfig = await MCPServerInspector.inspect(serverName, config);
|
||||
await configRepo.update(serverName, parsedConfig, userId);
|
||||
return parsedConfig;
|
||||
}
|
||||
|
||||
// TODO: This is currently used to determine if a server requires OAuth. However, this info can
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { ParsedServerConfig } from '~/mcp/types';
|
||||
import { ParsedServerConfig, AddServerResult } from '~/mcp/types';
|
||||
|
||||
/**
|
||||
* Interface for future DB implementation
|
||||
*/
|
||||
export interface IServerConfigsRepositoryInterface {
|
||||
add(serverName: string, config: ParsedServerConfig, userId?: string): Promise<void>;
|
||||
add(serverName: string, config: ParsedServerConfig, userId?: string): Promise<AddServerResult>;
|
||||
|
||||
//ACL Entry check if update is possible
|
||||
update(serverName: string, config: ParsedServerConfig, userId?: string): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@ jest.mock('~/cluster', () => ({
|
|||
isLeader: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
// Mock ServerConfigsDB to avoid needing MongoDB for cache integration tests
|
||||
jest.mock('../db/ServerConfigsDB', () => ({
|
||||
ServerConfigsDB: jest.fn().mockImplementation(() => ({
|
||||
get: jest.fn().mockResolvedValue(undefined),
|
||||
getAll: jest.fn().mockResolvedValue({}),
|
||||
add: jest.fn().mockResolvedValue({ config: {}, isNew: true }),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('MCPServersInitializer Redis Integration Tests', () => {
|
||||
let MCPServersInitializer: typeof import('../MCPServersInitializer').MCPServersInitializer;
|
||||
let MCPServersRegistry: typeof import('../MCPServersRegistry').MCPServersRegistry;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import type * as t from '~/mcp/types';
|
||||
import type { MCPServersRegistry as MCPServersRegistryType } from '../MCPServersRegistry';
|
||||
|
||||
// Mock ServerConfigsDB to avoid needing MongoDB for cache integration tests
|
||||
jest.mock('../db/ServerConfigsDB', () => ({
|
||||
ServerConfigsDB: jest.fn().mockImplementation(() => ({
|
||||
get: jest.fn().mockResolvedValue(undefined),
|
||||
getAll: jest.fn().mockResolvedValue({}),
|
||||
add: jest.fn().mockResolvedValue({
|
||||
serverName: 'mock-server',
|
||||
config: {} as t.ParsedServerConfig,
|
||||
}),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Integration tests for MCPServersRegistry using Redis-backed cache.
|
||||
* For unit tests using in-memory cache, see MCPServersRegistry.test.ts
|
||||
|
|
@ -18,7 +32,6 @@ describe('MCPServersRegistry Redis Integration Tests', () => {
|
|||
let LeaderElection: typeof import('~/cluster/LeaderElection').LeaderElection;
|
||||
let leaderInstance: InstanceType<typeof import('~/cluster/LeaderElection').LeaderElection>;
|
||||
let MCPServerInspector: typeof import('../MCPServerInspector').MCPServerInspector;
|
||||
let mongoServer: MongoMemoryServer;
|
||||
|
||||
const testParsedConfig: t.ParsedServerConfig = {
|
||||
type: 'stdio',
|
||||
|
|
@ -60,21 +73,12 @@ describe('MCPServersRegistry Redis Integration Tests', () => {
|
|||
const leaderElectionModule = await import('~/cluster/LeaderElection');
|
||||
const inspectorModule = await import('../MCPServerInspector');
|
||||
const mongoose = await import('mongoose');
|
||||
const { userSchema } = await import('@librechat/data-schemas');
|
||||
|
||||
MCPServersRegistry = registryModule.MCPServersRegistry;
|
||||
keyvRedisClient = redisClients.keyvRedisClient;
|
||||
LeaderElection = leaderElectionModule.LeaderElection;
|
||||
MCPServerInspector = inspectorModule.MCPServerInspector;
|
||||
|
||||
// Set up MongoDB with MongoMemoryServer for db methods
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
if (!mongoose.default.models.User) {
|
||||
mongoose.default.model('User', userSchema);
|
||||
}
|
||||
await mongoose.default.connect(mongoUri);
|
||||
|
||||
// Reset singleton and create new instance with mongoose
|
||||
(MCPServersRegistry as unknown as { instance: undefined }).instance = undefined;
|
||||
MCPServersRegistry.createInstance(mongoose.default);
|
||||
|
|
@ -135,11 +139,6 @@ describe('MCPServersRegistry Redis Integration Tests', () => {
|
|||
|
||||
// Close Redis connection
|
||||
if (keyvRedisClient?.isOpen) await keyvRedisClient.disconnect();
|
||||
|
||||
// Close MongoDB connection and stop memory server
|
||||
const mongoose = await import('mongoose');
|
||||
await mongoose.default.disconnect();
|
||||
if (mongoServer) await mongoServer.stop();
|
||||
});
|
||||
|
||||
// Tests for the old privateServersCache API have been removed
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ describe('MCPServersRegistry', () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
lastUpdatedAt: FIXED_TIME,
|
||||
updatedAt: FIXED_TIME,
|
||||
};
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
|
|
|
|||
844
packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts
Normal file
844
packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts
Normal file
|
|
@ -0,0 +1,844 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import {
|
||||
AccessRoleIds,
|
||||
PermissionBits,
|
||||
PrincipalType,
|
||||
PrincipalModel,
|
||||
ResourceType,
|
||||
} from 'librechat-data-provider';
|
||||
import { createModels, createMethods, RoleBits } from '@librechat/data-schemas';
|
||||
import { ServerConfigsDB } from '../db/ServerConfigsDB';
|
||||
import type { ParsedServerConfig } from '~/mcp/types';
|
||||
|
||||
// Mock the logger
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
let mongoServer: MongoMemoryServer;
|
||||
let serverConfigsDB: ServerConfigsDB;
|
||||
|
||||
// Test data helpers
|
||||
const createSSEConfig = (
|
||||
title?: string,
|
||||
description?: string,
|
||||
oauth?: { client_secret?: string; client_id?: string },
|
||||
): ParsedServerConfig => ({
|
||||
type: 'sse',
|
||||
url: 'https://example.com/mcp',
|
||||
...(title && { title }),
|
||||
...(description && { description }),
|
||||
...(oauth && { oauth }),
|
||||
});
|
||||
|
||||
let dbMethods: ReturnType<typeof createMethods>;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
|
||||
// Initialize all models
|
||||
createModels(mongoose);
|
||||
|
||||
// Create methods and seed default roles
|
||||
dbMethods = createMethods(mongoose);
|
||||
await dbMethods.seedDefaultRoles();
|
||||
|
||||
serverConfigsDB = new ServerConfigsDB(mongoose);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear collections except AccessRole
|
||||
await mongoose.models.MCPServer.deleteMany({});
|
||||
await mongoose.models.Agent.deleteMany({});
|
||||
await mongoose.models.AclEntry.deleteMany({});
|
||||
});
|
||||
|
||||
describe('ServerConfigsDB', () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const userId2 = new mongoose.Types.ObjectId().toString();
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should throw error when mongoose is not provided', () => {
|
||||
expect(() => new ServerConfigsDB(null as unknown as typeof mongoose)).toThrow(
|
||||
'ServerConfigsDB requires mongoose instance',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create instance when mongoose is provided', () => {
|
||||
const instance = new ServerConfigsDB(mongoose);
|
||||
expect(instance).toBeInstanceOf(ServerConfigsDB);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add()', () => {
|
||||
it('should throw error when userId is not provided', async () => {
|
||||
await expect(serverConfigsDB.add('test-server', createSSEConfig('Test'))).rejects.toThrow(
|
||||
'User ID is required to create a database-stored MCP server',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create server and return AddServerResult with generated serverName', async () => {
|
||||
const config = createSSEConfig('My Test Server', 'A test server');
|
||||
const result = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.serverName).toBe('my-test-server');
|
||||
expect(result.config).toMatchObject({
|
||||
type: 'sse',
|
||||
url: 'https://example.com/mcp',
|
||||
title: 'My Test Server',
|
||||
description: 'A test server',
|
||||
});
|
||||
expect(result.config.dbId).toBeDefined();
|
||||
});
|
||||
|
||||
it('should grant owner ACL to the user', async () => {
|
||||
const config = createSSEConfig('ACL Test Server');
|
||||
const result = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
// Verify ACL entry was created
|
||||
const aclEntry = await mongoose.models.AclEntry.findOne({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId),
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
});
|
||||
|
||||
expect(aclEntry).toBeDefined();
|
||||
expect(aclEntry!.resourceId.toString()).toBe(result.config.dbId);
|
||||
// OWNER role has VIEW | EDIT | DELETE | SHARE = 15
|
||||
expect(aclEntry!.permBits).toBe(RoleBits.OWNER);
|
||||
});
|
||||
|
||||
it('should include dbId and updatedAt in returned config', async () => {
|
||||
const config = createSSEConfig('Metadata Test');
|
||||
const result = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
expect(result.config.dbId).toBeDefined();
|
||||
expect(typeof result.config.dbId).toBe('string');
|
||||
expect(result.config.updatedAt).toBeDefined();
|
||||
expect(typeof result.config.updatedAt).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update()', () => {
|
||||
it('should throw error when userId is not provided', async () => {
|
||||
await expect(serverConfigsDB.update('test-server', createSSEConfig('Test'))).rejects.toThrow(
|
||||
'User ID is required to update a database-stored MCP server',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update server config', async () => {
|
||||
const config = createSSEConfig('Original Title', 'Original description');
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
const updatedConfig = createSSEConfig('Original Title', 'Updated description');
|
||||
await serverConfigsDB.update(created.serverName, updatedConfig, userId);
|
||||
|
||||
const retrieved = await serverConfigsDB.get(created.serverName, userId);
|
||||
expect(retrieved?.description).toBe('Updated description');
|
||||
});
|
||||
|
||||
it('should preserve oauth.client_secret when not provided in update', async () => {
|
||||
const config = createSSEConfig('OAuth Server', 'Test', {
|
||||
client_id: 'my-client-id',
|
||||
client_secret: 'super-secret-key',
|
||||
});
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
// Update without client_secret
|
||||
const updatedConfig = createSSEConfig('OAuth Server', 'Updated description', {
|
||||
client_id: 'my-client-id',
|
||||
// client_secret not provided
|
||||
});
|
||||
await serverConfigsDB.update(created.serverName, updatedConfig, userId);
|
||||
|
||||
// Verify the secret is preserved
|
||||
const MCPServer = mongoose.models.MCPServer;
|
||||
const server = await MCPServer.findOne({ serverName: created.serverName });
|
||||
expect(server?.config?.oauth?.client_secret).toBe('super-secret-key');
|
||||
});
|
||||
|
||||
it('should allow updating oauth.client_secret when explicitly provided', async () => {
|
||||
const config = createSSEConfig('OAuth Server 2', 'Test', {
|
||||
client_id: 'my-client-id',
|
||||
client_secret: 'old-secret',
|
||||
});
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
// Update with new client_secret
|
||||
const updatedConfig = createSSEConfig('OAuth Server 2', 'Updated', {
|
||||
client_id: 'my-client-id',
|
||||
client_secret: 'new-secret',
|
||||
});
|
||||
await serverConfigsDB.update(created.serverName, updatedConfig, userId);
|
||||
|
||||
// Verify the secret is updated
|
||||
const MCPServer = mongoose.models.MCPServer;
|
||||
const server = await MCPServer.findOne({ serverName: created.serverName });
|
||||
expect(server?.config?.oauth?.client_secret).toBe('new-secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove()', () => {
|
||||
it('should delete server from database', async () => {
|
||||
const config = createSSEConfig('Delete Test');
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
await serverConfigsDB.remove(created.serverName, userId);
|
||||
|
||||
const MCPServer = mongoose.models.MCPServer;
|
||||
const server = await MCPServer.findOne({ serverName: created.serverName });
|
||||
expect(server).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove all ACL entries for the server', async () => {
|
||||
const config = createSSEConfig('ACL Delete Test');
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
// Verify ACL exists before deletion
|
||||
let aclEntries = await mongoose.models.AclEntry.find({
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: new mongoose.Types.ObjectId(created.config.dbId!),
|
||||
});
|
||||
expect(aclEntries.length).toBeGreaterThan(0);
|
||||
|
||||
await serverConfigsDB.remove(created.serverName, userId);
|
||||
|
||||
// Verify ACL entries are removed
|
||||
aclEntries = await mongoose.models.AclEntry.find({
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: new mongoose.Types.ObjectId(created.config.dbId!),
|
||||
});
|
||||
expect(aclEntries.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle non-existent server gracefully', async () => {
|
||||
// Should not throw
|
||||
await expect(serverConfigsDB.remove('non-existent-server', userId)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get()', () => {
|
||||
describe('public access (no userId)', () => {
|
||||
it('should return undefined for non-public server without userId', async () => {
|
||||
const config = createSSEConfig('Private Server');
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
const result = await serverConfigsDB.get(created.serverName);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return server when publicly shared', async () => {
|
||||
const config = createSSEConfig('Public Server');
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
// Grant public access
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.PUBLIC,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: new mongoose.Types.ObjectId(created.config.dbId!),
|
||||
permBits: PermissionBits.VIEW,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
const result = await serverConfigsDB.get(created.serverName);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.title).toBe('Public Server');
|
||||
});
|
||||
|
||||
it('should return server with consumeOnly when accessible via public agent', async () => {
|
||||
const config = createSSEConfig('Agent MCP Server');
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
// Create an agent that has this MCP server
|
||||
const Agent = mongoose.models.Agent;
|
||||
const agent = await Agent.create({
|
||||
id: 'test-agent-id',
|
||||
name: 'Test Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [created.serverName],
|
||||
});
|
||||
|
||||
// Grant public access to the agent
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.PUBLIC,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
const result = await serverConfigsDB.get(created.serverName);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.consumeOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user direct access', () => {
|
||||
it('should return server when user has direct VIEW permission', async () => {
|
||||
const config = createSSEConfig('Direct Access Server');
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
// The owner should have access
|
||||
const result = await serverConfigsDB.get(created.serverName, userId);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.title).toBe('Direct Access Server');
|
||||
expect(result?.consumeOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when user has no permission', async () => {
|
||||
const config = createSSEConfig('Restricted Server');
|
||||
await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
// Different user without access
|
||||
const result = await serverConfigsDB.get('restricted-server', userId2);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return server when user is granted VIEW permission', async () => {
|
||||
const config = createSSEConfig('Shared Server');
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
// Grant VIEW permission to userId2
|
||||
const role = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
|
||||
});
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: new mongoose.Types.ObjectId(created.config.dbId!),
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: role!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
const result = await serverConfigsDB.get(created.serverName, userId2);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.title).toBe('Shared Server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent-based access (consumeOnly)', () => {
|
||||
it('should return server with consumeOnly when user has access via agent', async () => {
|
||||
const config = createSSEConfig('Agent Accessible Server');
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
// Create an agent with this MCP server
|
||||
const Agent = mongoose.models.Agent;
|
||||
const agent = await Agent.create({
|
||||
id: 'agent-for-user2',
|
||||
name: 'Agent for User 2',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [created.serverName],
|
||||
});
|
||||
|
||||
// Grant agent access to userId2
|
||||
const agentRole = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
});
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: agentRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
const result = await serverConfigsDB.get(created.serverName, userId2);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.consumeOnly).toBe(true);
|
||||
expect(result?.title).toBe('Agent Accessible Server');
|
||||
});
|
||||
|
||||
it('should prefer direct access over agent access (no consumeOnly)', async () => {
|
||||
const config = createSSEConfig('Both Access Server');
|
||||
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
// Create an agent with this MCP server
|
||||
const Agent = mongoose.models.Agent;
|
||||
const agent = await Agent.create({
|
||||
id: 'agent-both-access',
|
||||
name: 'Agent Both Access',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [created.serverName],
|
||||
});
|
||||
|
||||
// Grant userId2 both direct MCP access and agent access
|
||||
const mcpRole = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
|
||||
});
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: new mongoose.Types.ObjectId(created.config.dbId!),
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: mcpRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
const agentRole = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
});
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: agentRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
// Direct access should take precedence (no consumeOnly)
|
||||
const result = await serverConfigsDB.get(created.serverName, userId2);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.consumeOnly).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent server', async () => {
|
||||
const result = await serverConfigsDB.get('non-existent-server', userId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll()', () => {
|
||||
describe('public access (no userId)', () => {
|
||||
it('should return empty object when no public servers exist', async () => {
|
||||
const config = createSSEConfig('Private Server');
|
||||
await serverConfigsDB.add('temp-name', config, userId);
|
||||
|
||||
const result = await serverConfigsDB.getAll();
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return only publicly shared servers', async () => {
|
||||
const config1 = createSSEConfig('Public Server 1');
|
||||
const config2 = createSSEConfig('Private Server');
|
||||
const created1 = await serverConfigsDB.add('temp1', config1, userId);
|
||||
await serverConfigsDB.add('temp2', config2, userId);
|
||||
|
||||
// Make first server public
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.PUBLIC,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: new mongoose.Types.ObjectId(created1.config.dbId!),
|
||||
permBits: PermissionBits.VIEW,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
const result = await serverConfigsDB.getAll();
|
||||
expect(Object.keys(result)).toHaveLength(1);
|
||||
expect(result['public-server-1']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('user access', () => {
|
||||
it('should return servers directly accessible by user', async () => {
|
||||
const config1 = createSSEConfig('User Server 1');
|
||||
const config2 = createSSEConfig('User Server 2');
|
||||
await serverConfigsDB.add('temp1', config1, userId);
|
||||
await serverConfigsDB.add('temp2', config2, userId);
|
||||
|
||||
// Create server by different user (not accessible)
|
||||
await serverConfigsDB.add('temp3', createSSEConfig('Other User Server'), userId2);
|
||||
|
||||
const result = await serverConfigsDB.getAll(userId);
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result['user-server-1']).toBeDefined();
|
||||
expect(result['user-server-2']).toBeDefined();
|
||||
expect(result['other-user-server']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include agent-accessible servers with consumeOnly flag', async () => {
|
||||
const config1 = createSSEConfig('Direct Server');
|
||||
const config2 = createSSEConfig('Agent Only Server');
|
||||
await serverConfigsDB.add('temp1', config1, userId);
|
||||
const created2 = await serverConfigsDB.add('temp2', config2, userId);
|
||||
|
||||
// Create an agent with second MCP server, accessible by userId2
|
||||
const Agent = mongoose.models.Agent;
|
||||
const agent = await Agent.create({
|
||||
id: 'getall-agent',
|
||||
name: 'GetAll Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [created2.serverName],
|
||||
});
|
||||
|
||||
const agentRole = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
});
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: agentRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
const result = await serverConfigsDB.getAll(userId2);
|
||||
expect(Object.keys(result)).toHaveLength(1);
|
||||
expect(result['agent-only-server']).toBeDefined();
|
||||
expect(result['agent-only-server'].consumeOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should deduplicate servers with both direct and agent access', async () => {
|
||||
const config = createSSEConfig('Dedup Server');
|
||||
const created = await serverConfigsDB.add('temp', config, userId);
|
||||
|
||||
// Create an agent with this MCP server
|
||||
const Agent = mongoose.models.Agent;
|
||||
const agent = await Agent.create({
|
||||
id: 'dedup-agent',
|
||||
name: 'Dedup Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [created.serverName],
|
||||
});
|
||||
|
||||
// Grant userId2 both direct MCP access and agent access
|
||||
const mcpRole = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
|
||||
});
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: new mongoose.Types.ObjectId(created.config.dbId!),
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: mcpRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
const agentRole = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
});
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: agentRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
const result = await serverConfigsDB.getAll(userId2);
|
||||
// Should only have one entry (deduplicated)
|
||||
expect(Object.keys(result)).toHaveLength(1);
|
||||
// Direct access takes precedence - no consumeOnly
|
||||
expect(result['dedup-server']).toBeDefined();
|
||||
expect(result['dedup-server'].consumeOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should merge servers from multiple agents', async () => {
|
||||
const config1 = createSSEConfig('Agent1 Server');
|
||||
const config2 = createSSEConfig('Agent2 Server');
|
||||
const created1 = await serverConfigsDB.add('temp1', config1, userId);
|
||||
const created2 = await serverConfigsDB.add('temp2', config2, userId);
|
||||
|
||||
// Create two agents, each with a different MCP server
|
||||
const Agent = mongoose.models.Agent;
|
||||
const agent1 = await Agent.create({
|
||||
id: 'multi-agent-1',
|
||||
name: 'Multi Agent 1',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [created1.serverName],
|
||||
});
|
||||
|
||||
const agent2 = await Agent.create({
|
||||
id: 'multi-agent-2',
|
||||
name: 'Multi Agent 2',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [created2.serverName],
|
||||
});
|
||||
|
||||
// Grant userId2 access to both agents
|
||||
const agentRole = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
});
|
||||
|
||||
await mongoose.models.AclEntry.create([
|
||||
{
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent1._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: agentRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
},
|
||||
{
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent2._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: agentRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await serverConfigsDB.getAll(userId2);
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result['agent1-server']?.consumeOnly).toBe(true);
|
||||
expect(result['agent2-server']?.consumeOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAccessViaAgent() - private method integration', () => {
|
||||
it('should return false when no agents exist', async () => {
|
||||
const config = createSSEConfig('No Agent Server');
|
||||
const created = await serverConfigsDB.add('temp', config, userId);
|
||||
|
||||
// Access via get() which uses hasAccessViaAgent internally
|
||||
const result = await serverConfigsDB.get(created.serverName, userId2);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false when agent has MCP but user has no agent access', async () => {
|
||||
const config = createSSEConfig('Inaccessible Agent Server');
|
||||
const created = await serverConfigsDB.add('temp', config, userId);
|
||||
|
||||
// Create an agent with this MCP server but no ACL for userId2
|
||||
const Agent = mongoose.models.Agent;
|
||||
await Agent.create({
|
||||
id: 'inaccessible-agent',
|
||||
name: 'Inaccessible Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [created.serverName],
|
||||
});
|
||||
|
||||
const result = await serverConfigsDB.get(created.serverName, userId2);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return true when user has VIEW access to agent with the MCP server', async () => {
|
||||
const config = createSSEConfig('Accessible Agent Server');
|
||||
const created = await serverConfigsDB.add('temp', config, userId);
|
||||
|
||||
const Agent = mongoose.models.Agent;
|
||||
const agent = await Agent.create({
|
||||
id: 'accessible-agent',
|
||||
name: 'Accessible Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [created.serverName],
|
||||
});
|
||||
|
||||
const agentRole = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
});
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: agentRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
const result = await serverConfigsDB.get(created.serverName, userId2);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.consumeOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple agents - one accessible, one not', async () => {
|
||||
const config = createSSEConfig('Multi Agent Access Server');
|
||||
const created = await serverConfigsDB.add('temp', config, userId);
|
||||
|
||||
const Agent = mongoose.models.Agent;
|
||||
|
||||
// Agent 1: has MCP server but no user access
|
||||
await Agent.create({
|
||||
id: 'no-access-agent',
|
||||
name: 'No Access Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [created.serverName],
|
||||
});
|
||||
|
||||
// Agent 2: has MCP server AND user has access
|
||||
const accessibleAgent = await Agent.create({
|
||||
id: 'has-access-agent',
|
||||
name: 'Has Access Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [created.serverName],
|
||||
});
|
||||
|
||||
const agentRole = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
});
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: accessibleAgent._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: agentRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
const result = await serverConfigsDB.get(created.serverName, userId2);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.consumeOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset()', () => {
|
||||
it('should be a no-op and not throw', async () => {
|
||||
// Create a server first
|
||||
const config = createSSEConfig('Reset Test');
|
||||
await serverConfigsDB.add('temp', config, userId);
|
||||
|
||||
// Reset should complete without error
|
||||
await expect(serverConfigsDB.reset()).resolves.toBeUndefined();
|
||||
|
||||
// Server should still exist (reset is no-op for DB storage)
|
||||
const result = await serverConfigsDB.get('reset-test', userId);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapDBServerToParsedConfig()', () => {
|
||||
it('should include dbId from _id', async () => {
|
||||
const config = createSSEConfig('Map Test');
|
||||
const created = await serverConfigsDB.add('temp', config, userId);
|
||||
|
||||
expect(created.config.dbId).toBeDefined();
|
||||
expect(typeof created.config.dbId).toBe('string');
|
||||
expect(mongoose.Types.ObjectId.isValid(created.config.dbId!)).toBe(true);
|
||||
});
|
||||
|
||||
it('should include updatedAt as timestamp', async () => {
|
||||
const config = createSSEConfig('Timestamp Test');
|
||||
const created = await serverConfigsDB.add('temp', config, userId);
|
||||
|
||||
expect(created.config.updatedAt).toBeDefined();
|
||||
expect(typeof created.config.updatedAt).toBe('number');
|
||||
expect(created.config.updatedAt).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle server with empty mcpServerNames in agent', async () => {
|
||||
const config = createSSEConfig('Edge Case Server');
|
||||
const created = await serverConfigsDB.add('temp', config, userId);
|
||||
|
||||
// Create an agent with empty mcpServerNames
|
||||
const Agent = mongoose.models.Agent;
|
||||
const agent = await Agent.create({
|
||||
id: 'empty-mcp-agent',
|
||||
name: 'Empty MCP Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
mcpServerNames: [], // Empty array
|
||||
});
|
||||
|
||||
const agentRole = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
});
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: agentRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
// Should not find the server via agent (empty mcpServerNames)
|
||||
const result = await serverConfigsDB.get(created.serverName, userId2);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle agent without mcpServerNames field', async () => {
|
||||
const config = createSSEConfig('No Field Server');
|
||||
const created = await serverConfigsDB.add('temp', config, userId);
|
||||
|
||||
// Create an agent without mcpServerNames field (uses default)
|
||||
const Agent = mongoose.models.Agent;
|
||||
const agent = await Agent.create({
|
||||
id: 'no-field-agent',
|
||||
name: 'No Field Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(userId),
|
||||
// mcpServerNames not specified - should default to []
|
||||
});
|
||||
|
||||
const agentRole = await mongoose.models.AccessRole.findOne({
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
});
|
||||
await mongoose.models.AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalModel: PrincipalModel.USER,
|
||||
principalId: new mongoose.Types.ObjectId(userId2),
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
permBits: PermissionBits.VIEW,
|
||||
roleId: agentRole!._id,
|
||||
grantedBy: new mongoose.Types.ObjectId(userId),
|
||||
});
|
||||
|
||||
// Should not find the server via agent
|
||||
const result = await serverConfigsDB.get(created.serverName, userId2);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ParsedServerConfig } from '~/mcp/types';
|
||||
import { ParsedServerConfig, AddServerResult } from '~/mcp/types';
|
||||
|
||||
/**
|
||||
* In-memory implementation of MCP server configurations cache for single-instance deployments.
|
||||
|
|
@ -10,12 +10,14 @@ import { ParsedServerConfig } from '~/mcp/types';
|
|||
export class ServerConfigsCacheInMemory {
|
||||
private readonly cache: Map<string, ParsedServerConfig> = new Map();
|
||||
|
||||
public async add(serverName: string, config: ParsedServerConfig): Promise<void> {
|
||||
public async add(serverName: string, config: ParsedServerConfig): Promise<AddServerResult> {
|
||||
if (this.cache.has(serverName))
|
||||
throw new Error(
|
||||
`Server "${serverName}" already exists in cache. Use update() to modify existing configs.`,
|
||||
);
|
||||
this.cache.set(serverName, { ...config, lastUpdatedAt: Date.now() });
|
||||
const storedConfig = { ...config, updatedAt: Date.now() };
|
||||
this.cache.set(serverName, storedConfig);
|
||||
return { serverName, config: storedConfig };
|
||||
}
|
||||
|
||||
public async update(serverName: string, config: ParsedServerConfig): Promise<void> {
|
||||
|
|
@ -23,7 +25,7 @@ export class ServerConfigsCacheInMemory {
|
|||
throw new Error(
|
||||
`Server "${serverName}" does not exist in cache. Use add() to create new configs.`,
|
||||
);
|
||||
this.cache.set(serverName, { ...config, lastUpdatedAt: Date.now() });
|
||||
this.cache.set(serverName, { ...config, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
public async remove(serverName: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type Keyv from 'keyv';
|
||||
import { fromPairs } from 'lodash';
|
||||
import { standardCache, keyvRedisClient } from '~/cache';
|
||||
import { ParsedServerConfig } from '~/mcp/types';
|
||||
import { ParsedServerConfig, AddServerResult } from '~/mcp/types';
|
||||
import { BaseRegistryCache } from './BaseRegistryCache';
|
||||
import { IServerConfigsRepositoryInterface } from '../ServerConfigsRepositoryInterface';
|
||||
|
||||
|
|
@ -25,15 +25,17 @@ export class ServerConfigsCacheRedis
|
|||
this.cache = standardCache(`${this.PREFIX}::Servers::${namespace}`);
|
||||
}
|
||||
|
||||
public async add(serverName: string, config: ParsedServerConfig): Promise<void> {
|
||||
public async add(serverName: string, config: ParsedServerConfig): Promise<AddServerResult> {
|
||||
if (this.leaderOnly) await this.leaderCheck(`add ${this.namespace} MCP servers`);
|
||||
const exists = await this.cache.has(serverName);
|
||||
if (exists)
|
||||
throw new Error(
|
||||
`Server "${serverName}" already exists in cache. Use update() to modify existing configs.`,
|
||||
);
|
||||
const success = await this.cache.set(serverName, { ...config, lastUpdatedAt: Date.now() });
|
||||
const storedConfig = { ...config, updatedAt: Date.now() };
|
||||
const success = await this.cache.set(serverName, storedConfig);
|
||||
this.successCheck(`add ${this.namespace} server "${serverName}"`, success);
|
||||
return { serverName, config: storedConfig };
|
||||
}
|
||||
|
||||
public async update(serverName: string, config: ParsedServerConfig): Promise<void> {
|
||||
|
|
@ -43,7 +45,7 @@ export class ServerConfigsCacheRedis
|
|||
throw new Error(
|
||||
`Server "${serverName}" does not exist in cache. Use add() to create new configs.`,
|
||||
);
|
||||
const success = await this.cache.set(serverName, { ...config, lastUpdatedAt: Date.now() });
|
||||
const success = await this.cache.set(serverName, { ...config, updatedAt: Date.now() });
|
||||
this.successCheck(`update ${this.namespace} server "${serverName}"`, success);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ describe('ServerConfigsCacheInMemory Integration Tests', () => {
|
|||
command: 'node',
|
||||
args: ['server1.js'],
|
||||
env: { TEST: 'value1' },
|
||||
lastUpdatedAt: FIXED_TIME,
|
||||
updatedAt: FIXED_TIME,
|
||||
};
|
||||
|
||||
const mockConfig2: ParsedServerConfig = {
|
||||
command: 'python',
|
||||
args: ['server2.py'],
|
||||
env: { TEST: 'value2' },
|
||||
lastUpdatedAt: FIXED_TIME,
|
||||
updatedAt: FIXED_TIME,
|
||||
};
|
||||
|
||||
const mockConfig3: ParsedServerConfig = {
|
||||
|
|
@ -30,7 +30,7 @@ describe('ServerConfigsCacheInMemory Integration Tests', () => {
|
|||
args: ['server3.js'],
|
||||
url: 'http://localhost:3000',
|
||||
requiresOAuth: true,
|
||||
lastUpdatedAt: FIXED_TIME,
|
||||
updatedAt: FIXED_TIME,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { AllMethods, createMethods, logger } from '@librechat/data-schemas';
|
||||
import { Types } from 'mongoose';
|
||||
import {
|
||||
AccessRoleIds,
|
||||
PermissionBits,
|
||||
PrincipalType,
|
||||
ResourceType,
|
||||
} from 'librechat-data-provider';
|
||||
import { AllMethods, MCPServerDocument, createMethods, logger } from '@librechat/data-schemas';
|
||||
import type { IServerConfigsRepositoryInterface } from '~/mcp/registry/ServerConfigsRepositoryInterface';
|
||||
import type { ParsedServerConfig } from '~/mcp/types';
|
||||
import { AccessControlService } from '~/acl/accessControlService';
|
||||
import type { ParsedServerConfig, AddServerResult } from '~/mcp/types';
|
||||
|
||||
/**
|
||||
* DB backed config storage
|
||||
|
|
@ -10,35 +17,211 @@ import type { ParsedServerConfig } from '~/mcp/types';
|
|||
*/
|
||||
export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
|
||||
private _dbMethods: AllMethods;
|
||||
private _aclService: AccessControlService;
|
||||
private _mongoose: typeof import('mongoose');
|
||||
|
||||
constructor(mongoose: typeof import('mongoose')) {
|
||||
if (!mongoose) {
|
||||
throw new Error('ServerConfigsDB requires mongoose instance');
|
||||
}
|
||||
this._mongoose = mongoose;
|
||||
this._dbMethods = createMethods(mongoose);
|
||||
this._aclService = new AccessControlService(mongoose);
|
||||
}
|
||||
|
||||
public async add(serverName: string, config: ParsedServerConfig, userId?: string): Promise<void> {
|
||||
logger.debug('ServerConfigsDB add not yet implemented');
|
||||
return;
|
||||
/**
|
||||
* Checks if user has access to an MCP server via an agent they can VIEW.
|
||||
* @param serverName - The MCP server name to check
|
||||
* @param userId - The user ID (optional - if not provided, checks publicly accessible agents)
|
||||
* @returns true if user has VIEW access to at least one agent that has this MCP server
|
||||
*/
|
||||
private async hasAccessViaAgent(serverName: string, userId?: string): Promise<boolean> {
|
||||
let accessibleAgentIds: Types.ObjectId[];
|
||||
|
||||
if (!userId) {
|
||||
// Get publicly accessible agents
|
||||
accessibleAgentIds = await this._aclService.findPubliclyAccessibleResources({
|
||||
resourceType: ResourceType.AGENT,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
} else {
|
||||
// Get user-accessible agents
|
||||
accessibleAgentIds = await this._aclService.findAccessibleResources({
|
||||
userId,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
resourceType: ResourceType.AGENT,
|
||||
});
|
||||
}
|
||||
|
||||
if (accessibleAgentIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if any accessible agent has this MCP server
|
||||
const Agent = this._mongoose.model('Agent');
|
||||
const exists = await Agent.exists({
|
||||
_id: { $in: accessibleAgentIds },
|
||||
mcpServerNames: serverName,
|
||||
});
|
||||
|
||||
return exists !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new MCP server and grants owner permissions to the user.
|
||||
* @param serverName - Temporary server name (not persisted) will be replaced by the nano id generated by the db method
|
||||
* @param config - Server configuration to store
|
||||
* @param userId - ID of the user creating the server (required)
|
||||
* @returns The created server result with serverName and config (including dbId)
|
||||
* @throws Error if userId is not provided
|
||||
*/
|
||||
public async add(
|
||||
serverName: string,
|
||||
config: ParsedServerConfig,
|
||||
userId?: string,
|
||||
): Promise<AddServerResult> {
|
||||
logger.debug(
|
||||
`[ServerConfigsDB.add] Starting Creating server with temp servername: ${serverName} for the user with the ID ${userId}`,
|
||||
);
|
||||
if (!userId) {
|
||||
throw new Error(
|
||||
'[ServerConfigsDB.add] User ID is required to create a database-stored MCP server.',
|
||||
);
|
||||
}
|
||||
const createdServer = await this._dbMethods.createMCPServer({ config: config, author: userId });
|
||||
await this._aclService.grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: createdServer._id,
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||||
grantedBy: userId,
|
||||
});
|
||||
return {
|
||||
serverName: createdServer.serverName,
|
||||
config: this.mapDBServerToParsedConfig(createdServer),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param serverName mcp server unique identifier "serverName"
|
||||
* @param config new Configuration to update
|
||||
* @param userId user id required to update DB server config
|
||||
*/
|
||||
public async update(
|
||||
serverName: string,
|
||||
config: ParsedServerConfig,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
logger.debug('ServerConfigsDB update not yet implemented');
|
||||
return;
|
||||
if (!userId) {
|
||||
throw new Error(
|
||||
'[ServerConfigsDB.update] User ID is required to update a database-stored MCP server.',
|
||||
);
|
||||
}
|
||||
|
||||
// Preserve sensitive fields (like oauth.client_secret) that may not be sent from the client
|
||||
// Create a copy to avoid mutating the input parameter
|
||||
let mergedConfig = config;
|
||||
const existingServer = await this._dbMethods.findMCPServerById(serverName);
|
||||
if (existingServer?.config?.oauth?.client_secret && !config.oauth?.client_secret) {
|
||||
mergedConfig = {
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
client_secret: existingServer.config.oauth.client_secret,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// specific user permissions for action permission will be handled in the controller calling the update method of the registry
|
||||
await this._dbMethods.updateMCPServer(serverName, { config: mergedConfig });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an MCP server and removes all associated ACL entries.
|
||||
* @param serverName - The serverName of the server to remove
|
||||
* @param userId - User performing the deletion (for logging)
|
||||
*/
|
||||
public async remove(serverName: string, userId?: string): Promise<void> {
|
||||
logger.debug('ServerConfigsDB remove not yet implemented');
|
||||
return;
|
||||
logger.debug(`[ServerConfigsDB.remove] removing ${serverName}. UserId: ${userId}`);
|
||||
const deletedServer = await this._dbMethods.deleteMCPServer(serverName);
|
||||
if (deletedServer && deletedServer._id) {
|
||||
logger.debug(`[ServerConfigsDB.remove] removing all permissions entries of ${serverName}.`);
|
||||
await this._aclService.removeAllPermissions({
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: deletedServer._id!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
logger.warn(`[ServerConfigsDB.remove] server with serverName ${serverName} does not exist`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single MCP server configuration by its serverName.
|
||||
* @param serverName - The serverName of the server to retrieve
|
||||
* @param userId - the user id provide the scope of the request. If the user Id is not provided, only publicly visible servers are returned.
|
||||
* @returns The parsed server config or undefined if not found. If accessed via agent, consumeOnly will be true.
|
||||
*/
|
||||
public async get(serverName: string, userId?: string): Promise<ParsedServerConfig | undefined> {
|
||||
logger.debug('ServerConfigsDB get not yet implemented');
|
||||
return;
|
||||
const server = await this._dbMethods.findMCPServerById(serverName);
|
||||
if (!server) return undefined;
|
||||
|
||||
// Check public access if no userId
|
||||
if (!userId) {
|
||||
const directlyAccessibleMCPIds = (
|
||||
await this._aclService.findPubliclyAccessibleResources({
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
})
|
||||
).map((id) => id.toString());
|
||||
if (directlyAccessibleMCPIds.indexOf(server._id.toString()) > -1) {
|
||||
return this.mapDBServerToParsedConfig(server);
|
||||
}
|
||||
|
||||
// Check access via publicly accessible agents
|
||||
const hasAgentAccess = await this.hasAccessViaAgent(serverName);
|
||||
if (hasAgentAccess) {
|
||||
logger.debug(
|
||||
`[ServerConfigsDB.get] accessing ${serverName} via public agent (consumeOnly)`,
|
||||
);
|
||||
return {
|
||||
...this.mapDBServerToParsedConfig(server),
|
||||
consumeOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check direct user access
|
||||
const userHasDirectAccess = await this._aclService.checkPermission({
|
||||
userId,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
resourceId: server._id,
|
||||
});
|
||||
|
||||
if (userHasDirectAccess) {
|
||||
logger.debug(
|
||||
`[ServerConfigsDB.get] getting ${serverName} for user with the UserId: ${userId}`,
|
||||
);
|
||||
return this.mapDBServerToParsedConfig(server);
|
||||
}
|
||||
|
||||
// Check agent access (user can VIEW an agent that has this MCP server)
|
||||
const hasAgentAccess = await this.hasAccessViaAgent(serverName, userId);
|
||||
if (hasAgentAccess) {
|
||||
logger.debug(
|
||||
`[ServerConfigsDB.get] user ${userId} accessing ${serverName} via agent (consumeOnly)`,
|
||||
);
|
||||
return {
|
||||
...this.mapDBServerToParsedConfig(server),
|
||||
consumeOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,13 +230,109 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
|
|||
* @returns record of parsed configs
|
||||
*/
|
||||
public async getAll(userId?: string): Promise<Record<string, ParsedServerConfig>> {
|
||||
// TODO: Implement DB-backed config retrieval
|
||||
logger.debug('[ServerConfigsDB] getAll not yet implemented', { userId });
|
||||
return {};
|
||||
// 1. Get directly accessible MCP IDs
|
||||
let directlyAccessibleMCPIds: Types.ObjectId[] = [];
|
||||
if (!userId) {
|
||||
logger.debug(`[ServerConfigsDB.getAll] fetching all publicly shared mcp servers`);
|
||||
directlyAccessibleMCPIds = await this._aclService.findPubliclyAccessibleResources({
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
} else {
|
||||
logger.debug(
|
||||
`[ServerConfigsDB.getAll] fetching mcp servers directly shared with the user with ID: ${userId}`,
|
||||
);
|
||||
directlyAccessibleMCPIds = await this._aclService.findAccessibleResources({
|
||||
userId,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Get agent-accessible MCP server names
|
||||
let agentMCPServerNames: string[] = [];
|
||||
let accessibleAgentIds: Types.ObjectId[] = [];
|
||||
|
||||
if (!userId) {
|
||||
// Get publicly accessible agents
|
||||
accessibleAgentIds = await this._aclService.findPubliclyAccessibleResources({
|
||||
resourceType: ResourceType.AGENT,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
} else {
|
||||
// Get user-accessible agents
|
||||
accessibleAgentIds = await this._aclService.findAccessibleResources({
|
||||
userId,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
resourceType: ResourceType.AGENT,
|
||||
});
|
||||
}
|
||||
|
||||
if (accessibleAgentIds.length > 0) {
|
||||
// Efficient query: get agents with non-empty mcpServerNames
|
||||
const Agent = this._mongoose.model('Agent');
|
||||
const agentsWithMCP = await Agent.find(
|
||||
{
|
||||
_id: { $in: accessibleAgentIds },
|
||||
mcpServerNames: { $exists: true, $not: { $size: 0 } },
|
||||
},
|
||||
{ mcpServerNames: 1 },
|
||||
).lean();
|
||||
|
||||
// Flatten and dedupe server names
|
||||
agentMCPServerNames = [
|
||||
...new Set(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agentsWithMCP.flatMap((a: any) => a.mcpServerNames || []),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// 3. Fetch directly accessible MCP servers
|
||||
const directResults = await this._dbMethods.getListMCPServersByIds({
|
||||
ids: directlyAccessibleMCPIds,
|
||||
});
|
||||
|
||||
// 4. Build result with direct access servers
|
||||
const parsedConfigs: Record<string, ParsedServerConfig> = {};
|
||||
const directServerNames = new Set<string>();
|
||||
|
||||
for (const s of directResults.data || []) {
|
||||
parsedConfigs[s.serverName] = this.mapDBServerToParsedConfig(s);
|
||||
directServerNames.add(s.serverName);
|
||||
}
|
||||
|
||||
// 5. Fetch agent-accessible servers (excluding already direct)
|
||||
const agentOnlyServerNames = agentMCPServerNames.filter((name) => !directServerNames.has(name));
|
||||
|
||||
if (agentOnlyServerNames.length > 0) {
|
||||
const agentServers = await this._dbMethods.getListMCPServersByNames({
|
||||
names: agentOnlyServerNames,
|
||||
});
|
||||
|
||||
for (const s of agentServers.data || []) {
|
||||
parsedConfigs[s.serverName] = {
|
||||
...this.mapDBServerToParsedConfig(s),
|
||||
consumeOnly: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return parsedConfigs;
|
||||
}
|
||||
|
||||
/** No-op for DB storage; logs a warning if called. */
|
||||
public async reset(): Promise<void> {
|
||||
logger.warn('Attempt to reset the DB config storage');
|
||||
return;
|
||||
}
|
||||
|
||||
/** Maps a MongoDB server document to the ParsedServerConfig format. */
|
||||
private mapDBServerToParsedConfig(serverDBDoc: MCPServerDocument): ParsedServerConfig {
|
||||
return {
|
||||
...serverDBDoc.config,
|
||||
dbId: (serverDBDoc._id as Types.ObjectId).toString(),
|
||||
updatedAt: serverDBDoc.updatedAt?.getTime(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,8 +153,15 @@ export type ParsedServerConfig = MCPOptions & {
|
|||
tools?: string;
|
||||
toolFunctions?: LCAvailableTools;
|
||||
initDuration?: number;
|
||||
lastUpdatedAt?: number;
|
||||
updatedAt?: number;
|
||||
dbId?: string;
|
||||
/** True if access is only via agent (not directly shared with user) */
|
||||
consumeOnly?: boolean;
|
||||
};
|
||||
|
||||
export type AddServerResult = {
|
||||
serverName: string;
|
||||
config: ParsedServerConfig;
|
||||
};
|
||||
|
||||
export interface BasicConnectionOptions {
|
||||
|
|
|
|||
|
|
@ -45,3 +45,30 @@ export function sanitizeUrlForLogging(url: string | URL): string {
|
|||
return '[invalid URL]';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes special regex characters in a string so they are treated literally.
|
||||
* @param str - The string to escape
|
||||
* @returns The escaped string safe for use in a regex pattern
|
||||
*/
|
||||
export 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.
|
||||
* @param title - The display title to convert
|
||||
* @returns A slug suitable for use as serverName (e.g., "GitHub MCP Tool" → "github-mcp-tool")
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue