mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 04:12:36 +00:00
Reimplement the MCP Apps ui-meta helpers (RESOURCE_MIME_TYPE, getToolUiResourceUri, isToolVisibilityModelOnly, isToolVisibilityAppOnly) in packages/api/src/mcp/apps.ts so @librechat/api no longer imports the ESM-only @modelcontextprotocol/ext-apps from its CommonJS build. ext-apps remains a client-only dependency, removing the require(ESM) boundary that throws ERR_REQUIRE_ESM on Node versions without synchronous require(esm) support. Add an mcpSettings.apps toggle (enabled unless explicitly false). Thread enableApps through connection creation so the io.modelcontextprotocol/ui capability is advertised only when apps are enabled, and gate the resource and app-tool-call routes with a requireMCPAppsEnabled middleware. Authorize app-driven resources/read against the resources and templates a server advertises, so a sandboxed app cannot proxy arbitrary uris. ui:// resources stay allowed and the check fails closed. Render MCP apps in shared and search transcripts display-only by withholding the host-bound bridge handlers and capabilities in read-only views, so an embedded app cannot call tools or read resources with the viewer's auth while the stored tool result still renders.
320 lines
11 KiB
JavaScript
320 lines
11 KiB
JavaScript
/**
|
|
* Tests for initializeMCPs.js
|
|
*
|
|
* These tests verify that MCPServersRegistry and MCPManager are ALWAYS initialized,
|
|
* even when no explicitly configured MCP servers exist. This is critical for the
|
|
* "Dynamic MCP Server Management" feature (introduced in `0.8.2-rc1` release) which
|
|
* allows users to add MCP servers via the UI without requiring explicit configuration.
|
|
*
|
|
* Bug fixed: Previously, MCPManager was only initialized when mcpServers existed
|
|
* in librechat.yaml, causing "MCPManager has not been initialized" errors when
|
|
* users tried to create MCP servers via the UI.
|
|
*/
|
|
|
|
// Mock dependencies before imports
|
|
jest.mock('mongoose', () => ({
|
|
connection: { readyState: 1 },
|
|
}));
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
debug: jest.fn(),
|
|
error: jest.fn(),
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock config functions
|
|
const mockGetAppConfig = jest.fn();
|
|
const mockMergeAppTools = jest.fn();
|
|
|
|
jest.mock('./Config', () => ({
|
|
get getAppConfig() {
|
|
return mockGetAppConfig;
|
|
},
|
|
get mergeAppTools() {
|
|
return mockMergeAppTools;
|
|
},
|
|
}));
|
|
|
|
// Mock MCP singletons
|
|
const mockCreateMCPServersRegistry = jest.fn();
|
|
const mockCreateMCPManager = jest.fn();
|
|
const mockMCPManagerInstance = {
|
|
getAppToolFunctions: jest.fn(),
|
|
};
|
|
|
|
jest.mock('~/config', () => ({
|
|
get createMCPServersRegistry() {
|
|
return mockCreateMCPServersRegistry;
|
|
},
|
|
get createMCPManager() {
|
|
return mockCreateMCPManager;
|
|
},
|
|
}));
|
|
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const initializeMCPs = require('./initializeMCPs');
|
|
|
|
describe('initializeMCPs', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Default: successful initialization
|
|
mockCreateMCPServersRegistry.mockReturnValue(undefined);
|
|
mockCreateMCPManager.mockResolvedValue(mockMCPManagerInstance);
|
|
mockMCPManagerInstance.getAppToolFunctions.mockResolvedValue({});
|
|
mockMergeAppTools.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe('MCPServersRegistry initialization', () => {
|
|
it('should ALWAYS initialize MCPServersRegistry even without configured servers', async () => {
|
|
mockGetAppConfig.mockResolvedValue({
|
|
mcpConfig: null, // No configured servers
|
|
mcpSettings: { allowedDomains: ['localhost'] },
|
|
});
|
|
|
|
await initializeMCPs();
|
|
|
|
expect(mockCreateMCPServersRegistry).toHaveBeenCalledTimes(1);
|
|
expect(mockCreateMCPServersRegistry).toHaveBeenCalledWith(
|
|
expect.anything(), // mongoose
|
|
['localhost'],
|
|
undefined,
|
|
expect.any(Function), // per-request allowlist resolver
|
|
undefined, // mcpSettings.apps
|
|
);
|
|
});
|
|
|
|
it('should pass allowedDomains from mcpSettings to registry', async () => {
|
|
const allowedDomains = ['localhost', '*.example.com', 'trusted-mcp.com'];
|
|
mockGetAppConfig.mockResolvedValue({
|
|
mcpConfig: null,
|
|
mcpSettings: { allowedDomains },
|
|
});
|
|
|
|
await initializeMCPs();
|
|
|
|
expect(mockCreateMCPServersRegistry).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
allowedDomains,
|
|
undefined,
|
|
expect.any(Function),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should handle undefined mcpSettings gracefully', async () => {
|
|
mockGetAppConfig.mockResolvedValue({
|
|
mcpConfig: null,
|
|
// mcpSettings is undefined
|
|
});
|
|
|
|
await initializeMCPs();
|
|
|
|
expect(mockCreateMCPServersRegistry).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
undefined,
|
|
undefined,
|
|
expect.any(Function),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('wires a per-request resolver that reads the merged (non-baseOnly) config', async () => {
|
|
mockGetAppConfig.mockResolvedValue({
|
|
mcpConfig: null,
|
|
mcpSettings: { allowedDomains: ['yaml.com'] },
|
|
});
|
|
|
|
await initializeMCPs();
|
|
|
|
const resolver = mockCreateMCPServersRegistry.mock.calls[0][3];
|
|
expect(typeof resolver).toBe('function');
|
|
|
|
// The resolver resolves the request's merged allowlists — not the boot YAML base.
|
|
mockGetAppConfig.mockResolvedValue({
|
|
mcpSettings: { allowedDomains: ['merged.com'], allowedAddresses: ['10.0.0.0/8'] },
|
|
});
|
|
const resolved = await resolver({ userId: 'u1', role: 'ADMIN' });
|
|
|
|
expect(mockGetAppConfig).toHaveBeenLastCalledWith({ role: 'ADMIN', userId: 'u1' });
|
|
expect(resolved).toEqual({
|
|
allowedDomains: ['merged.com'],
|
|
allowedAddresses: ['10.0.0.0/8'],
|
|
});
|
|
});
|
|
|
|
it('should throw and log error if MCPServersRegistry initialization fails', async () => {
|
|
const registryError = new Error('Registry initialization failed');
|
|
mockCreateMCPServersRegistry.mockImplementation(() => {
|
|
throw registryError;
|
|
});
|
|
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
|
|
|
|
await expect(initializeMCPs()).rejects.toThrow('Registry initialization failed');
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'[MCP] Failed to initialize MCPServersRegistry:',
|
|
registryError,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('MCPManager initialization', () => {
|
|
it('should ALWAYS initialize MCPManager even without configured servers', async () => {
|
|
mockGetAppConfig.mockResolvedValue({
|
|
mcpConfig: null, // No configured servers
|
|
});
|
|
|
|
await initializeMCPs();
|
|
|
|
// MCPManager should be created with empty object when no configured servers
|
|
expect(mockCreateMCPManager).toHaveBeenCalledTimes(1);
|
|
expect(mockCreateMCPManager).toHaveBeenCalledWith({});
|
|
});
|
|
|
|
it('should initialize MCPManager with configured servers when provided', async () => {
|
|
const mcpServers = {
|
|
'test-server': { type: 'sse', url: 'http://localhost:3001/sse' },
|
|
'local-server': { type: 'stdio', command: 'node', args: ['server.js'] },
|
|
};
|
|
mockGetAppConfig.mockResolvedValue({ mcpConfig: mcpServers });
|
|
|
|
await initializeMCPs();
|
|
|
|
expect(mockCreateMCPManager).toHaveBeenCalledWith(mcpServers);
|
|
});
|
|
|
|
it('should throw and log error if MCPManager initialization fails', async () => {
|
|
const managerError = new Error('Manager initialization failed');
|
|
mockCreateMCPManager.mockRejectedValue(managerError);
|
|
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
|
|
|
|
await expect(initializeMCPs()).rejects.toThrow('Manager initialization failed');
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'[MCP] Failed to initialize MCPManager:',
|
|
managerError,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Tool merging behavior', () => {
|
|
it('should NOT merge tools when no configured servers exist', async () => {
|
|
mockGetAppConfig.mockResolvedValue({
|
|
mcpConfig: null, // No configured servers
|
|
});
|
|
|
|
await initializeMCPs();
|
|
|
|
expect(mockMCPManagerInstance.getAppToolFunctions).not.toHaveBeenCalled();
|
|
expect(mockMergeAppTools).not.toHaveBeenCalled();
|
|
expect(logger.debug).toHaveBeenCalledWith(
|
|
'[MCP] No servers configured. MCPManager ready for UI-based servers.',
|
|
);
|
|
});
|
|
|
|
it('should NOT merge tools when mcpConfig is empty object', async () => {
|
|
mockGetAppConfig.mockResolvedValue({
|
|
mcpConfig: {}, // Empty object
|
|
});
|
|
|
|
await initializeMCPs();
|
|
|
|
expect(mockMCPManagerInstance.getAppToolFunctions).not.toHaveBeenCalled();
|
|
expect(mockMergeAppTools).not.toHaveBeenCalled();
|
|
expect(logger.debug).toHaveBeenCalledWith(
|
|
'[MCP] No servers configured. MCPManager ready for UI-based servers.',
|
|
);
|
|
});
|
|
|
|
it('should merge tools when configured servers exist', async () => {
|
|
const mcpServers = {
|
|
'test-server': { type: 'sse', url: 'http://localhost:3001/sse' },
|
|
};
|
|
const mcpTools = {
|
|
tool1: jest.fn(),
|
|
tool2: jest.fn(),
|
|
};
|
|
mockGetAppConfig.mockResolvedValue({ mcpConfig: mcpServers });
|
|
mockMCPManagerInstance.getAppToolFunctions.mockResolvedValue(mcpTools);
|
|
|
|
await initializeMCPs();
|
|
|
|
expect(mockMCPManagerInstance.getAppToolFunctions).toHaveBeenCalledTimes(1);
|
|
expect(mockMergeAppTools).toHaveBeenCalledWith(mcpTools);
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
'[MCP] Initialized with 1 configured server and 2 tools.',
|
|
);
|
|
});
|
|
|
|
it('should handle null return from getAppToolFunctions', async () => {
|
|
const mcpServers = { 'test-server': { type: 'sse', url: 'http://localhost:3001' } };
|
|
mockGetAppConfig.mockResolvedValue({ mcpConfig: mcpServers });
|
|
mockMCPManagerInstance.getAppToolFunctions.mockResolvedValue(null);
|
|
|
|
await initializeMCPs();
|
|
|
|
// Should use empty object fallback
|
|
expect(mockMergeAppTools).toHaveBeenCalledWith({});
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
'[MCP] Initialized with 1 configured server and 0 tools.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Initialization order', () => {
|
|
it('should initialize Registry before Manager', async () => {
|
|
const callOrder = [];
|
|
|
|
mockCreateMCPServersRegistry.mockImplementation(() => {
|
|
callOrder.push('registry');
|
|
});
|
|
mockCreateMCPManager.mockImplementation(async () => {
|
|
callOrder.push('manager');
|
|
return mockMCPManagerInstance;
|
|
});
|
|
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
|
|
|
|
await initializeMCPs();
|
|
|
|
expect(callOrder).toEqual(['registry', 'manager']);
|
|
});
|
|
|
|
it('should not attempt MCPManager initialization if Registry fails', async () => {
|
|
mockCreateMCPServersRegistry.mockImplementation(() => {
|
|
throw new Error('Registry failed');
|
|
});
|
|
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
|
|
|
|
await expect(initializeMCPs()).rejects.toThrow('Registry failed');
|
|
expect(mockCreateMCPManager).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('UI-based MCP server management support', () => {
|
|
/**
|
|
* This test documents the critical fix:
|
|
* MCPManager must be initialized even without configured servers to support
|
|
* the "Dynamic MCP Server Management" feature where users create
|
|
* MCP servers via the UI.
|
|
*/
|
|
it('should support UI-based server creation without explicit configuration', async () => {
|
|
// Scenario: User has no MCP servers in librechat.yaml but wants to
|
|
// add servers via the UI
|
|
mockGetAppConfig.mockResolvedValue({
|
|
mcpConfig: null,
|
|
mcpSettings: undefined,
|
|
});
|
|
|
|
await initializeMCPs();
|
|
|
|
// Both singletons must be initialized for UI-based management to work
|
|
expect(mockCreateMCPServersRegistry).toHaveBeenCalledTimes(1);
|
|
expect(mockCreateMCPManager).toHaveBeenCalledTimes(1);
|
|
|
|
// Verify manager was created with empty config (not null/undefined)
|
|
expect(mockCreateMCPManager).toHaveBeenCalledWith({});
|
|
});
|
|
});
|
|
});
|