LibreChat/packages/api/src/mcp/__tests__/MCPManager.test.ts
Max Sanna 762d78b7fe
🪪 feat: Microsoft Graph Access Token Placeholder for MCP Servers (#10867)
* feat: MCP Graph Token env var

* Addressing copilot remarks

* Addressed Copilot review remarks

* Fixed graphtokenservice mock in MCP test suite

* fix: remove unnecessary type check and cast in resolveGraphTokensInRecord

* ci: add Graph Token integration tests in MCPManager

* refactor: update user type definitions to use Partial<IUser> in multiple functions

* test: enhance MCP tests for graph token processing and user placeholder resolution

- Added comprehensive tests to validate the interaction between preProcessGraphTokens and processMCPEnv.
- Ensured correct resolution of graph tokens and user placeholders in various configurations.
- Mocked OIDC utilities to facilitate testing of token extraction and validation.
- Verified that original options remain unchanged after processing.

* chore: import order

* chore: imports

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-01-22 10:01:45 -05:00

790 lines
25 KiB
TypeScript

import { logger } from '@librechat/data-schemas';
import type { IUser } from '@librechat/data-schemas';
import type { GraphTokenResolver } from '~/utils/graph';
import type * as t from '~/mcp/types';
import { MCPServersInitializer } from '~/mcp/registry/MCPServersInitializer';
import { MCPServerInspector } from '~/mcp/registry/MCPServerInspector';
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
import { MCPConnection } from '~/mcp/connection';
import { MCPManager } from '~/mcp/MCPManager';
import * as graphUtils from '~/utils/graph';
// Mock external dependencies
jest.mock('@librechat/data-schemas', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));
jest.mock('~/utils/graph', () => ({
...jest.requireActual('~/utils/graph'),
preProcessGraphTokens: jest.fn(),
}));
jest.mock('~/utils/env', () => ({
processMCPEnv: jest.fn((params) => params.options),
}));
const mockRegistryInstance = {
getServerConfig: jest.fn(),
getAllServerConfigs: jest.fn(),
getOAuthServers: jest.fn(),
};
jest.mock('~/mcp/registry/MCPServersRegistry', () => ({
MCPServersRegistry: {
getInstance: () => mockRegistryInstance,
},
}));
jest.mock('~/mcp/registry/MCPServersInitializer', () => ({
MCPServersInitializer: {
initialize: jest.fn(),
},
}));
jest.mock('~/mcp/registry/MCPServerInspector');
jest.mock('~/mcp/ConnectionsRepository');
const mockLogger = logger as jest.Mocked<typeof logger>;
describe('MCPManager', () => {
const userId = 'test-user-123';
const serverName = 'test_server';
beforeEach(() => {
// Reset MCPManager singleton state
(MCPManager as unknown as { instance: null }).instance = null;
jest.clearAllMocks();
// Set up default mock implementations
(MCPServersInitializer.initialize as jest.Mock).mockResolvedValue(undefined);
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({});
});
function mockAppConnections(
appConnectionsConfig: Partial<ConnectionsRepository>,
): jest.MockedClass<typeof ConnectionsRepository> {
const mock = {
has: jest.fn().mockResolvedValue(false),
get: jest.fn().mockResolvedValue({} as unknown as MCPConnection),
...appConnectionsConfig,
};
return (
ConnectionsRepository as jest.MockedClass<typeof ConnectionsRepository>
).mockImplementation(() => mock as unknown as ConnectionsRepository);
}
function newMCPServersConfig(serverNameOverride?: string): t.MCPServers {
return {
[serverNameOverride ?? serverName]: {
type: 'stdio',
command: 'test',
args: [],
},
};
}
describe('getAppToolFunctions', () => {
it('should return empty object when no servers have tool functions', async () => {
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
server1: { type: 'stdio', command: 'test', args: [] },
server2: { type: 'stdio', command: 'test2', args: [] },
});
const manager = await MCPManager.createInstance(newMCPServersConfig());
const result = await manager.getAppToolFunctions();
expect(result).toEqual({});
});
it('should collect tool functions from multiple servers', async () => {
const toolFunctions1 = {
tool1_mcp_server1: {
type: 'function' as const,
function: {
name: 'tool1_mcp_server1',
description: 'Tool 1',
parameters: { type: 'object' as const },
},
},
};
const toolFunctions2 = {
tool2_mcp_server2: {
type: 'function' as const,
function: {
name: 'tool2_mcp_server2',
description: 'Tool 2',
parameters: { type: 'object' as const },
},
},
};
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
server1: {
type: 'stdio',
command: 'test',
args: [],
toolFunctions: toolFunctions1,
},
server2: {
type: 'stdio',
command: 'test2',
args: [],
toolFunctions: toolFunctions2,
},
});
const manager = await MCPManager.createInstance(newMCPServersConfig());
const result = await manager.getAppToolFunctions();
expect(result).toEqual({
...toolFunctions1,
...toolFunctions2,
});
});
it('should handle servers with null or undefined toolFunctions', async () => {
const toolFunctions1 = {
tool1_mcp_server1: {
type: 'function' as const,
function: {
name: 'tool1_mcp_server1',
description: 'Tool 1',
parameters: { type: 'object' as const },
},
},
};
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
server1: {
type: 'stdio',
command: 'test',
args: [],
toolFunctions: toolFunctions1,
},
server2: {
type: 'stdio',
command: 'test2',
args: [],
toolFunctions: null,
},
server3: {
type: 'stdio',
command: 'test3',
args: [],
},
});
const manager = await MCPManager.createInstance(newMCPServersConfig());
const result = await manager.getAppToolFunctions();
expect(result).toEqual(toolFunctions1);
});
});
describe('formatInstructionsForContext', () => {
it('should return empty string when no servers have instructions', async () => {
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
server1: { type: 'stdio', command: 'test', args: [] },
server2: { type: 'stdio', command: 'test2', args: [] },
});
const manager = await MCPManager.createInstance(newMCPServersConfig());
const result = await manager.formatInstructionsForContext();
expect(result).toBe('');
});
it('should format instructions from multiple servers', async () => {
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
github: {
type: 'sse',
url: 'https://api.github.com',
serverInstructions: 'Use GitHub API with care',
},
files: {
type: 'stdio',
command: 'node',
args: ['files.js'],
serverInstructions: 'Only read/write files in allowed directories',
},
});
const manager = await MCPManager.createInstance(newMCPServersConfig());
const result = await manager.formatInstructionsForContext();
expect(result).toContain('# MCP Server Instructions');
expect(result).toContain('## github MCP Server Instructions');
expect(result).toContain('Use GitHub API with care');
expect(result).toContain('## files MCP Server Instructions');
expect(result).toContain('Only read/write files in allowed directories');
});
it('should filter instructions by server names when provided', async () => {
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
github: {
type: 'sse',
url: 'https://api.github.com',
serverInstructions: 'Use GitHub API with care',
},
files: {
type: 'stdio',
command: 'node',
args: ['files.js'],
serverInstructions: 'Only read/write files in allowed directories',
},
database: {
type: 'stdio',
command: 'node',
args: ['db.js'],
serverInstructions: 'Be careful with database operations',
},
});
const manager = await MCPManager.createInstance(newMCPServersConfig());
const result = await manager.formatInstructionsForContext(['github', 'database']);
expect(result).toContain('## github MCP Server Instructions');
expect(result).toContain('Use GitHub API with care');
expect(result).toContain('## database MCP Server Instructions');
expect(result).toContain('Be careful with database operations');
expect(result).not.toContain('files');
expect(result).not.toContain('Only read/write files in allowed directories');
});
it('should handle servers with null or undefined instructions', async () => {
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
github: {
type: 'sse',
url: 'https://api.github.com',
serverInstructions: 'Use GitHub API with care',
},
files: {
type: 'stdio',
command: 'node',
args: ['files.js'],
serverInstructions: null,
},
database: {
type: 'stdio',
command: 'node',
args: ['db.js'],
},
});
const manager = await MCPManager.createInstance(newMCPServersConfig());
const result = await manager.formatInstructionsForContext();
expect(result).toContain('## github MCP Server Instructions');
expect(result).toContain('Use GitHub API with care');
expect(result).not.toContain('files');
expect(result).not.toContain('database');
});
it('should return empty string when filtered servers have no instructions', async () => {
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
github: {
type: 'sse',
url: 'https://api.github.com',
serverInstructions: 'Use GitHub API with care',
},
files: {
type: 'stdio',
command: 'node',
args: ['files.js'],
},
});
const manager = await MCPManager.createInstance(newMCPServersConfig());
const result = await manager.formatInstructionsForContext(['files']);
expect(result).toBe('');
});
});
describe('getServerToolFunctions', () => {
it('should catch and handle errors gracefully', async () => {
(MCPServerInspector.getToolFunctions as jest.Mock) = jest.fn(() => {
throw new Error('Connection failed');
});
mockAppConnections({
has: jest.fn().mockResolvedValue(true),
});
const manager = await MCPManager.createInstance(newMCPServersConfig());
const result = await manager.getServerToolFunctions(userId, serverName);
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
`[getServerToolFunctions] Error getting tool functions for server ${serverName}`,
expect.any(Error),
);
});
it('should catch synchronous errors from getUserConnections', async () => {
(MCPServerInspector.getToolFunctions as jest.Mock) = jest.fn().mockResolvedValue({});
mockAppConnections({
get: jest.fn().mockResolvedValue(null),
});
const manager = await MCPManager.createInstance(newMCPServersConfig());
const spy = jest.spyOn(manager, 'getUserConnections').mockImplementation(() => {
throw new Error('Failed to get user connections');
});
const result = await manager.getServerToolFunctions(userId, serverName);
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
`[getServerToolFunctions] Error getting tool functions for server ${serverName}`,
expect.any(Error),
);
expect(spy).toHaveBeenCalled();
});
it('should return tools successfully when no errors occur', async () => {
const expectedTools: t.LCAvailableTools = {
[`test_tool_mcp_${serverName}`]: {
type: 'function',
function: {
name: `test_tool_mcp_${serverName}`,
description: 'Test tool',
parameters: { type: 'object' },
},
},
};
(MCPServerInspector.getToolFunctions as jest.Mock) = jest
.fn()
.mockResolvedValue(expectedTools);
mockAppConnections({
has: jest.fn().mockResolvedValue(true),
});
const manager = await MCPManager.createInstance(newMCPServersConfig());
const result = await manager.getServerToolFunctions(userId, serverName);
expect(result).toEqual(expectedTools);
expect(mockLogger.warn).not.toHaveBeenCalled();
});
it('should include specific server name in error messages', async () => {
const specificServerName = 'github_mcp_server';
(MCPServerInspector.getToolFunctions as jest.Mock) = jest.fn(() => {
throw new Error('Server specific error');
});
mockAppConnections({
has: jest.fn().mockResolvedValue(true),
});
const manager = await MCPManager.createInstance(newMCPServersConfig(specificServerName));
const result = await manager.getServerToolFunctions(userId, specificServerName);
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
`[getServerToolFunctions] Error getting tool functions for server ${specificServerName}`,
expect.any(Error),
);
});
});
describe('callTool - Graph Token Integration', () => {
const mockUser: Partial<IUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
};
const mockFlowManager = {
getState: jest.fn(),
setState: jest.fn(),
clearState: jest.fn(),
};
const mockConnection = {
isConnected: jest.fn().mockResolvedValue(true),
setRequestHeaders: jest.fn(),
timeout: 30000,
client: {
request: jest.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Tool result' }],
isError: false,
}),
},
} as unknown as MCPConnection;
const mockGraphTokenResolver: GraphTokenResolver = jest.fn().mockResolvedValue({
access_token: 'resolved-graph-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'https://graph.microsoft.com/.default',
});
function createServerConfigWithGraphPlaceholder(): t.SSEOptions {
return {
type: 'sse',
url: 'https://api.example.com',
headers: {
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
'Content-Type': 'application/json',
},
};
}
beforeEach(() => {
jest.clearAllMocks();
// Mock preProcessGraphTokens to simulate token resolution
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(
async (options, graphOptions) => {
if (
options.headers?.Authorization?.includes('{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}') &&
graphOptions.graphTokenResolver
) {
return {
...options,
headers: {
...options.headers,
Authorization: 'Bearer resolved-graph-token',
},
};
}
return options;
},
);
});
it('should call preProcessGraphTokens with graphTokenResolver when provided', async () => {
const serverConfig = createServerConfigWithGraphPlaceholder();
mockAppConnections({
get: jest.fn().mockResolvedValue(mockConnection),
});
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
await manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
graphTokenResolver: mockGraphTokenResolver,
});
expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith(
serverConfig,
expect.objectContaining({
user: mockUser,
graphTokenResolver: mockGraphTokenResolver,
}),
);
});
it('should resolve graph token placeholders in headers before tool call', async () => {
const serverConfig = createServerConfigWithGraphPlaceholder();
mockAppConnections({
get: jest.fn().mockResolvedValue(mockConnection),
});
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
await manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
graphTokenResolver: mockGraphTokenResolver,
});
// Verify the connection received the resolved headers
expect(mockConnection.setRequestHeaders).toHaveBeenCalledWith(
expect.objectContaining({
Authorization: 'Bearer resolved-graph-token',
}),
);
});
it('should pass options unchanged when no graphTokenResolver is provided', async () => {
const serverConfig: t.SSEOptions = {
type: 'sse',
url: 'https://api.example.com',
headers: {
Authorization: 'Bearer static-token',
},
};
// Reset mock to return options unchanged
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(
async (options) => options,
);
mockAppConnections({
get: jest.fn().mockResolvedValue(mockConnection),
});
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
await manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
// No graphTokenResolver provided
});
// Verify preProcessGraphTokens was still called (to check for placeholders)
expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith(
serverConfig,
expect.objectContaining({
user: mockUser,
graphTokenResolver: undefined,
}),
);
});
it('should handle graph token resolution failure gracefully', async () => {
const serverConfig = createServerConfigWithGraphPlaceholder();
// Simulate resolution failure - returns original value unchanged
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(
async (options) => options,
);
mockAppConnections({
get: jest.fn().mockResolvedValue(mockConnection),
});
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
// Should not throw, even when token resolution fails
await expect(
manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
graphTokenResolver: mockGraphTokenResolver,
}),
).resolves.toBeDefined();
// Headers should contain the unresolved placeholder
expect(mockConnection.setRequestHeaders).toHaveBeenCalledWith(
expect.objectContaining({
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
}),
);
});
it('should resolve graph tokens in env variables', async () => {
const serverConfig: t.StdioOptions = {
type: 'stdio',
command: 'node',
args: ['server.js'],
env: {
GRAPH_TOKEN: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
OTHER_VAR: 'static-value',
},
};
// Mock resolution for env variables
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(async (options) => {
if (options.env?.GRAPH_TOKEN?.includes('{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}')) {
return {
...options,
env: {
...options.env,
GRAPH_TOKEN: 'resolved-graph-token',
},
};
}
return options;
});
mockAppConnections({
get: jest.fn().mockResolvedValue(mockConnection),
});
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
await manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
graphTokenResolver: mockGraphTokenResolver,
});
expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith(
serverConfig,
expect.objectContaining({
graphTokenResolver: mockGraphTokenResolver,
}),
);
});
it('should resolve graph tokens in URL', async () => {
const serverConfig: t.SSEOptions = {
type: 'sse',
url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
};
// Mock resolution for URL
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(async (options) => {
if (options.url?.includes('{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}')) {
return {
...options,
url: 'https://api.example.com?token=resolved-graph-token',
};
}
return options;
});
mockAppConnections({
get: jest.fn().mockResolvedValue(mockConnection),
});
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
await manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
graphTokenResolver: mockGraphTokenResolver,
});
expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith(
serverConfig,
expect.objectContaining({
graphTokenResolver: mockGraphTokenResolver,
}),
);
});
it('should pass scopes from environment variable to preProcessGraphTokens', async () => {
const originalEnv = process.env.GRAPH_API_SCOPES;
process.env.GRAPH_API_SCOPES = 'custom.scope.read custom.scope.write';
const serverConfig = createServerConfigWithGraphPlaceholder();
mockAppConnections({
get: jest.fn().mockResolvedValue(mockConnection),
});
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
await manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
graphTokenResolver: mockGraphTokenResolver,
});
expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith(
serverConfig,
expect.objectContaining({
scopes: 'custom.scope.read custom.scope.write',
}),
);
// Restore environment
if (originalEnv !== undefined) {
process.env.GRAPH_API_SCOPES = originalEnv;
} else {
delete process.env.GRAPH_API_SCOPES;
}
});
it('should work correctly when config has no graph token placeholders', async () => {
const serverConfig: t.SSEOptions = {
type: 'sse',
url: 'https://api.example.com',
headers: {
Authorization: 'Bearer static-token',
},
};
// Mock to return unchanged options when no placeholders
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(
async (options) => options,
);
mockAppConnections({
get: jest.fn().mockResolvedValue(mockConnection),
});
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
const result = await manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
graphTokenResolver: mockGraphTokenResolver,
});
expect(result).toBeDefined();
expect(mockConnection.setRequestHeaders).toHaveBeenCalledWith(
expect.objectContaining({
Authorization: 'Bearer static-token',
}),
);
});
});
});