🧬 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:
Danny Avila 2025-09-21 20:19:51 -04:00
parent 5b1a31ef4d
commit f0599ad36c
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
19 changed files with 235 additions and 1104 deletions

View file

@ -8,6 +8,7 @@ process.env.CREDS_IV = '0123456789abcdef';
jest.mock('~/server/services/Config', () => ({ jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn(), getCachedTools: jest.fn(),
getMCPServerTools: jest.fn(),
})); }));
const mongoose = require('mongoose'); const mongoose = require('mongoose');
@ -30,7 +31,7 @@ const {
generateActionMetadataHash, generateActionMetadataHash,
} = require('./Agent'); } = require('./Agent');
const permissionService = require('~/server/services/PermissionService'); const permissionService = require('~/server/services/PermissionService');
const { getCachedTools } = require('~/server/services/Config'); const { getCachedTools, getMCPServerTools } = require('~/server/services/Config');
const { AclEntry } = require('~/db/models'); const { AclEntry } = require('~/db/models');
/** /**
@ -1929,6 +1930,16 @@ describe('models/Agent', () => {
another_tool: {}, another_tool: {},
}); });
// Mock getMCPServerTools to return tools for each server
getMCPServerTools.mockImplementation(async (server) => {
if (server === 'server1') {
return { tool1_mcp_server1: {} };
} else if (server === 'server2') {
return { tool2_mcp_server2: {} };
}
return null;
});
const mockReq = { const mockReq = {
user: { id: 'user123' }, user: { id: 'user123' },
body: { body: {
@ -2113,6 +2124,14 @@ describe('models/Agent', () => {
getCachedTools.mockResolvedValue(availableTools); getCachedTools.mockResolvedValue(availableTools);
// Mock getMCPServerTools to return all tools for server1
getMCPServerTools.mockImplementation(async (server) => {
if (server === 'server1') {
return availableTools; // All 100 tools belong to server1
}
return null;
});
const mockReq = { const mockReq = {
user: { id: 'user123' }, user: { id: 'user123' },
body: { body: {
@ -2654,6 +2673,17 @@ describe('models/Agent', () => {
tool_mcp_server2: {}, // Different server tool_mcp_server2: {}, // Different server
}); });
// Mock getMCPServerTools to return only tools matching the server
getMCPServerTools.mockImplementation(async (server) => {
if (server === 'server1') {
// Only return tool that correctly matches server1 format
return { tool_mcp_server1: {} };
} else if (server === 'server2') {
return { tool_mcp_server2: {} };
}
return null;
});
const mockReq = { const mockReq = {
user: { id: 'user123' }, user: { id: 'user123' },
body: { body: {

View file

@ -4,7 +4,6 @@
*/ */
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider'); const { Constants } = require('librechat-data-provider');
const { convertMCPToolToPlugin } = require('@librechat/api');
const { const {
cacheMCPServerTools, cacheMCPServerTools,
getMCPServerTools, getMCPServerTools,
@ -14,7 +13,6 @@ const { getMCPManager } = require('~/config');
/** /**
* Get all MCP tools available to the user * Get all MCP tools available to the user
* Returns only MCP tools, not regular LibreChat tools
*/ */
const getMCPTools = async (req, res) => { const getMCPTools = async (req, res) => {
try { try {
@ -26,77 +24,97 @@ const getMCPTools = async (req, res) => {
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role })); const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
if (!appConfig?.mcpConfig) { if (!appConfig?.mcpConfig) {
return res.status(200).json([]); return res.status(200).json({ servers: {} });
} }
const mcpManager = getMCPManager(); const mcpManager = getMCPManager();
const configuredServers = Object.keys(appConfig.mcpConfig); const configuredServers = Object.keys(appConfig.mcpConfig);
const mcpTools = []; const mcpServers = {};
// Fetch tools from each configured server const cachePromises = configuredServers.map((serverName) =>
getMCPServerTools(serverName).then((tools) => ({ serverName, tools })),
);
const cacheResults = await Promise.all(cachePromises);
const serverToolsMap = new Map();
for (const { serverName, tools } of cacheResults) {
if (tools) {
serverToolsMap.set(serverName, tools);
continue;
}
const serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
if (!serverTools) {
logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
continue;
}
serverToolsMap.set(serverName, serverTools);
if (Object.keys(serverTools).length > 0) {
// Cache asynchronously without blocking
cacheMCPServerTools({ serverName, serverTools }).catch((err) =>
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
);
}
}
// Process each configured server
for (const serverName of configuredServers) { for (const serverName of configuredServers) {
try { try {
// First check server-specific cache const serverTools = serverToolsMap.get(serverName);
let serverTools = await getMCPServerTools(serverName);
if (!serverTools) { // Get server config once
// If not cached, fetch from MCP manager
const allTools = await mcpManager.getAllToolFunctions(userId);
serverTools = {};
// Filter tools for this specific server
for (const [toolKey, toolData] of Object.entries(allTools)) {
if (toolKey.endsWith(`${Constants.mcp_delimiter}${serverName}`)) {
serverTools[toolKey] = toolData;
}
}
// Cache server tools if found
if (Object.keys(serverTools).length > 0) {
await cacheMCPServerTools({ serverName, serverTools });
}
}
// Convert to plugin format
for (const [toolKey, toolData] of Object.entries(serverTools)) {
const plugin = convertMCPToolToPlugin({
toolKey,
toolData,
mcpManager,
});
if (plugin) {
// Add authentication config from server config
const serverConfig = appConfig.mcpConfig[serverName]; const serverConfig = appConfig.mcpConfig[serverName];
const rawServerConfig = mcpManager.getRawConfig(serverName);
// Initialize server object with all server-level data
const server = {
name: serverName,
icon: rawServerConfig?.iconPath || '',
authenticated: true,
authConfig: [],
tools: [],
};
// Set authentication config once for the server
if (serverConfig?.customUserVars) { if (serverConfig?.customUserVars) {
const customVarKeys = Object.keys(serverConfig.customUserVars); const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length === 0) { if (customVarKeys.length > 0) {
plugin.authConfig = []; server.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
plugin.authenticated = true;
} else {
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(
([key, value]) => ({
authField: key, authField: key,
label: value.title || key, label: value.title || key,
description: value.description || '', description: value.description || '',
}), }));
); server.authenticated = false;
plugin.authenticated = false;
} }
} else {
plugin.authConfig = [];
plugin.authenticated = true;
} }
mcpTools.push(plugin); // Process tools efficiently - no need for convertMCPToolToPlugin
if (serverTools) {
for (const [toolKey, toolData] of Object.entries(serverTools)) {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
continue;
} }
const toolName = toolKey.split(Constants.mcp_delimiter)[0];
server.tools.push({
name: toolName,
pluginKey: toolKey,
description: toolData.function.description || '',
});
}
}
// Only add server if it has tools or is configured
if (server.tools.length > 0 || serverConfig) {
mcpServers[serverName] = server;
} }
} catch (error) { } catch (error) {
logger.error(`[getMCPTools] Error loading tools for server ${serverName}:`, error); logger.error(`[getMCPTools] Error loading tools for server ${serverName}:`, error);
} }
} }
res.status(200).json(mcpTools); res.status(200).json({ servers: mcpServers });
} catch (error) { } catch (error) {
logger.error('[getMCPTools]', error); logger.error('[getMCPTools]', error);
res.status(500).json({ message: error.message }); res.status(500).json({ message: error.message });

View file

@ -1,561 +0,0 @@
const { getMCPTools } = require('./mcp');
const { getAppConfig, getMCPServerTools } = require('~/server/services/Config');
const { getMCPManager } = require('~/config');
const { convertMCPToolToPlugin } = require('@librechat/api');
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
}));
jest.mock('librechat-data-provider', () => ({
Constants: {
mcp_delimiter: '~~~',
},
}));
jest.mock('@librechat/api', () => ({
convertMCPToolToPlugin: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
getMCPServerTools: jest.fn(),
cacheMCPServerTools: jest.fn(),
}));
jest.mock('~/config', () => ({
getMCPManager: jest.fn(),
}));
describe('MCP Controller', () => {
let mockReq, mockRes, mockMCPManager;
beforeEach(() => {
jest.clearAllMocks();
mockReq = {
user: { id: 'test-user-id', role: 'user' },
config: null,
};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
mockMCPManager = {
getAllToolFunctions: jest.fn().mockResolvedValue({}),
};
getMCPManager.mockReturnValue(mockMCPManager);
getAppConfig.mockResolvedValue({
mcpConfig: {},
});
getMCPServerTools.mockResolvedValue(null);
});
describe('getMCPTools', () => {
it('should return 401 when user ID is not found', async () => {
mockReq.user = null;
await getMCPTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Unauthorized' });
const { logger } = require('@librechat/data-schemas');
expect(logger.warn).toHaveBeenCalledWith('[getMCPTools] User ID not found in request');
});
it('should return empty array when no mcpConfig exists', async () => {
getAppConfig.mockResolvedValue({
// No mcpConfig
});
await getMCPTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should use cached server tools when available', async () => {
const cachedTools = {
'tool1~~~server1': {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
};
getMCPServerTools.mockResolvedValue(cachedTools);
getAppConfig.mockResolvedValue({
mcpConfig: {
server1: {},
},
});
const mockPlugin = {
name: 'Tool 1',
pluginKey: 'tool1~~~server1',
description: 'Tool 1',
};
convertMCPToolToPlugin.mockReturnValue(mockPlugin);
await getMCPTools(mockReq, mockRes);
expect(getMCPServerTools).toHaveBeenCalledWith('server1');
expect(mockMCPManager.getAllToolFunctions).not.toHaveBeenCalled();
expect(convertMCPToolToPlugin).toHaveBeenCalledWith({
toolKey: 'tool1~~~server1',
toolData: cachedTools['tool1~~~server1'],
mcpManager: mockMCPManager,
});
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([
{
...mockPlugin,
authConfig: [],
authenticated: true,
},
]);
});
it('should fetch from MCP manager when cache is empty', async () => {
getMCPServerTools.mockResolvedValue(null);
const allTools = {
'tool1~~~server1': {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
'tool2~~~server2': {
type: 'function',
function: {
name: 'tool2',
description: 'Tool 2',
parameters: {},
},
},
};
mockMCPManager.getAllToolFunctions.mockResolvedValue(allTools);
getAppConfig.mockResolvedValue({
mcpConfig: {
server1: {},
},
});
const mockPlugin = {
name: 'Tool 1',
pluginKey: 'tool1~~~server1',
description: 'Tool 1',
};
convertMCPToolToPlugin.mockReturnValue(mockPlugin);
await getMCPTools(mockReq, mockRes);
expect(getMCPServerTools).toHaveBeenCalledWith('server1');
expect(mockMCPManager.getAllToolFunctions).toHaveBeenCalledWith('test-user-id');
// Should cache the server tools
const { cacheMCPServerTools } = require('~/server/services/Config');
expect(cacheMCPServerTools).toHaveBeenCalledWith({
serverName: 'server1',
serverTools: {
'tool1~~~server1': allTools['tool1~~~server1'],
},
});
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([
{
...mockPlugin,
authConfig: [],
authenticated: true,
},
]);
});
it('should handle custom user variables in server config', async () => {
getMCPServerTools.mockResolvedValue({
'tool1~~~server1': {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
});
getAppConfig.mockResolvedValue({
mcpConfig: {
server1: {
customUserVars: {
API_KEY: {
title: 'API Key',
description: 'Your API key',
},
SECRET: {
title: 'Secret Token',
description: 'Your secret token',
},
},
},
},
});
const mockPlugin = {
name: 'Tool 1',
pluginKey: 'tool1~~~server1',
description: 'Tool 1',
};
convertMCPToolToPlugin.mockReturnValue(mockPlugin);
await getMCPTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([
{
...mockPlugin,
authConfig: [
{
authField: 'API_KEY',
label: 'API Key',
description: 'Your API key',
},
{
authField: 'SECRET',
label: 'Secret Token',
description: 'Your secret token',
},
],
authenticated: false,
},
]);
});
it('should handle empty custom user variables', async () => {
getMCPServerTools.mockResolvedValue({
'tool1~~~server1': {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
});
getAppConfig.mockResolvedValue({
mcpConfig: {
server1: {
customUserVars: {},
},
},
});
const mockPlugin = {
name: 'Tool 1',
pluginKey: 'tool1~~~server1',
description: 'Tool 1',
};
convertMCPToolToPlugin.mockReturnValue(mockPlugin);
await getMCPTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([
{
...mockPlugin,
authConfig: [],
authenticated: true,
},
]);
});
it('should handle multiple servers', async () => {
getMCPServerTools.mockResolvedValue(null);
const allTools = {
'tool1~~~server1': {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
'tool2~~~server2': {
type: 'function',
function: {
name: 'tool2',
description: 'Tool 2',
parameters: {},
},
},
};
mockMCPManager.getAllToolFunctions.mockResolvedValue(allTools);
getAppConfig.mockResolvedValue({
mcpConfig: {
server1: {},
server2: {},
},
});
const mockPlugin1 = {
name: 'Tool 1',
pluginKey: 'tool1~~~server1',
description: 'Tool 1',
};
const mockPlugin2 = {
name: 'Tool 2',
pluginKey: 'tool2~~~server2',
description: 'Tool 2',
};
convertMCPToolToPlugin.mockReturnValueOnce(mockPlugin1).mockReturnValueOnce(mockPlugin2);
await getMCPTools(mockReq, mockRes);
expect(getMCPServerTools).toHaveBeenCalledWith('server1');
expect(getMCPServerTools).toHaveBeenCalledWith('server2');
expect(mockMCPManager.getAllToolFunctions).toHaveBeenCalledTimes(2);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([
{
...mockPlugin1,
authConfig: [],
authenticated: true,
},
{
...mockPlugin2,
authConfig: [],
authenticated: true,
},
]);
});
it('should handle server-specific errors gracefully', async () => {
getMCPServerTools.mockResolvedValue(null);
mockMCPManager.getAllToolFunctions.mockRejectedValue(new Error('Server connection failed'));
getAppConfig.mockResolvedValue({
mcpConfig: {
server1: {},
server2: {},
},
});
await getMCPTools(mockReq, mockRes);
const { logger } = require('@librechat/data-schemas');
expect(logger.error).toHaveBeenCalledWith(
'[getMCPTools] Error loading tools for server server1:',
expect.any(Error),
);
expect(logger.error).toHaveBeenCalledWith(
'[getMCPTools] Error loading tools for server server2:',
expect.any(Error),
);
// Should still return 200 with empty array
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should skip tools when convertMCPToolToPlugin returns null', async () => {
getMCPServerTools.mockResolvedValue({
'tool1~~~server1': {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
'tool2~~~server1': {
type: 'function',
function: {
name: 'tool2',
description: 'Tool 2',
parameters: {},
},
},
});
getAppConfig.mockResolvedValue({
mcpConfig: {
server1: {},
},
});
const mockPlugin = {
name: 'Tool 1',
pluginKey: 'tool1~~~server1',
description: 'Tool 1',
};
// First tool returns plugin, second returns null
convertMCPToolToPlugin.mockReturnValueOnce(mockPlugin).mockReturnValueOnce(null);
await getMCPTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([
{
...mockPlugin,
authConfig: [],
authenticated: true,
},
]);
});
it('should use req.config when available', async () => {
const reqConfig = {
mcpConfig: {
server1: {},
},
};
mockReq.config = reqConfig;
getMCPServerTools.mockResolvedValue({
'tool1~~~server1': {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
});
const mockPlugin = {
name: 'Tool 1',
pluginKey: 'tool1~~~server1',
description: 'Tool 1',
};
convertMCPToolToPlugin.mockReturnValue(mockPlugin);
await getMCPTools(mockReq, mockRes);
// Should not call getAppConfig when req.config is available
expect(getAppConfig).not.toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([
{
...mockPlugin,
authConfig: [],
authenticated: true,
},
]);
});
it('should handle general error in getMCPTools', async () => {
const error = new Error('Unexpected error');
getAppConfig.mockRejectedValue(error);
await getMCPTools(mockReq, mockRes);
const { logger } = require('@librechat/data-schemas');
expect(logger.error).toHaveBeenCalledWith('[getMCPTools]', error);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Unexpected error' });
});
it('should handle custom user variables without title or description', async () => {
getMCPServerTools.mockResolvedValue({
'tool1~~~server1': {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
});
getAppConfig.mockResolvedValue({
mcpConfig: {
server1: {
customUserVars: {
MY_VAR: {
// No title or description
},
},
},
},
});
const mockPlugin = {
name: 'Tool 1',
pluginKey: 'tool1~~~server1',
description: 'Tool 1',
};
convertMCPToolToPlugin.mockReturnValue(mockPlugin);
await getMCPTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([
{
...mockPlugin,
authConfig: [
{
authField: 'MY_VAR',
label: 'MY_VAR', // Falls back to key
description: '', // Empty string
},
],
authenticated: false,
},
]);
});
it('should not cache when no tools are found for a server', async () => {
getMCPServerTools.mockResolvedValue(null);
const allTools = {
'tool1~~~otherserver': {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
};
mockMCPManager.getAllToolFunctions.mockResolvedValue(allTools);
getAppConfig.mockResolvedValue({
mcpConfig: {
server1: {},
},
});
await getMCPTools(mockReq, mockRes);
const { cacheMCPServerTools } = require('~/server/services/Config');
expect(cacheMCPServerTools).not.toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
});
});

View file

@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useMemo } from 'react'; import React, { createContext, useContext, useState, useMemo } from 'react';
import { Constants, EModelEndpoint } from 'librechat-data-provider'; import { EModelEndpoint } from 'librechat-data-provider';
import type { MCP, Action, TPlugin } from 'librechat-data-provider'; import type { MCP, Action, TPlugin } from 'librechat-data-provider';
import type { AgentPanelContextType, MCPServerInfo } from '~/common'; import type { AgentPanelContextType, MCPServerInfo } from '~/common';
import { import {
@ -30,6 +30,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
const [activePanel, setActivePanel] = useState<Panel>(Panel.builder); const [activePanel, setActivePanel] = useState<Panel>(Panel.builder);
const [agent_id, setCurrentAgentId] = useState<string | undefined>(undefined); const [agent_id, setCurrentAgentId] = useState<string | undefined>(undefined);
const { data: startupConfig } = useGetStartupConfig();
const { data: actions } = useGetActionsQuery(EModelEndpoint.agents, { const { data: actions } = useGetActionsQuery(EModelEndpoint.agents, {
enabled: !!agent_id, enabled: !!agent_id,
}); });
@ -38,11 +39,10 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
enabled: !!agent_id, enabled: !!agent_id,
}); });
const { data: mcpTools } = useMCPToolsQuery({ const { data: mcpData } = useMCPToolsQuery({
enabled: !!agent_id, enabled: !!agent_id && startupConfig?.mcpServers != null,
}); });
const { data: startupConfig } = useGetStartupConfig();
const { agentsConfig, endpointsConfig } = useGetAgentsConfig(); const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
const mcpServerNames = useMemo( const mcpServerNames = useMemo(
() => Object.keys(startupConfig?.mcpServers ?? {}), () => Object.keys(startupConfig?.mcpServers ?? {}),
@ -57,34 +57,35 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
const configuredServers = new Set(mcpServerNames); const configuredServers = new Set(mcpServerNames);
const serversMap = new Map<string, MCPServerInfo>(); const serversMap = new Map<string, MCPServerInfo>();
if (mcpTools) { if (mcpData?.servers) {
for (const pluginTool of mcpTools) { for (const [serverName, serverData] of Object.entries(mcpData.servers)) {
if (pluginTool.pluginKey.includes(Constants.mcp_delimiter)) {
const [_toolName, serverName] = pluginTool.pluginKey.split(Constants.mcp_delimiter);
if (!serversMap.has(serverName)) {
const metadata = { const metadata = {
name: serverName, name: serverName,
pluginKey: serverName, pluginKey: serverName,
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`, description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
icon: pluginTool.icon || '', icon: serverData.icon || '',
authConfig: serverData.authConfig,
authenticated: serverData.authenticated,
} as TPlugin; } as TPlugin;
const tools = serverData.tools.map((tool) => ({
tool_id: tool.pluginKey,
metadata: {
...tool,
icon: serverData.icon,
authConfig: serverData.authConfig,
authenticated: serverData.authenticated,
} as TPlugin,
}));
serversMap.set(serverName, { serversMap.set(serverName, {
serverName, serverName,
tools: [], tools,
isConfigured: configuredServers.has(serverName), isConfigured: configuredServers.has(serverName),
isConnected: connectionStatus?.[serverName]?.connectionState === 'connected', isConnected: connectionStatus?.[serverName]?.connectionState === 'connected',
metadata, metadata,
}); });
} }
serversMap.get(serverName)!.tools.push({
tool_id: pluginTool.pluginKey,
metadata: pluginTool as TPlugin,
});
}
}
} }
// Add configured servers that don't have tools yet // Add configured servers that don't have tools yet
@ -109,7 +110,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
} }
return serversMap; return serversMap;
}, [mcpTools, localize, mcpServerNames, connectionStatus]); }, [mcpData, localize, mcpServerNames, connectionStatus]);
const value: AgentPanelContextType = { const value: AgentPanelContextType = {
mcp, mcp,
@ -120,7 +121,6 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
setMcps, setMcps,
agent_id, agent_id,
setAction, setAction,
mcpTools,
activePanel, activePanel,
regularTools, regularTools,
agentsConfig, agentsConfig,

View file

@ -234,7 +234,6 @@ export type AgentPanelContextType = {
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>; setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
activePanel?: string; activePanel?: string;
regularTools?: t.TPlugin[]; regularTools?: t.TPlugin[];
mcpTools?: t.TPlugin[];
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>; setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>; setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
agent_id?: string; agent_id?: string;

View file

@ -473,6 +473,7 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
setIsOpen={setShowToolDialog} setIsOpen={setShowToolDialog}
endpoint={EModelEndpoint.agents} endpoint={EModelEndpoint.agents}
/> />
{startupConfig?.mcpServers != null && (
<MCPToolSelectDialog <MCPToolSelectDialog
agentId={agent_id} agentId={agent_id}
isOpen={showMCPToolDialog} isOpen={showMCPToolDialog}
@ -480,6 +481,7 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
setIsOpen={setShowMCPToolDialog} setIsOpen={setShowMCPToolDialog}
endpoint={EModelEndpoint.agents} endpoint={EModelEndpoint.agents}
/> />
)}
</> </>
); );
} }

View file

@ -8,9 +8,9 @@ import type { TError, AgentToolType } from 'librechat-data-provider';
import type { AgentForm, TPluginStoreDialogProps } from '~/common'; import type { AgentForm, TPluginStoreDialogProps } from '~/common';
import { useLocalize, usePluginDialogHelpers, useMCPServerManager } from '~/hooks'; import { useLocalize, usePluginDialogHelpers, useMCPServerManager } from '~/hooks';
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection'; import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
import { useGetStartupConfig, useMCPToolsQuery } from '~/data-provider';
import { PluginPagination } from '~/components/Plugins/Store'; import { PluginPagination } from '~/components/Plugins/Store';
import { useAgentPanelContext } from '~/Providers'; import { useAgentPanelContext } from '~/Providers';
import { useMCPToolsQuery } from '~/data-provider';
import MCPToolItem from './MCPToolItem'; import MCPToolItem from './MCPToolItem';
function MCPToolSelectDialog({ function MCPToolSelectDialog({
@ -24,11 +24,12 @@ function MCPToolSelectDialog({
endpoint: EModelEndpoint.agents; endpoint: EModelEndpoint.agents;
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const { mcpServersMap } = useAgentPanelContext();
const { initializeServer } = useMCPServerManager(); const { initializeServer } = useMCPServerManager();
const { data: startupConfig } = useGetStartupConfig();
const { refetch: refetchMCPTools } = useMCPToolsQuery();
const { getValues, setValue } = useFormContext<AgentForm>(); const { getValues, setValue } = useFormContext<AgentForm>();
const { mcpServersMap, startupConfig } = useAgentPanelContext();
const { refetch: refetchMCPTools } = useMCPToolsQuery({
enabled: mcpServersMap.size > 0,
});
const [isInitializing, setIsInitializing] = useState<string | null>(null); const [isInitializing, setIsInitializing] = useState<string | null>(null);
const [configuringServer, setConfiguringServer] = useState<string | null>(null); const [configuringServer, setConfiguringServer] = useState<string | null>(null);
@ -90,18 +91,17 @@ function MCPToolSelectDialog({
setIsInitializing(null); setIsInitializing(null);
}, },
onSuccess: async () => { onSuccess: async () => {
const { data: updatedMCPTools } = await refetchMCPTools(); const { data: updatedMCPData } = await refetchMCPTools();
const currentTools = getValues('tools') || []; const currentTools = getValues('tools') || [];
const toolsToAdd: string[] = [ const toolsToAdd: string[] = [
`${Constants.mcp_server}${Constants.mcp_delimiter}${serverName}`, `${Constants.mcp_server}${Constants.mcp_delimiter}${serverName}`,
]; ];
if (updatedMCPTools) { if (updatedMCPData?.servers?.[serverName]) {
updatedMCPTools.forEach((tool) => { const serverData = updatedMCPData.servers[serverName];
if (tool.pluginKey.endsWith(`${Constants.mcp_delimiter}${serverName}`)) { serverData.tools.forEach((tool) => {
toolsToAdd.push(tool.pluginKey); toolsToAdd.push(tool.pluginKey);
}
}); });
} }

View file

@ -5,17 +5,17 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider'; import { QueryKeys, dataService } from 'librechat-data-provider';
import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query'; import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
import type { TPlugin } from 'librechat-data-provider'; import type { MCPServersResponse } from 'librechat-data-provider';
/** /**
* Hook for fetching MCP-specific tools * Hook for fetching MCP-specific tools
* @param config - React Query configuration * @param config - React Query configuration
* @returns MCP tools grouped by server * @returns MCP servers with their tools
*/ */
export const useMCPToolsQuery = <TData = TPlugin[]>( export const useMCPToolsQuery = <TData = MCPServersResponse>(
config?: UseQueryOptions<TPlugin[], unknown, TData>, config?: UseQueryOptions<MCPServersResponse, unknown, TData>,
): QueryObserverResult<TData> => { ): QueryObserverResult<TData> => {
return useQuery<TPlugin[], unknown, TData>( return useQuery<MCPServersResponse, unknown, TData>(
[QueryKeys.mcpTools], [QueryKeys.mcpTools],
() => dataService.getMCPTools(), () => dataService.getMCPTools(),
{ {

View file

@ -7,6 +7,7 @@ import type { TStartupConfig, TPlugin, TUser } from 'librechat-data-provider';
import { mapPlugins, selectPlugins, processPlugins } from '~/utils'; import { mapPlugins, selectPlugins, processPlugins } from '~/utils';
import { cleanupTimestampedStorage } from '~/utils/timestamps'; import { cleanupTimestampedStorage } from '~/utils/timestamps';
import useSpeechSettingsInit from './useSpeechSettingsInit'; import useSpeechSettingsInit from './useSpeechSettingsInit';
import { useMCPToolsQuery } from '~/data-provider';
import store from '~/store'; import store from '~/store';
const pluginStore: TPlugin = { const pluginStore: TPlugin = {
@ -35,6 +36,10 @@ export default function useAppStartup({
useSpeechSettingsInit(!!user); useSpeechSettingsInit(!!user);
useMCPToolsQuery({
enabled: !!startupConfig?.mcpServers && !!user,
});
/** Clean up old localStorage entries on startup */ /** Clean up old localStorage entries on startup */
useEffect(() => { useEffect(() => {
cleanupTimestampedStorage(); cleanupTimestampedStorage();

View file

@ -1,4 +1,3 @@
export * from './useGetMCPTools';
export * from './useMCPConnectionStatus'; export * from './useMCPConnectionStatus';
export * from './useMCPSelect'; export * from './useMCPSelect';
export * from './useVisibleTools'; export * from './useVisibleTools';

View file

@ -1,48 +0,0 @@
import { useMemo } from 'react';
import { Constants } from 'librechat-data-provider';
import type { TPlugin } from 'librechat-data-provider';
import { useMCPToolsQuery, useGetStartupConfig } from '~/data-provider';
/**
* Hook for fetching and filtering MCP tools based on server configuration
* Uses the dedicated MCP tools query instead of filtering from general tools
*/
export function useGetMCPTools() {
const { data: startupConfig } = useGetStartupConfig();
// Use dedicated MCP tools query
const { data: rawMcpTools } = useMCPToolsQuery({
select: (data: TPlugin[]) => {
// Group tools by server for easier management
const mcpToolsMap = new Map<string, TPlugin>();
data.forEach((tool) => {
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
if (!mcpToolsMap.has(serverName)) {
mcpToolsMap.set(serverName, {
name: serverName,
pluginKey: tool.pluginKey,
authConfig: tool.authConfig,
authenticated: tool.authenticated,
});
}
});
return Array.from(mcpToolsMap.values());
},
});
// Filter out servers that have chatMenu disabled
const mcpToolDetails = useMemo(() => {
if (!rawMcpTools || !startupConfig?.mcpServers) {
return rawMcpTools;
}
return rawMcpTools.filter((tool) => {
const serverConfig = startupConfig?.mcpServers?.[tool.name];
return serverConfig?.chatMenu !== false;
});
}, [rawMcpTools, startupConfig?.mcpServers]);
return {
mcpToolDetails,
};
}

View file

@ -7,9 +7,9 @@ import {
useUpdateUserPluginsMutation, useUpdateUserPluginsMutation,
useReinitializeMCPServerMutation, useReinitializeMCPServerMutation,
} from 'librechat-data-provider/react-query'; } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider'; import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider';
import type { ConfigFieldDetail } from '~/common'; import type { ConfigFieldDetail } from '~/common';
import { useLocalize, useMCPSelect, useGetMCPTools, useMCPConnectionStatus } from '~/hooks'; import { useLocalize, useMCPSelect, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
interface ServerState { interface ServerState {
@ -24,7 +24,6 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const localize = useLocalize(); const localize = useLocalize();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const { mcpToolDetails } = useGetMCPTools();
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig();
const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId }); const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId });
@ -448,7 +447,10 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const getServerStatusIconProps = useCallback( const getServerStatusIconProps = useCallback(
(serverName: string) => { (serverName: string) => {
const tool = mcpToolDetails?.find((t) => t.name === serverName); const mcpData = queryClient.getQueryData<MCPServersResponse | undefined>([
QueryKeys.mcpTools,
]);
const serverData = mcpData?.servers?.[serverName];
const serverStatus = connectionStatus?.[serverName]; const serverStatus = connectionStatus?.[serverName];
const serverConfig = startupConfig?.mcpServers?.[serverName]; const serverConfig = startupConfig?.mcpServers?.[serverName];
@ -458,17 +460,20 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
previousFocusRef.current = document.activeElement as HTMLElement; previousFocusRef.current = document.activeElement as HTMLElement;
const configTool = tool || { /** Minimal TPlugin object for the config dialog */
const configTool: TPlugin = {
name: serverName, name: serverName,
pluginKey: `${Constants.mcp_prefix}${serverName}`, pluginKey: `${Constants.mcp_prefix}${serverName}`,
authConfig: serverConfig?.customUserVars authConfig:
serverData?.authConfig ||
(serverConfig?.customUserVars
? Object.entries(serverConfig.customUserVars).map(([key, config]) => ({ ? Object.entries(serverConfig.customUserVars).map(([key, config]) => ({
authField: key, authField: key,
label: config.title, label: config.title,
description: config.description, description: config.description,
})) }))
: [], : []),
authenticated: false, authenticated: serverData?.authenticated ?? false,
}; };
setSelectedToolForConfig(configTool); setSelectedToolForConfig(configTool);
setIsConfigModalOpen(true); setIsConfigModalOpen(true);
@ -486,7 +491,14 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
return { return {
serverName, serverName,
serverStatus, serverStatus,
tool, tool: serverData
? ({
name: serverName,
pluginKey: `${Constants.mcp_prefix}${serverName}`,
icon: serverData.icon,
authenticated: serverData.authenticated,
} as TPlugin)
: undefined,
onConfigClick: handleConfigClick, onConfigClick: handleConfigClick,
isInitializing: isInitializing(serverName), isInitializing: isInitializing(serverName),
canCancel: isCancellable(serverName), canCancel: isCancellable(serverName),
@ -495,8 +507,8 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
}; };
}, },
[ [
queryClient,
isCancellable, isCancellable,
mcpToolDetails,
isInitializing, isInitializing,
cancelOAuthFlow, cancelOAuthFlow,
connectionStatus, connectionStatus,

View file

@ -13,7 +13,6 @@ import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
import { formatToolContent } from './parsers'; import { formatToolContent } from './parsers';
import { MCPConnection } from './connection'; import { MCPConnection } from './connection';
import { processMCPEnv } from '~/utils/env'; import { processMCPEnv } from '~/utils/env';
import { CONSTANTS } from './enum';
/** /**
* Centralized manager for MCP server connections and tool execution. * Centralized manager for MCP server connections and tool execution.
@ -78,6 +77,28 @@ export class MCPManager extends UserConnectionManager {
return allToolFunctions; 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 * Get instructions for MCP servers
@ -121,72 +142,6 @@ ${formattedInstructions}
Please follow these instructions when using tools from the respective MCP servers.`; 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 * 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 * (if userId is provided) or an app-level connection. Updates the last activity timestamp

View file

@ -2,6 +2,7 @@ import pick from 'lodash/pick';
import pickBy from 'lodash/pickBy'; import pickBy from 'lodash/pickBy';
import mapValues from 'lodash/mapValues'; import mapValues from 'lodash/mapValues';
import { logger } from '@librechat/data-schemas'; import { logger } from '@librechat/data-schemas';
import { Constants } from 'librechat-data-provider';
import type { MCPConnection } from '~/mcp/connection'; import type { MCPConnection } from '~/mcp/connection';
import type { JsonSchemaType } from '~/types'; import type { JsonSchemaType } from '~/types';
import type * as t from '~/mcp/types'; import type * as t from '~/mcp/types';
@ -9,7 +10,6 @@ import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
import { detectOAuthRequirement } from '~/mcp/oauth'; import { detectOAuthRequirement } from '~/mcp/oauth';
import { sanitizeUrlForLogging } from '~/mcp/utils'; import { sanitizeUrlForLogging } from '~/mcp/utils';
import { processMCPEnv, isEnabled } from '~/utils'; import { processMCPEnv, isEnabled } from '~/utils';
import { CONSTANTS } from '~/mcp/enum';
/** /**
* Manages MCP server configurations and metadata discovery. * Manages MCP server configurations and metadata discovery.
@ -127,7 +127,7 @@ export class MCPServersRegistry {
const toolFunctions: t.LCAvailableTools = {}; const toolFunctions: t.LCAvailableTools = {};
tools.forEach((tool) => { tools.forEach((tool) => {
const name = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`; const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
toolFunctions[name] = { toolFunctions[name] = {
type: 'function', type: 'function',
['function']: { ['function']: {

View file

@ -1,5 +1,4 @@
export enum CONSTANTS { export enum CONSTANTS {
mcp_delimiter = '_mcp_',
/** System user ID for app-level OAuth tokens (all zeros ObjectId) */ /** System user ID for app-level OAuth tokens (all zeros ObjectId) */
SYSTEM_USER_ID = '000000000000000000000000', SYSTEM_USER_ID = '000000000000000000000000',
} }

View file

@ -1,12 +1,6 @@
import { AuthType, Constants, EToolResources } from 'librechat-data-provider'; import { AuthType, EToolResources } from 'librechat-data-provider';
import type { TPlugin, FunctionTool } from 'librechat-data-provider'; import type { TPlugin } from 'librechat-data-provider';
import type { MCPManager } from '~/mcp/MCPManager'; import { filterUniquePlugins, checkPluginAuth, getToolkitKey } from './format';
import {
convertMCPToolsToPlugins,
filterUniquePlugins,
checkPluginAuth,
getToolkitKey,
} from './format';
describe('format.ts helper functions', () => { describe('format.ts helper functions', () => {
describe('filterUniquePlugins', () => { 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', () => { describe('getToolkitKey', () => {
it('should return undefined when toolName is undefined', () => { it('should return undefined when toolName is undefined', () => {
const toolkits: TPlugin[] = [ const toolkits: TPlugin[] = [

View file

@ -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 { 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. * 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 toolkits
* @param toolName * @param toolName

View file

@ -299,7 +299,7 @@ export const getAvailableTools = (
/* MCP Tools - Decoupled from regular tools */ /* MCP Tools - Decoupled from regular tools */
export const getMCPTools = (): Promise<s.TPlugin[]> => { export const getMCPTools = (): Promise<q.MCPServersResponse> => {
return request.get(endpoints.mcp.tools); return request.get(endpoints.mcp.tools);
}; };

View file

@ -101,6 +101,25 @@ export type AllPromptGroupsResponse = t.TPromptGroup[];
export type ConversationTagsResponse = s.TConversationTag[]; export type ConversationTagsResponse = s.TConversationTag[];
/* MCP Types */
export type MCPTool = {
name: string;
pluginKey: string;
description: string;
};
export type MCPServer = {
name: string;
icon: string;
authenticated: boolean;
authConfig: s.TPluginAuthConfig[];
tools: MCPTool[];
};
export type MCPServersResponse = {
servers: Record<string, MCPServer>;
};
export type VerifyToolAuthParams = { toolId: string }; export type VerifyToolAuthParams = { toolId: string };
export type VerifyToolAuthResponse = { export type VerifyToolAuthResponse = {
authenticated: boolean; authenticated: boolean;