mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 02:10:15 +01:00
🧬 refactor: Optimize MCP Tool Queries with Server-Centric Architecture
🧬 refactor: Optimize MCP Tool Queries with Server-Centric Architecture
refactor: optimize mcp tool queries by removing redundancy, making server-centric structure, enabling query only when expected, minimize looping/transforming query data, eliminating unused/compute-heavy methods
ci: MCP Server Tools Mocking in Agent Tests
This commit is contained in:
parent
5b1a31ef4d
commit
f0599ad36c
19 changed files with 235 additions and 1104 deletions
|
|
@ -13,7 +13,6 @@ import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
|
|||
import { formatToolContent } from './parsers';
|
||||
import { MCPConnection } from './connection';
|
||||
import { processMCPEnv } from '~/utils/env';
|
||||
import { CONSTANTS } from './enum';
|
||||
|
||||
/**
|
||||
* Centralized manager for MCP server connections and tool execution.
|
||||
|
|
@ -78,6 +77,28 @@ export class MCPManager extends UserConnectionManager {
|
|||
|
||||
return allToolFunctions;
|
||||
}
|
||||
/** Returns all available tool functions from all connections available to user */
|
||||
public async getServerToolFunctions(
|
||||
userId: string,
|
||||
serverName: string,
|
||||
): Promise<t.LCAvailableTools | null> {
|
||||
if (this.appConnections?.has(serverName)) {
|
||||
return this.serversRegistry.getToolFunctions(
|
||||
serverName,
|
||||
await this.appConnections.get(serverName),
|
||||
);
|
||||
}
|
||||
|
||||
const userConnections = this.getUserConnections(userId);
|
||||
if (!userConnections || userConnections.size === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!userConnections.has(serverName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.serversRegistry.getToolFunctions(serverName, userConnections.get(serverName)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instructions for MCP servers
|
||||
|
|
@ -121,72 +142,6 @@ ${formattedInstructions}
|
|||
Please follow these instructions when using tools from the respective MCP servers.`;
|
||||
}
|
||||
|
||||
private async loadAppManifestTools(): Promise<t.LCManifestTool[]> {
|
||||
const connections = await this.appConnections!.getAll();
|
||||
return await this.loadManifestTools(connections);
|
||||
}
|
||||
|
||||
private async loadUserManifestTools(userId: string): Promise<t.LCManifestTool[]> {
|
||||
const connections = this.getUserConnections(userId);
|
||||
return await this.loadManifestTools(connections);
|
||||
}
|
||||
|
||||
public async loadAllManifestTools(userId: string): Promise<t.LCManifestTool[]> {
|
||||
const appTools = await this.loadAppManifestTools();
|
||||
const userTools = await this.loadUserManifestTools(userId);
|
||||
return [...appTools, ...userTools];
|
||||
}
|
||||
|
||||
/** Loads tools from all app-level connections into the manifest. */
|
||||
private async loadManifestTools(
|
||||
connections?: Map<string, MCPConnection> | null,
|
||||
): Promise<t.LCToolManifest> {
|
||||
const mcpTools: t.LCManifestTool[] = [];
|
||||
if (!connections || connections.size === 0) {
|
||||
return mcpTools;
|
||||
}
|
||||
for (const [serverName, connection] of connections.entries()) {
|
||||
try {
|
||||
if (!(await connection.isConnected())) {
|
||||
logger.warn(
|
||||
`[MCP][${serverName}] Connection not available for ${serverName} manifest tools.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tools = await connection.fetchTools();
|
||||
const serverTools: t.LCManifestTool[] = [];
|
||||
for (const tool of tools) {
|
||||
const pluginKey = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`;
|
||||
|
||||
const config = this.serversRegistry.parsedConfigs[serverName];
|
||||
const manifestTool: t.LCManifestTool = {
|
||||
name: tool.name,
|
||||
pluginKey,
|
||||
description: tool.description ?? '',
|
||||
icon: connection.iconPath,
|
||||
authConfig: config?.customUserVars
|
||||
? Object.entries(config.customUserVars).map(([key, value]) => ({
|
||||
authField: key,
|
||||
label: value.title || key,
|
||||
description: value.description || '',
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
if (config?.chatMenu === false) {
|
||||
manifestTool.chatMenu = false;
|
||||
}
|
||||
mcpTools.push(manifestTool);
|
||||
serverTools.push(manifestTool);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[MCP][${serverName}] Error fetching tools for manifest:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return mcpTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a tool on an MCP server, using either a user-specific connection
|
||||
* (if userId is provided) or an app-level connection. Updates the last activity timestamp
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import pick from 'lodash/pick';
|
|||
import pickBy from 'lodash/pickBy';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { MCPConnection } from '~/mcp/connection';
|
||||
import type { JsonSchemaType } from '~/types';
|
||||
import type * as t from '~/mcp/types';
|
||||
|
|
@ -9,7 +10,6 @@ import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
|
|||
import { detectOAuthRequirement } from '~/mcp/oauth';
|
||||
import { sanitizeUrlForLogging } from '~/mcp/utils';
|
||||
import { processMCPEnv, isEnabled } from '~/utils';
|
||||
import { CONSTANTS } from '~/mcp/enum';
|
||||
|
||||
/**
|
||||
* Manages MCP server configurations and metadata discovery.
|
||||
|
|
@ -127,7 +127,7 @@ export class MCPServersRegistry {
|
|||
|
||||
const toolFunctions: t.LCAvailableTools = {};
|
||||
tools.forEach((tool) => {
|
||||
const name = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`;
|
||||
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
|
||||
toolFunctions[name] = {
|
||||
type: 'function',
|
||||
['function']: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
export enum CONSTANTS {
|
||||
mcp_delimiter = '_mcp_',
|
||||
/** System user ID for app-level OAuth tokens (all zeros ObjectId) */
|
||||
SYSTEM_USER_ID = '000000000000000000000000',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
import { AuthType, Constants, EToolResources } from 'librechat-data-provider';
|
||||
import type { TPlugin, FunctionTool } from 'librechat-data-provider';
|
||||
import type { MCPManager } from '~/mcp/MCPManager';
|
||||
import {
|
||||
convertMCPToolsToPlugins,
|
||||
filterUniquePlugins,
|
||||
checkPluginAuth,
|
||||
getToolkitKey,
|
||||
} from './format';
|
||||
import { AuthType, EToolResources } from 'librechat-data-provider';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import { filterUniquePlugins, checkPluginAuth, getToolkitKey } from './format';
|
||||
|
||||
describe('format.ts helper functions', () => {
|
||||
describe('filterUniquePlugins', () => {
|
||||
|
|
@ -197,212 +191,6 @@ describe('format.ts helper functions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('convertMCPToolsToPlugins', () => {
|
||||
it('should return undefined when functionTools is undefined', () => {
|
||||
const result = convertMCPToolsToPlugins({ functionTools: undefined });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when functionTools is not an object', () => {
|
||||
const result = convertMCPToolsToPlugins({
|
||||
functionTools: 'not-an-object' as unknown as Record<string, FunctionTool>,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return empty array when functionTools is empty object', () => {
|
||||
const result = convertMCPToolsToPlugins({ functionTools: {} });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip entries without function property', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
tool1: { type: 'function' } as FunctionTool,
|
||||
tool2: { function: { name: 'tool2', description: 'Tool 2' } } as FunctionTool,
|
||||
};
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools });
|
||||
expect(result).toHaveLength(0); // tool2 doesn't have mcp_delimiter in key
|
||||
});
|
||||
|
||||
it('should skip entries without mcp_delimiter in key', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
'regular-tool': {
|
||||
type: 'function',
|
||||
function: { name: 'regular-tool', description: 'Regular tool' },
|
||||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools });
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should convert MCP tools to plugins correctly', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: { name: 'tool1', description: 'Tool 1 description' },
|
||||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0]).toEqual({
|
||||
name: 'tool1',
|
||||
pluginKey: `tool1${Constants.mcp_delimiter}server1`,
|
||||
description: 'Tool 1 description',
|
||||
authenticated: true,
|
||||
icon: undefined,
|
||||
authConfig: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing description', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: { name: 'tool1' },
|
||||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].description).toBe('');
|
||||
});
|
||||
|
||||
it('should add icon from server config', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: { name: 'tool1', description: 'Tool 1' },
|
||||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
command: 'test',
|
||||
args: [],
|
||||
iconPath: '/path/to/icon.png',
|
||||
}),
|
||||
} as unknown as MCPManager;
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].icon).toBe('/path/to/icon.png');
|
||||
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
|
||||
});
|
||||
|
||||
it('should handle customUserVars in server config', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: { name: 'tool1', description: 'Tool 1' },
|
||||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
command: 'test',
|
||||
args: [],
|
||||
customUserVars: {
|
||||
API_KEY: { title: 'API Key', description: 'Your API key' },
|
||||
SECRET: { title: 'Secret', description: 'Your secret' },
|
||||
},
|
||||
}),
|
||||
} as unknown as MCPManager;
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].authConfig).toHaveLength(2);
|
||||
expect(result![0].authConfig).toEqual([
|
||||
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
|
||||
{ authField: 'SECRET', label: 'Secret', description: 'Your secret' },
|
||||
]);
|
||||
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
|
||||
});
|
||||
|
||||
it('should use key as label when title is missing in customUserVars', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: { name: 'tool1', description: 'Tool 1' },
|
||||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
command: 'test',
|
||||
args: [],
|
||||
customUserVars: {
|
||||
API_KEY: { title: 'API Key', description: 'Your API key' },
|
||||
},
|
||||
}),
|
||||
} as unknown as MCPManager;
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].authConfig).toEqual([
|
||||
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
|
||||
]);
|
||||
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
|
||||
});
|
||||
|
||||
it('should handle empty customUserVars', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: { name: 'tool1', description: 'Tool 1' },
|
||||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
command: 'test',
|
||||
args: [],
|
||||
customUserVars: {},
|
||||
}),
|
||||
} as unknown as MCPManager;
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].authConfig).toEqual([]);
|
||||
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
|
||||
});
|
||||
|
||||
it('should handle missing mcpManager', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: { name: 'tool1', description: 'Tool 1' },
|
||||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].icon).toBeUndefined();
|
||||
expect(result![0].authConfig).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle when getRawConfig returns undefined', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: { name: 'tool1', description: 'Tool 1' },
|
||||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue(undefined),
|
||||
} as unknown as MCPManager;
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].icon).toBeUndefined();
|
||||
expect(result![0].authConfig).toEqual([]);
|
||||
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolkitKey', () => {
|
||||
it('should return undefined when toolName is undefined', () => {
|
||||
const toolkits: TPlugin[] = [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { AuthType, Constants, EToolResources } from 'librechat-data-provider';
|
||||
import { AuthType, EToolResources } from 'librechat-data-provider';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import type { MCPManager } from '~/mcp/MCPManager';
|
||||
import { LCAvailableTools, LCFunctionTool } from '~/mcp/types';
|
||||
|
||||
/**
|
||||
* Filters out duplicate plugins from the list of plugins.
|
||||
|
|
@ -48,90 +46,6 @@ export const checkPluginAuth = (plugin?: TPlugin): boolean => {
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts MCP function format tool to plugin format
|
||||
* @param params
|
||||
* @param params.toolKey
|
||||
* @param params.toolData
|
||||
* @param params.customConfig
|
||||
* @returns
|
||||
*/
|
||||
export function convertMCPToolToPlugin({
|
||||
toolKey,
|
||||
toolData,
|
||||
mcpManager,
|
||||
}: {
|
||||
toolKey: string;
|
||||
toolData: LCFunctionTool;
|
||||
mcpManager?: MCPManager;
|
||||
}): TPlugin | undefined {
|
||||
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const functionData = toolData.function;
|
||||
const parts = toolKey.split(Constants.mcp_delimiter);
|
||||
const serverName = parts[parts.length - 1];
|
||||
|
||||
const serverConfig = mcpManager?.getRawConfig(serverName);
|
||||
|
||||
const plugin: TPlugin = {
|
||||
/** Tool name without server suffix */
|
||||
name: parts[0],
|
||||
pluginKey: toolKey,
|
||||
description: functionData.description || '',
|
||||
authenticated: true,
|
||||
icon: serverConfig?.iconPath,
|
||||
};
|
||||
|
||||
if (!serverConfig?.customUserVars) {
|
||||
/** `authConfig` for MCP tools */
|
||||
plugin.authConfig = [];
|
||||
return plugin;
|
||||
}
|
||||
|
||||
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
||||
if (customVarKeys.length === 0) {
|
||||
plugin.authConfig = [];
|
||||
} else {
|
||||
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
|
||||
authField: key,
|
||||
label: value.title || key,
|
||||
description: value.description || '',
|
||||
}));
|
||||
}
|
||||
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts MCP function format tools to plugin format
|
||||
* @param functionTools - Object with function format tools
|
||||
* @param customConfig - Custom configuration for MCP servers
|
||||
* @returns Array of plugin objects
|
||||
*/
|
||||
export function convertMCPToolsToPlugins({
|
||||
functionTools,
|
||||
mcpManager,
|
||||
}: {
|
||||
functionTools?: LCAvailableTools;
|
||||
mcpManager?: MCPManager;
|
||||
}): TPlugin[] | undefined {
|
||||
if (!functionTools || typeof functionTools !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const plugins: TPlugin[] = [];
|
||||
for (const [toolKey, toolData] of Object.entries(functionTools)) {
|
||||
const plugin = convertMCPToolToPlugin({ toolKey, toolData, mcpManager });
|
||||
if (plugin) {
|
||||
plugins.push(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param toolkits
|
||||
* @param toolName
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue