mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🧰 refactor: Decouple MCP Tools from System Tools (#9748)
This commit is contained in:
parent
9d2aba5df5
commit
386900fb4f
29 changed files with 1032 additions and 1195 deletions
|
|
@ -1,16 +1,9 @@
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
const { CacheKeys } = require('librechat-data-provider');
|
||||||
const {
|
const { getToolkitKey, checkPluginAuth, filterUniquePlugins } = require('@librechat/api');
|
||||||
getToolkitKey,
|
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
||||||
checkPluginAuth,
|
|
||||||
filterUniquePlugins,
|
|
||||||
convertMCPToolToPlugin,
|
|
||||||
convertMCPToolsToPlugins,
|
|
||||||
} = require('@librechat/api');
|
|
||||||
const { getCachedTools, setCachedTools, mergeUserTools } = require('~/server/services/Config');
|
|
||||||
const { availableTools, toolkits } = require('~/app/clients/tools');
|
const { availableTools, toolkits } = require('~/app/clients/tools');
|
||||||
const { getAppConfig } = require('~/server/services/Config');
|
const { getAppConfig } = require('~/server/services/Config');
|
||||||
const { getMCPManager } = require('~/config');
|
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
const getAvailablePluginsController = async (req, res) => {
|
const getAvailablePluginsController = async (req, res) => {
|
||||||
|
|
@ -72,69 +65,27 @@ const getAvailableTools = async (req, res) => {
|
||||||
}
|
}
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
|
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
|
||||||
const cachedUserTools = await getCachedTools({ userId });
|
|
||||||
|
|
||||||
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
|
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
|
||||||
|
|
||||||
/** @type {TPlugin[]} */
|
// Return early if we have cached tools
|
||||||
let mcpPlugins;
|
if (cachedToolsArray != null) {
|
||||||
if (appConfig?.mcpConfig) {
|
res.status(200).json(cachedToolsArray);
|
||||||
const mcpManager = getMCPManager();
|
|
||||||
mcpPlugins =
|
|
||||||
cachedUserTools != null
|
|
||||||
? convertMCPToolsToPlugins({ functionTools: cachedUserTools, mcpManager })
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
cachedToolsArray != null &&
|
|
||||||
(appConfig?.mcpConfig != null ? mcpPlugins != null && mcpPlugins.length > 0 : true)
|
|
||||||
) {
|
|
||||||
const dedupedTools = filterUniquePlugins([...(mcpPlugins ?? []), ...cachedToolsArray]);
|
|
||||||
res.status(200).json(dedupedTools);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {Record<string, FunctionTool> | null} Get tool definitions to filter which tools are actually available */
|
/** @type {Record<string, FunctionTool> | null} Get tool definitions to filter which tools are actually available */
|
||||||
let toolDefinitions = await getCachedTools({ includeGlobal: true });
|
let toolDefinitions = await getCachedTools();
|
||||||
let prelimCachedTools;
|
|
||||||
|
|
||||||
if (toolDefinitions == null && appConfig?.availableTools != null) {
|
if (toolDefinitions == null && appConfig?.availableTools != null) {
|
||||||
logger.warn('[getAvailableTools] Tool cache was empty, re-initializing from app config');
|
logger.warn('[getAvailableTools] Tool cache was empty, re-initializing from app config');
|
||||||
await setCachedTools(appConfig.availableTools, { isGlobal: true });
|
await setCachedTools(appConfig.availableTools);
|
||||||
toolDefinitions = appConfig.availableTools;
|
toolDefinitions = appConfig.availableTools;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {import('@librechat/api').LCManifestTool[]} */
|
/** @type {import('@librechat/api').LCManifestTool[]} */
|
||||||
let pluginManifest = availableTools;
|
let pluginManifest = availableTools;
|
||||||
|
|
||||||
if (appConfig?.mcpConfig != null) {
|
|
||||||
try {
|
|
||||||
const mcpManager = getMCPManager();
|
|
||||||
const mcpTools = await mcpManager.getAllToolFunctions(userId);
|
|
||||||
prelimCachedTools = prelimCachedTools ?? {};
|
|
||||||
for (const [toolKey, toolData] of Object.entries(mcpTools)) {
|
|
||||||
const plugin = convertMCPToolToPlugin({
|
|
||||||
toolKey,
|
|
||||||
toolData,
|
|
||||||
mcpManager,
|
|
||||||
});
|
|
||||||
if (plugin) {
|
|
||||||
pluginManifest.push(plugin);
|
|
||||||
}
|
|
||||||
prelimCachedTools[toolKey] = toolData;
|
|
||||||
}
|
|
||||||
await mergeUserTools({ userId, cachedUserTools, userTools: prelimCachedTools });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'[getAvailableTools] Error loading MCP Tools, servers may still be initializing:',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (prelimCachedTools != null) {
|
|
||||||
await setCachedTools(prelimCachedTools, { isGlobal: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {TPlugin[]} Deduplicate and authenticate plugins */
|
/** @type {TPlugin[]} Deduplicate and authenticate plugins */
|
||||||
const uniquePlugins = filterUniquePlugins(pluginManifest);
|
const uniquePlugins = filterUniquePlugins(pluginManifest);
|
||||||
const authenticatedPlugins = uniquePlugins.map((plugin) => {
|
const authenticatedPlugins = uniquePlugins.map((plugin) => {
|
||||||
|
|
@ -145,7 +96,7 @@ const getAvailableTools = async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Filter plugins based on availability and add MCP-specific auth config */
|
/** Filter plugins based on availability */
|
||||||
const toolsOutput = [];
|
const toolsOutput = [];
|
||||||
for (const plugin of authenticatedPlugins) {
|
for (const plugin of authenticatedPlugins) {
|
||||||
const isToolDefined = toolDefinitions?.[plugin.pluginKey] !== undefined;
|
const isToolDefined = toolDefinitions?.[plugin.pluginKey] !== undefined;
|
||||||
|
|
@ -159,39 +110,13 @@ const getAvailableTools = async (req, res) => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolToAdd = { ...plugin };
|
toolsOutput.push(plugin);
|
||||||
|
|
||||||
if (plugin.pluginKey.includes(Constants.mcp_delimiter)) {
|
|
||||||
const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
|
|
||||||
const serverName = parts[parts.length - 1];
|
|
||||||
const serverConfig = appConfig?.mcpConfig?.[serverName];
|
|
||||||
|
|
||||||
if (serverConfig?.customUserVars) {
|
|
||||||
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
|
||||||
if (customVarKeys.length === 0) {
|
|
||||||
toolToAdd.authConfig = [];
|
|
||||||
toolToAdd.authenticated = true;
|
|
||||||
} else {
|
|
||||||
toolToAdd.authConfig = Object.entries(serverConfig.customUserVars).map(
|
|
||||||
([key, value]) => ({
|
|
||||||
authField: key,
|
|
||||||
label: value.title || key,
|
|
||||||
description: value.description || '',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
toolToAdd.authenticated = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toolsOutput.push(toolToAdd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalTools = filterUniquePlugins(toolsOutput);
|
const finalTools = filterUniquePlugins(toolsOutput);
|
||||||
await cache.set(CacheKeys.TOOLS, finalTools);
|
await cache.set(CacheKeys.TOOLS, finalTools);
|
||||||
|
|
||||||
const dedupedTools = filterUniquePlugins([...(mcpPlugins ?? []), ...finalTools]);
|
res.status(200).json(finalTools);
|
||||||
res.status(200).json(dedupedTools);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getAvailableTools]', error);
|
logger.error('[getAvailableTools]', error);
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
const { Constants } = require('librechat-data-provider');
|
|
||||||
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
|
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
|
|
@ -17,18 +16,10 @@ jest.mock('~/server/services/Config', () => ({
|
||||||
includedTools: [],
|
includedTools: [],
|
||||||
}),
|
}),
|
||||||
setCachedTools: jest.fn(),
|
setCachedTools: jest.fn(),
|
||||||
mergeUserTools: jest.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// loadAndFormatTools mock removed - no longer used in PluginController
|
// loadAndFormatTools mock removed - no longer used in PluginController
|
||||||
|
// getMCPManager mock removed - no longer used in PluginController
|
||||||
jest.mock('~/config', () => ({
|
|
||||||
getMCPManager: jest.fn(() => ({
|
|
||||||
getAllToolFunctions: jest.fn().mockResolvedValue({}),
|
|
||||||
getRawConfig: jest.fn().mockReturnValue({}),
|
|
||||||
})),
|
|
||||||
getFlowStateManager: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/app/clients/tools', () => ({
|
jest.mock('~/app/clients/tools', () => ({
|
||||||
availableTools: [],
|
availableTools: [],
|
||||||
|
|
@ -159,52 +150,6 @@ describe('PluginController', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAvailableTools', () => {
|
describe('getAvailableTools', () => {
|
||||||
it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => {
|
|
||||||
const mockUserTools = {
|
|
||||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: `tool1${Constants.mcp_delimiter}server1`,
|
|
||||||
description: 'Tool 1',
|
|
||||||
parameters: { type: 'object', properties: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
|
||||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
|
||||||
mockReq.config = {
|
|
||||||
mcpConfig: {
|
|
||||||
server1: {},
|
|
||||||
},
|
|
||||||
paths: { structuredTools: '/mock/path' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock MCP manager to return empty tools initially (since getAllToolFunctions is called)
|
|
||||||
const mockMCPManager = {
|
|
||||||
getAllToolFunctions: jest.fn().mockResolvedValue({}),
|
|
||||||
getRawConfig: jest.fn().mockReturnValue({}),
|
|
||||||
};
|
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
|
||||||
|
|
||||||
// Mock second call to return tool definitions (includeGlobal: true)
|
|
||||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
|
||||||
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
||||||
const responseData = mockRes.json.mock.calls[0][0];
|
|
||||||
expect(responseData).toBeDefined();
|
|
||||||
expect(Array.isArray(responseData)).toBe(true);
|
|
||||||
expect(responseData.length).toBeGreaterThan(0);
|
|
||||||
const convertedTool = responseData.find(
|
|
||||||
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}server1`,
|
|
||||||
);
|
|
||||||
expect(convertedTool).toBeDefined();
|
|
||||||
// The real convertMCPToolsToPlugins extracts the name from the delimiter
|
|
||||||
expect(convertedTool.name).toBe('tool1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
|
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
|
||||||
const mockUserTools = {
|
const mockUserTools = {
|
||||||
'user-tool': {
|
'user-tool': {
|
||||||
|
|
@ -229,9 +174,6 @@ describe('PluginController', () => {
|
||||||
paths: { structuredTools: '/mock/path' },
|
paths: { structuredTools: '/mock/path' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock second call to return tool definitions
|
|
||||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
|
|
@ -254,14 +196,7 @@ describe('PluginController', () => {
|
||||||
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
// First call returns null for user tools
|
// getCachedTools returns the tool definitions
|
||||||
getCachedTools.mockResolvedValueOnce(null);
|
|
||||||
mockReq.config = {
|
|
||||||
mcpConfig: null,
|
|
||||||
paths: { structuredTools: '/mock/path' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Second call (with includeGlobal: true) returns the tool definitions
|
|
||||||
getCachedTools.mockResolvedValueOnce({
|
getCachedTools.mockResolvedValueOnce({
|
||||||
tool1: {
|
tool1: {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
|
|
@ -272,6 +207,10 @@ describe('PluginController', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
mockReq.config = {
|
||||||
|
mcpConfig: null,
|
||||||
|
paths: { structuredTools: '/mock/path' },
|
||||||
|
};
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
|
|
@ -302,14 +241,7 @@ describe('PluginController', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
// First call returns null for user tools
|
// getCachedTools returns the tool definitions
|
||||||
getCachedTools.mockResolvedValueOnce(null);
|
|
||||||
mockReq.config = {
|
|
||||||
mcpConfig: null,
|
|
||||||
paths: { structuredTools: '/mock/path' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Second call (with includeGlobal: true) returns the tool definitions
|
|
||||||
getCachedTools.mockResolvedValueOnce({
|
getCachedTools.mockResolvedValueOnce({
|
||||||
toolkit1_function: {
|
toolkit1_function: {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
|
|
@ -320,6 +252,10 @@ describe('PluginController', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
mockReq.config = {
|
||||||
|
mcpConfig: null,
|
||||||
|
paths: { structuredTools: '/mock/path' },
|
||||||
|
};
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
|
|
@ -331,126 +267,7 @@ describe('PluginController', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('plugin.icon behavior', () => {
|
|
||||||
const callGetAvailableToolsWithMCPServer = async (serverConfig) => {
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const functionTools = {
|
|
||||||
[`test-tool${Constants.mcp_delimiter}test-server`]: {
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: `test-tool${Constants.mcp_delimiter}test-server`,
|
|
||||||
description: 'A test tool',
|
|
||||||
parameters: { type: 'object', properties: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock the MCP manager to return tools and server config
|
|
||||||
const mockMCPManager = {
|
|
||||||
getAllToolFunctions: jest.fn().mockResolvedValue(functionTools),
|
|
||||||
getRawConfig: jest.fn().mockReturnValue(serverConfig),
|
|
||||||
};
|
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
|
||||||
|
|
||||||
// First call returns empty user tools
|
|
||||||
getCachedTools.mockResolvedValueOnce({});
|
|
||||||
|
|
||||||
// Mock getAppConfig to return the mcpConfig
|
|
||||||
mockReq.config = {
|
|
||||||
mcpConfig: {
|
|
||||||
'test-server': serverConfig,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Second call (with includeGlobal: true) returns the tool definitions
|
|
||||||
getCachedTools.mockResolvedValueOnce(functionTools);
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
|
||||||
const responseData = mockRes.json.mock.calls[0][0];
|
|
||||||
return responseData.find(
|
|
||||||
(tool) => tool.pluginKey === `test-tool${Constants.mcp_delimiter}test-server`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should set plugin.icon when iconPath is defined', async () => {
|
|
||||||
const serverConfig = {
|
|
||||||
iconPath: '/path/to/icon.png',
|
|
||||||
};
|
|
||||||
const testTool = await callGetAvailableToolsWithMCPServer(serverConfig);
|
|
||||||
expect(testTool.icon).toBe('/path/to/icon.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set plugin.icon to undefined when iconPath is not defined', async () => {
|
|
||||||
const serverConfig = {};
|
|
||||||
const testTool = await callGetAvailableToolsWithMCPServer(serverConfig);
|
|
||||||
expect(testTool.icon).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('helper function integration', () => {
|
describe('helper function integration', () => {
|
||||||
it('should properly handle MCP tools with custom user variables', async () => {
|
|
||||||
const appConfig = {
|
|
||||||
mcpConfig: {
|
|
||||||
'test-server': {
|
|
||||||
customUserVars: {
|
|
||||||
API_KEY: { title: 'API Key', description: 'Your API key' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock MCP tools returned by getAllToolFunctions
|
|
||||||
const mcpToolFunctions = {
|
|
||||||
[`tool1${Constants.mcp_delimiter}test-server`]: {
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: `tool1${Constants.mcp_delimiter}test-server`,
|
|
||||||
description: 'Tool 1',
|
|
||||||
parameters: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock the MCP manager to return tools
|
|
||||||
const mockMCPManager = {
|
|
||||||
getAllToolFunctions: jest.fn().mockResolvedValue(mcpToolFunctions),
|
|
||||||
getRawConfig: jest.fn().mockReturnValue({
|
|
||||||
customUserVars: {
|
|
||||||
API_KEY: { title: 'API Key', description: 'Your API key' },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
|
||||||
mockReq.config = appConfig;
|
|
||||||
|
|
||||||
// First call returns user tools (empty in this case)
|
|
||||||
getCachedTools.mockResolvedValueOnce({});
|
|
||||||
|
|
||||||
// Second call (with includeGlobal: true) returns tool definitions including our MCP tool
|
|
||||||
getCachedTools.mockResolvedValueOnce(mcpToolFunctions);
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
|
||||||
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
||||||
const responseData = mockRes.json.mock.calls[0][0];
|
|
||||||
expect(Array.isArray(responseData)).toBe(true);
|
|
||||||
|
|
||||||
// Find the MCP tool in the response
|
|
||||||
const mcpTool = responseData.find(
|
|
||||||
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The actual implementation adds authConfig and sets authenticated to false when customUserVars exist
|
|
||||||
expect(mcpTool).toBeDefined();
|
|
||||||
expect(mcpTool.authConfig).toEqual([
|
|
||||||
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
|
|
||||||
]);
|
|
||||||
expect(mcpTool.authenticated).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle error cases gracefully', async () => {
|
it('should handle error cases gracefully', async () => {
|
||||||
mockCache.get.mockRejectedValue(new Error('Cache error'));
|
mockCache.get.mockRejectedValue(new Error('Cache error'));
|
||||||
|
|
||||||
|
|
@ -472,23 +289,13 @@ describe('PluginController', () => {
|
||||||
|
|
||||||
it('should handle null cachedTools and cachedUserTools', async () => {
|
it('should handle null cachedTools and cachedUserTools', async () => {
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
// First call returns null for user tools
|
// getCachedTools returns empty object instead of null
|
||||||
getCachedTools.mockResolvedValueOnce(null);
|
getCachedTools.mockResolvedValueOnce({});
|
||||||
mockReq.config = {
|
mockReq.config = {
|
||||||
mcpConfig: null,
|
mcpConfig: null,
|
||||||
paths: { structuredTools: '/mock/path' },
|
paths: { structuredTools: '/mock/path' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock MCP manager to return no tools
|
|
||||||
const mockMCPManager = {
|
|
||||||
getAllToolFunctions: jest.fn().mockResolvedValue({}),
|
|
||||||
getRawConfig: jest.fn().mockReturnValue({}),
|
|
||||||
};
|
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
|
||||||
|
|
||||||
// Second call (with includeGlobal: true) returns empty object instead of null
|
|
||||||
getCachedTools.mockResolvedValueOnce({});
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
// Should handle null values gracefully
|
// Should handle null values gracefully
|
||||||
|
|
@ -503,9 +310,9 @@ describe('PluginController', () => {
|
||||||
paths: { structuredTools: '/mock/path' },
|
paths: { structuredTools: '/mock/path' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock getCachedTools to return undefined for both calls
|
// Mock getCachedTools to return undefined
|
||||||
getCachedTools.mockReset();
|
getCachedTools.mockReset();
|
||||||
getCachedTools.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined);
|
getCachedTools.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
|
|
@ -514,51 +321,6 @@ describe('PluginController', () => {
|
||||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle `cachedToolsArray` and `mcpPlugins` both being defined', async () => {
|
|
||||||
const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }];
|
|
||||||
// Use MCP delimiter for the user tool so convertMCPToolsToPlugins works
|
|
||||||
const userTools = {
|
|
||||||
[`user-tool${Constants.mcp_delimiter}server1`]: {
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: `user-tool${Constants.mcp_delimiter}server1`,
|
|
||||||
description: 'User tool',
|
|
||||||
parameters: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(cachedTools);
|
|
||||||
getCachedTools.mockResolvedValueOnce(userTools);
|
|
||||||
mockReq.config = {
|
|
||||||
mcpConfig: {
|
|
||||||
server1: {},
|
|
||||||
},
|
|
||||||
paths: { structuredTools: '/mock/path' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock MCP manager to return empty tools initially
|
|
||||||
const mockMCPManager = {
|
|
||||||
getAllToolFunctions: jest.fn().mockResolvedValue({}),
|
|
||||||
getRawConfig: jest.fn().mockReturnValue({}),
|
|
||||||
};
|
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
|
||||||
|
|
||||||
// The controller expects a second call to getCachedTools
|
|
||||||
getCachedTools.mockResolvedValueOnce({
|
|
||||||
'cached-tool': { type: 'function', function: { name: 'cached-tool' } },
|
|
||||||
[`user-tool${Constants.mcp_delimiter}server1`]:
|
|
||||||
userTools[`user-tool${Constants.mcp_delimiter}server1`],
|
|
||||||
});
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
|
||||||
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
||||||
const responseData = mockRes.json.mock.calls[0][0];
|
|
||||||
// Should have both cached and user tools
|
|
||||||
expect(responseData.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty toolDefinitions object', async () => {
|
it('should handle empty toolDefinitions object', async () => {
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
// Reset getCachedTools to ensure clean state
|
// Reset getCachedTools to ensure clean state
|
||||||
|
|
@ -569,76 +331,12 @@ describe('PluginController', () => {
|
||||||
// Ensure no plugins are available
|
// Ensure no plugins are available
|
||||||
require('~/app/clients/tools').availableTools.length = 0;
|
require('~/app/clients/tools').availableTools.length = 0;
|
||||||
|
|
||||||
// Reset MCP manager to default state
|
|
||||||
const mockMCPManager = {
|
|
||||||
getAllToolFunctions: jest.fn().mockResolvedValue({}),
|
|
||||||
getRawConfig: jest.fn().mockReturnValue({}),
|
|
||||||
};
|
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
// With empty tool definitions, no tools should be in the final output
|
// With empty tool definitions, no tools should be in the final output
|
||||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle MCP tools without customUserVars', async () => {
|
|
||||||
const appConfig = {
|
|
||||||
mcpConfig: {
|
|
||||||
'test-server': {
|
|
||||||
// No customUserVars defined
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockUserTools = {
|
|
||||||
[`tool1${Constants.mcp_delimiter}test-server`]: {
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: `tool1${Constants.mcp_delimiter}test-server`,
|
|
||||||
description: 'Tool 1',
|
|
||||||
parameters: { type: 'object', properties: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock the MCP manager to return the tools
|
|
||||||
const mockMCPManager = {
|
|
||||||
getAllToolFunctions: jest.fn().mockResolvedValue(mockUserTools),
|
|
||||||
getRawConfig: jest.fn().mockReturnValue({
|
|
||||||
// No customUserVars defined
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
|
||||||
mockReq.config = appConfig;
|
|
||||||
// First call returns empty user tools
|
|
||||||
getCachedTools.mockResolvedValueOnce({});
|
|
||||||
|
|
||||||
// Second call (with includeGlobal: true) returns the tool definitions
|
|
||||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
|
||||||
|
|
||||||
// Ensure no plugins in availableTools for clean test
|
|
||||||
require('~/app/clients/tools').availableTools.length = 0;
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
|
||||||
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
||||||
const responseData = mockRes.json.mock.calls[0][0];
|
|
||||||
expect(Array.isArray(responseData)).toBe(true);
|
|
||||||
expect(responseData.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const mcpTool = responseData.find(
|
|
||||||
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mcpTool).toBeDefined();
|
|
||||||
expect(mcpTool.authenticated).toBe(true);
|
|
||||||
// The actual implementation sets authConfig to empty array when no customUserVars
|
|
||||||
expect(mcpTool.authConfig).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle undefined filteredTools and includedTools', async () => {
|
it('should handle undefined filteredTools and includedTools', async () => {
|
||||||
mockReq.config = {};
|
mockReq.config = {};
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
|
|
@ -667,16 +365,13 @@ describe('PluginController', () => {
|
||||||
require('~/app/clients/tools').availableTools.push(mockToolkit);
|
require('~/app/clients/tools').availableTools.push(mockToolkit);
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
// First call returns empty object
|
// getCachedTools returns empty object to avoid null reference error
|
||||||
getCachedTools.mockResolvedValueOnce({});
|
getCachedTools.mockResolvedValueOnce({});
|
||||||
mockReq.config = {
|
mockReq.config = {
|
||||||
mcpConfig: null,
|
mcpConfig: null,
|
||||||
paths: { structuredTools: '/mock/path' },
|
paths: { structuredTools: '/mock/path' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Second call (with includeGlobal: true) returns empty object to avoid null reference error
|
|
||||||
getCachedTools.mockResolvedValueOnce({});
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
// Should handle null toolDefinitions gracefully
|
// Should handle null toolDefinitions gracefully
|
||||||
|
|
@ -697,15 +392,12 @@ describe('PluginController', () => {
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
|
|
||||||
// First call returns null for user tools
|
|
||||||
getCachedTools.mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
mockReq.config = {
|
mockReq.config = {
|
||||||
mcpConfig: null,
|
mcpConfig: null,
|
||||||
paths: { structuredTools: '/mock/path' },
|
paths: { structuredTools: '/mock/path' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// CRITICAL: Second call (with includeGlobal: true) returns undefined
|
// CRITICAL: getCachedTools returns undefined
|
||||||
// This is what causes the bug when trying to access toolDefinitions[plugin.pluginKey]
|
// This is what causes the bug when trying to access toolDefinitions[plugin.pluginKey]
|
||||||
getCachedTools.mockResolvedValueOnce(undefined);
|
getCachedTools.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
|
@ -744,9 +436,8 @@ describe('PluginController', () => {
|
||||||
{ name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' },
|
{ name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' },
|
||||||
);
|
);
|
||||||
|
|
||||||
// First call: Simulate cache cleared state (returns null for both global and user tools)
|
// Simulate cache cleared state (returns null)
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
getCachedTools.mockResolvedValueOnce(null); // User tools
|
|
||||||
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
|
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
|
||||||
|
|
||||||
mockReq.config = {
|
mockReq.config = {
|
||||||
|
|
@ -761,7 +452,7 @@ describe('PluginController', () => {
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
// Should have re-initialized the cache with tools from appConfig
|
// Should have re-initialized the cache with tools from appConfig
|
||||||
expect(setCachedTools).toHaveBeenCalledWith(mockAppTools, { isGlobal: true });
|
expect(setCachedTools).toHaveBeenCalledWith(mockAppTools);
|
||||||
|
|
||||||
// Should still return tools successfully
|
// Should still return tools successfully
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
|
|
@ -784,7 +475,6 @@ describe('PluginController', () => {
|
||||||
|
|
||||||
// Cache returns null (cleared state)
|
// Cache returns null (cleared state)
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
getCachedTools.mockResolvedValueOnce(null); // User tools
|
|
||||||
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
|
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
|
||||||
|
|
||||||
mockReq.config = {
|
mockReq.config = {
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,34 @@
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
webSearchKeys,
|
webSearchKeys,
|
||||||
extractWebSearchEnvVars,
|
MCPOAuthHandler,
|
||||||
normalizeHttpError,
|
|
||||||
MCPTokenStorage,
|
MCPTokenStorage,
|
||||||
|
normalizeHttpError,
|
||||||
|
extractWebSearchEnvVars,
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
getFiles,
|
getFiles,
|
||||||
|
findToken,
|
||||||
updateUser,
|
updateUser,
|
||||||
deleteFiles,
|
deleteFiles,
|
||||||
deleteConvos,
|
deleteConvos,
|
||||||
deletePresets,
|
deletePresets,
|
||||||
deleteMessages,
|
deleteMessages,
|
||||||
deleteUserById,
|
deleteUserById,
|
||||||
|
deleteAllSharedLinks,
|
||||||
deleteAllUserSessions,
|
deleteAllUserSessions,
|
||||||
} = require('~/models');
|
} = require('~/models');
|
||||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||||
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
|
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
|
||||||
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
||||||
|
const { getAppConfig, clearMCPServerTools } = require('~/server/services/Config');
|
||||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||||
const { Tools, Constants, FileSources } = require('librechat-data-provider');
|
|
||||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||||
const { Transaction, Balance, User, Token } = require('~/db/models');
|
const { Transaction, Balance, User, Token } = require('~/db/models');
|
||||||
const { getAppConfig } = require('~/server/services/Config');
|
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||||
const { deleteAllSharedLinks } = require('~/models');
|
|
||||||
const { getMCPManager } = require('~/config');
|
|
||||||
const { MCPOAuthHandler } = require('@librechat/api');
|
|
||||||
const { getFlowStateManager } = require('~/config');
|
|
||||||
const { CacheKeys } = require('librechat-data-provider');
|
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
const { clearMCPServerTools } = require('~/server/services/Config/mcpToolsCache');
|
|
||||||
const { findToken } = require('~/models');
|
|
||||||
|
|
||||||
const getUserController = async (req, res) => {
|
const getUserController = async (req, res) => {
|
||||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||||
|
|
|
||||||
105
api/server/controllers/mcp.js
Normal file
105
api/server/controllers/mcp.js
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* MCP Tools Controller
|
||||||
|
* Handles MCP-specific tool endpoints, decoupled from regular LibreChat tools
|
||||||
|
*/
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { Constants } = require('librechat-data-provider');
|
||||||
|
const { convertMCPToolToPlugin } = require('@librechat/api');
|
||||||
|
const { getAppConfig, getMCPServerTools } = require('~/server/services/Config');
|
||||||
|
const { getMCPManager } = require('~/config');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all MCP tools available to the user
|
||||||
|
* Returns only MCP tools, not regular LibreChat tools
|
||||||
|
*/
|
||||||
|
const getMCPTools = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
logger.warn('[getMCPTools] User ID not found in request');
|
||||||
|
return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
|
||||||
|
if (!appConfig?.mcpConfig) {
|
||||||
|
return res.status(200).json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpManager = getMCPManager();
|
||||||
|
const configuredServers = Object.keys(appConfig.mcpConfig);
|
||||||
|
const mcpTools = [];
|
||||||
|
|
||||||
|
// Fetch tools from each configured server
|
||||||
|
for (const serverName of configuredServers) {
|
||||||
|
try {
|
||||||
|
// First check server-specific cache
|
||||||
|
let serverTools = await getMCPServerTools(serverName);
|
||||||
|
|
||||||
|
if (!serverTools) {
|
||||||
|
// 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) {
|
||||||
|
const { cacheMCPServerTools } = require('~/server/services/Config');
|
||||||
|
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];
|
||||||
|
if (serverConfig?.customUserVars) {
|
||||||
|
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
||||||
|
if (customVarKeys.length === 0) {
|
||||||
|
plugin.authConfig = [];
|
||||||
|
plugin.authenticated = true;
|
||||||
|
} else {
|
||||||
|
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(
|
||||||
|
([key, value]) => ({
|
||||||
|
authField: key,
|
||||||
|
label: value.title || key,
|
||||||
|
description: value.description || '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
plugin.authenticated = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
plugin.authConfig = [];
|
||||||
|
plugin.authenticated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpTools.push(plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[getMCPTools] Error loading tools for server ${serverName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(mcpTools);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[getMCPTools]', error);
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getMCPTools,
|
||||||
|
};
|
||||||
561
api/server/controllers/mcp.spec.js
Normal file
561
api/server/controllers/mcp.spec.js
Normal file
|
|
@ -0,0 +1,561 @@
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -47,8 +47,8 @@ jest.mock('~/server/services/Config', () => ({
|
||||||
loadCustomConfig: jest.fn(),
|
loadCustomConfig: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('~/server/services/Config/mcpToolsCache', () => ({
|
jest.mock('~/server/services/Config/mcp', () => ({
|
||||||
updateMCPUserTools: jest.fn(),
|
updateMCPServerTools: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('~/server/services/MCP', () => ({
|
jest.mock('~/server/services/MCP', () => ({
|
||||||
|
|
@ -778,10 +778,10 @@ describe('MCP Routes', () => {
|
||||||
require('~/cache').getLogStores.mockReturnValue({});
|
require('~/cache').getLogStores.mockReturnValue({});
|
||||||
|
|
||||||
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
||||||
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
|
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
|
||||||
getCachedTools.mockResolvedValue({});
|
getCachedTools.mockResolvedValue({});
|
||||||
setCachedTools.mockResolvedValue();
|
setCachedTools.mockResolvedValue();
|
||||||
updateMCPUserTools.mockResolvedValue();
|
updateMCPServerTools.mockResolvedValue();
|
||||||
|
|
||||||
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
|
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -836,10 +836,10 @@ describe('MCP Routes', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
||||||
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
|
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
|
||||||
getCachedTools.mockResolvedValue({});
|
getCachedTools.mockResolvedValue({});
|
||||||
setCachedTools.mockResolvedValue();
|
setCachedTools.mockResolvedValue();
|
||||||
updateMCPUserTools.mockResolvedValue();
|
updateMCPServerTools.mockResolvedValue();
|
||||||
|
|
||||||
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
|
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,24 @@ const { MCPOAuthHandler, getUserMCPAuthMap } = require('@librechat/api');
|
||||||
const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
|
const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
|
||||||
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
|
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
|
||||||
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
|
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
|
||||||
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
|
|
||||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||||
|
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
|
||||||
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
||||||
|
const { getMCPTools } = require('~/server/controllers/mcp');
|
||||||
const { requireJwtAuth } = require('~/server/middleware');
|
const { requireJwtAuth } = require('~/server/middleware');
|
||||||
const { findPluginAuthsByKeys } = require('~/models');
|
const { findPluginAuthsByKeys } = require('~/models');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all MCP tools available to the user
|
||||||
|
* Returns only MCP tools, completely decoupled from regular LibreChat tools
|
||||||
|
*/
|
||||||
|
router.get('/tools', requireJwtAuth, async (req, res) => {
|
||||||
|
return getMCPTools(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initiate OAuth flow
|
* Initiate OAuth flow
|
||||||
* This endpoint is called when the user clicks the auth link in the UI
|
* This endpoint is called when the user clicks the auth link in the UI
|
||||||
|
|
@ -149,8 +158,7 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||||
oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
|
oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
|
||||||
|
|
||||||
const tools = await userConnection.fetchTools();
|
const tools = await userConnection.fetchTools();
|
||||||
await updateMCPUserTools({
|
await updateMCPServerTools({
|
||||||
userId: flowState.userId,
|
|
||||||
serverName,
|
serverName,
|
||||||
tools,
|
tools,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ async function getAppConfig(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (baseConfig.availableTools) {
|
if (baseConfig.availableTools) {
|
||||||
await setCachedTools(baseConfig.availableTools, { isGlobal: true });
|
await setCachedTools(baseConfig.availableTools);
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.set(BASE_CONFIG_KEY, baseConfig);
|
await cache.set(BASE_CONFIG_KEY, baseConfig);
|
||||||
|
|
|
||||||
|
|
@ -3,89 +3,32 @@ const getLogStores = require('~/cache/getLogStores');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache key generators for different tool access patterns
|
* Cache key generators for different tool access patterns
|
||||||
* These will support future permission-based caching
|
|
||||||
*/
|
*/
|
||||||
const ToolCacheKeys = {
|
const ToolCacheKeys = {
|
||||||
/** Global tools available to all users */
|
/** Global tools available to all users */
|
||||||
GLOBAL: 'tools:global',
|
GLOBAL: 'tools:global',
|
||||||
/** Tools available to a specific user */
|
/** MCP tools cached by server name */
|
||||||
USER: (userId) => `tools:user:${userId}`,
|
MCP_SERVER: (serverName) => `tools:mcp:${serverName}`,
|
||||||
/** Tools available to a specific role */
|
|
||||||
ROLE: (roleId) => `tools:role:${roleId}`,
|
|
||||||
/** Tools available to a specific group */
|
|
||||||
GROUP: (groupId) => `tools:group:${groupId}`,
|
|
||||||
/** Combined effective tools for a user (computed from all sources) */
|
|
||||||
EFFECTIVE: (userId) => `tools:effective:${userId}`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves available tools from cache
|
* Retrieves available tools from cache
|
||||||
* @function getCachedTools
|
* @function getCachedTools
|
||||||
* @param {Object} options - Options for retrieving tools
|
* @param {Object} options - Options for retrieving tools
|
||||||
* @param {string} [options.userId] - User ID for user-specific tools
|
* @param {string} [options.serverName] - MCP server name to get cached tools for
|
||||||
* @param {string[]} [options.roleIds] - Role IDs for role-based tools
|
|
||||||
* @param {string[]} [options.groupIds] - Group IDs for group-based tools
|
|
||||||
* @param {boolean} [options.includeGlobal=true] - Whether to include global tools
|
|
||||||
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
|
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
|
||||||
*/
|
*/
|
||||||
async function getCachedTools(options = {}) {
|
async function getCachedTools(options = {}) {
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
const { userId, roleIds = [], groupIds = [], includeGlobal = true } = options;
|
const { serverName } = options;
|
||||||
|
|
||||||
// For now, return global tools (current behavior)
|
// Return MCP server-specific tools if requested
|
||||||
// This will be expanded to merge tools from different sources
|
if (serverName) {
|
||||||
if (!userId && includeGlobal) {
|
return await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||||
return await cache.get(ToolCacheKeys.GLOBAL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Future implementation will merge tools from multiple sources
|
// Default to global tools
|
||||||
// based on user permissions, roles, and groups
|
return await cache.get(ToolCacheKeys.GLOBAL);
|
||||||
if (userId) {
|
|
||||||
/** @type {LCAvailableTools | null} Check if we have pre-computed effective tools for this user */
|
|
||||||
const effectiveTools = await cache.get(ToolCacheKeys.EFFECTIVE(userId));
|
|
||||||
if (effectiveTools) {
|
|
||||||
return effectiveTools;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {LCAvailableTools | null} Otherwise, compute from individual sources */
|
|
||||||
const toolSources = [];
|
|
||||||
|
|
||||||
if (includeGlobal) {
|
|
||||||
const globalTools = await cache.get(ToolCacheKeys.GLOBAL);
|
|
||||||
if (globalTools) {
|
|
||||||
toolSources.push(globalTools);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User-specific tools
|
|
||||||
const userTools = await cache.get(ToolCacheKeys.USER(userId));
|
|
||||||
if (userTools) {
|
|
||||||
toolSources.push(userTools);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role-based tools
|
|
||||||
for (const roleId of roleIds) {
|
|
||||||
const roleTools = await cache.get(ToolCacheKeys.ROLE(roleId));
|
|
||||||
if (roleTools) {
|
|
||||||
toolSources.push(roleTools);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group-based tools
|
|
||||||
for (const groupId of groupIds) {
|
|
||||||
const groupTools = await cache.get(ToolCacheKeys.GROUP(groupId));
|
|
||||||
if (groupTools) {
|
|
||||||
toolSources.push(groupTools);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge all tool sources (for now, simple merge - future will handle conflicts)
|
|
||||||
if (toolSources.length > 0) {
|
|
||||||
return mergeToolSources(toolSources);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -93,49 +36,34 @@ async function getCachedTools(options = {}) {
|
||||||
* @function setCachedTools
|
* @function setCachedTools
|
||||||
* @param {Object} tools - The tools object to cache
|
* @param {Object} tools - The tools object to cache
|
||||||
* @param {Object} options - Options for caching tools
|
* @param {Object} options - Options for caching tools
|
||||||
* @param {string} [options.userId] - User ID for user-specific tools
|
* @param {string} [options.serverName] - MCP server name for server-specific tools
|
||||||
* @param {string} [options.roleId] - Role ID for role-based tools
|
|
||||||
* @param {string} [options.groupId] - Group ID for group-based tools
|
|
||||||
* @param {boolean} [options.isGlobal=false] - Whether these are global tools
|
|
||||||
* @param {number} [options.ttl] - Time to live in milliseconds
|
* @param {number} [options.ttl] - Time to live in milliseconds
|
||||||
* @returns {Promise<boolean>} Whether the operation was successful
|
* @returns {Promise<boolean>} Whether the operation was successful
|
||||||
*/
|
*/
|
||||||
async function setCachedTools(tools, options = {}) {
|
async function setCachedTools(tools, options = {}) {
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
const { userId, roleId, groupId, isGlobal = false, ttl } = options;
|
const { serverName, ttl } = options;
|
||||||
|
|
||||||
let cacheKey;
|
// Cache by MCP server if specified
|
||||||
if (isGlobal || (!userId && !roleId && !groupId)) {
|
if (serverName) {
|
||||||
cacheKey = ToolCacheKeys.GLOBAL;
|
return await cache.set(ToolCacheKeys.MCP_SERVER(serverName), tools, ttl);
|
||||||
} else if (userId) {
|
|
||||||
cacheKey = ToolCacheKeys.USER(userId);
|
|
||||||
} else if (roleId) {
|
|
||||||
cacheKey = ToolCacheKeys.ROLE(roleId);
|
|
||||||
} else if (groupId) {
|
|
||||||
cacheKey = ToolCacheKeys.GROUP(groupId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cacheKey) {
|
// Default to global cache
|
||||||
throw new Error('Invalid cache key options provided');
|
return await cache.set(ToolCacheKeys.GLOBAL, tools, ttl);
|
||||||
}
|
|
||||||
|
|
||||||
return await cache.set(cacheKey, tools, ttl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidates cached tools
|
* Invalidates cached tools
|
||||||
* @function invalidateCachedTools
|
* @function invalidateCachedTools
|
||||||
* @param {Object} options - Options for invalidating tools
|
* @param {Object} options - Options for invalidating tools
|
||||||
* @param {string} [options.userId] - User ID to invalidate
|
* @param {string} [options.serverName] - MCP server name to invalidate
|
||||||
* @param {string} [options.roleId] - Role ID to invalidate
|
|
||||||
* @param {string} [options.groupId] - Group ID to invalidate
|
|
||||||
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
|
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
|
||||||
* @param {boolean} [options.invalidateEffective=true] - Whether to invalidate effective tools
|
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function invalidateCachedTools(options = {}) {
|
async function invalidateCachedTools(options = {}) {
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
const { userId, roleId, groupId, invalidateGlobal = false, invalidateEffective = true } = options;
|
const { serverName, invalidateGlobal = false } = options;
|
||||||
|
|
||||||
const keysToDelete = [];
|
const keysToDelete = [];
|
||||||
|
|
||||||
|
|
@ -143,116 +71,45 @@ async function invalidateCachedTools(options = {}) {
|
||||||
keysToDelete.push(ToolCacheKeys.GLOBAL);
|
keysToDelete.push(ToolCacheKeys.GLOBAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userId) {
|
if (serverName) {
|
||||||
keysToDelete.push(ToolCacheKeys.USER(userId));
|
keysToDelete.push(ToolCacheKeys.MCP_SERVER(serverName));
|
||||||
if (invalidateEffective) {
|
|
||||||
keysToDelete.push(ToolCacheKeys.EFFECTIVE(userId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roleId) {
|
|
||||||
keysToDelete.push(ToolCacheKeys.ROLE(roleId));
|
|
||||||
// TODO: In future, invalidate all users with this role
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groupId) {
|
|
||||||
keysToDelete.push(ToolCacheKeys.GROUP(groupId));
|
|
||||||
// TODO: In future, invalidate all users in this group
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
|
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes and caches effective tools for a user
|
* Gets MCP tools for a specific server from cache or merges with global tools
|
||||||
* @function computeEffectiveTools
|
* @function getMCPServerTools
|
||||||
* @param {string} userId - The user ID
|
* @param {string} serverName - The MCP server name
|
||||||
* @param {Object} context - Context containing user's roles and groups
|
* @returns {Promise<LCAvailableTools|null>} The available tools for the server
|
||||||
* @param {string[]} [context.roleIds=[]] - User's role IDs
|
|
||||||
* @param {string[]} [context.groupIds=[]] - User's group IDs
|
|
||||||
* @param {number} [ttl] - Time to live for the computed result
|
|
||||||
* @returns {Promise<Object>} The computed effective tools
|
|
||||||
*/
|
*/
|
||||||
async function computeEffectiveTools(userId, context = {}, ttl) {
|
async function getMCPServerTools(serverName) {
|
||||||
const { roleIds = [], groupIds = [] } = context;
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
|
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||||
|
|
||||||
// Get all tool sources
|
if (serverTools) {
|
||||||
const tools = await getCachedTools({
|
return serverTools;
|
||||||
userId,
|
|
||||||
roleIds,
|
|
||||||
groupIds,
|
|
||||||
includeGlobal: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tools) {
|
|
||||||
// Cache the computed result
|
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
|
||||||
await cache.set(ToolCacheKeys.EFFECTIVE(userId), tools, ttl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tools;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges multiple tool sources into a single tools object
|
|
||||||
* @function mergeToolSources
|
|
||||||
* @param {Object[]} sources - Array of tool objects to merge
|
|
||||||
* @returns {Object} Merged tools object
|
|
||||||
*/
|
|
||||||
function mergeToolSources(sources) {
|
|
||||||
// For now, simple merge that combines all tools
|
|
||||||
// Future implementation will handle:
|
|
||||||
// - Permission precedence (deny > allow)
|
|
||||||
// - Tool property conflicts
|
|
||||||
// - Metadata merging
|
|
||||||
const merged = {};
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
if (!source || typeof source !== 'object') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [toolId, toolConfig] of Object.entries(source)) {
|
|
||||||
// Simple last-write-wins for now
|
|
||||||
// Future: merge based on permission levels
|
|
||||||
merged[toolId] = toolConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware-friendly function to get tools for a request
|
* Middleware-friendly function to get tools for a request
|
||||||
* @function getToolsForRequest
|
* @function getToolsForRequest
|
||||||
* @param {Object} req - Express request object
|
* @param {Object} [req] - Express request object
|
||||||
* @returns {Promise<Object|null>} Available tools for the request
|
* @returns {Promise<Object|null>} Available tools for the request
|
||||||
*/
|
*/
|
||||||
async function getToolsForRequest(req) {
|
async function getToolsForRequest(_req) {
|
||||||
const userId = req.user?.id;
|
return getCachedTools();
|
||||||
|
|
||||||
// For now, return global tools if no user
|
|
||||||
if (!userId) {
|
|
||||||
return getCachedTools({ includeGlobal: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Future: Extract roles and groups from req.user
|
|
||||||
const roleIds = req.user?.roles || [];
|
|
||||||
const groupIds = req.user?.groups || [];
|
|
||||||
|
|
||||||
return getCachedTools({
|
|
||||||
userId,
|
|
||||||
roleIds,
|
|
||||||
groupIds,
|
|
||||||
includeGlobal: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ToolCacheKeys,
|
ToolCacheKeys,
|
||||||
getCachedTools,
|
getCachedTools,
|
||||||
setCachedTools,
|
setCachedTools,
|
||||||
|
getMCPServerTools,
|
||||||
getToolsForRequest,
|
getToolsForRequest,
|
||||||
invalidateCachedTools,
|
invalidateCachedTools,
|
||||||
computeEffectiveTools,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const appConfig = require('./app');
|
const appConfig = require('./app');
|
||||||
|
const mcpToolsCache = require('./mcp');
|
||||||
const { config } = require('./EndpointService');
|
const { config } = require('./EndpointService');
|
||||||
const getCachedTools = require('./getCachedTools');
|
const getCachedTools = require('./getCachedTools');
|
||||||
const mcpToolsCache = require('./mcpToolsCache');
|
|
||||||
const loadCustomConfig = require('./loadCustomConfig');
|
const loadCustomConfig = require('./loadCustomConfig');
|
||||||
const loadConfigModels = require('./loadConfigModels');
|
const loadConfigModels = require('./loadConfigModels');
|
||||||
const loadDefaultModels = require('./loadDefaultModels');
|
const loadDefaultModels = require('./loadDefaultModels');
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,20 @@ const { getCachedTools, setCachedTools } = require('./getCachedTools');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates MCP tools in the cache for a specific server and user
|
* Updates MCP tools in the cache for a specific server
|
||||||
* @param {Object} params - Parameters for updating MCP tools
|
* @param {Object} params - Parameters for updating MCP tools
|
||||||
* @param {string} params.userId - User ID
|
|
||||||
* @param {string} params.serverName - MCP server name
|
* @param {string} params.serverName - MCP server name
|
||||||
* @param {Array} params.tools - Array of tool objects from MCP server
|
* @param {Array} params.tools - Array of tool objects from MCP server
|
||||||
* @returns {Promise<LCAvailableTools>}
|
* @returns {Promise<LCAvailableTools>}
|
||||||
*/
|
*/
|
||||||
async function updateMCPUserTools({ userId, serverName, tools }) {
|
async function updateMCPServerTools({ serverName, tools }) {
|
||||||
try {
|
try {
|
||||||
const userTools = await getCachedTools({ userId });
|
const serverTools = {};
|
||||||
|
|
||||||
const mcpDelimiter = Constants.mcp_delimiter;
|
const mcpDelimiter = Constants.mcp_delimiter;
|
||||||
for (const key of Object.keys(userTools)) {
|
|
||||||
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
|
|
||||||
delete userTools[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
|
const name = `${tool.name}${mcpDelimiter}${serverName}`;
|
||||||
userTools[name] = {
|
serverTools[name] = {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
['function']: {
|
['function']: {
|
||||||
name,
|
name,
|
||||||
|
|
@ -34,12 +27,12 @@ async function updateMCPUserTools({ userId, serverName, tools }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await setCachedTools(userTools, { userId });
|
await setCachedTools(serverTools, { serverName });
|
||||||
|
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
await cache.delete(CacheKeys.TOOLS);
|
await cache.delete(CacheKeys.TOOLS);
|
||||||
logger.debug(`[MCP Cache] Updated ${tools.length} tools for ${serverName} user ${userId}`);
|
logger.debug(`[MCP Cache] Updated ${tools.length} tools for server ${serverName}`);
|
||||||
return userTools;
|
return serverTools;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
|
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -57,9 +50,9 @@ async function mergeAppTools(appTools) {
|
||||||
if (!count) {
|
if (!count) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cachedTools = await getCachedTools({ includeGlobal: true });
|
const cachedTools = await getCachedTools();
|
||||||
const mergedTools = { ...cachedTools, ...appTools };
|
const mergedTools = { ...cachedTools, ...appTools };
|
||||||
await setCachedTools(mergedTools, { isGlobal: true });
|
await setCachedTools(mergedTools);
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
await cache.delete(CacheKeys.TOOLS);
|
await cache.delete(CacheKeys.TOOLS);
|
||||||
logger.debug(`Merged ${count} app-level tools`);
|
logger.debug(`Merged ${count} app-level tools`);
|
||||||
|
|
@ -70,30 +63,23 @@ async function mergeAppTools(appTools) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges user-level tools with global tools
|
* Caches MCP server tools (no longer merges with global)
|
||||||
* @param {object} params
|
* @param {object} params
|
||||||
* @param {string} params.userId
|
* @param {string} params.serverName
|
||||||
* @param {Record<string, FunctionTool>} params.cachedUserTools
|
* @param {import('@librechat/api').LCAvailableTools} params.serverTools
|
||||||
* @param {import('@librechat/api').LCAvailableTools} params.userTools
|
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function mergeUserTools({ userId, cachedUserTools, userTools }) {
|
async function cacheMCPServerTools({ serverName, serverTools }) {
|
||||||
try {
|
try {
|
||||||
if (!userId) {
|
const count = Object.keys(serverTools).length;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const count = Object.keys(userTools).length;
|
|
||||||
if (!count) {
|
if (!count) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cachedTools = cachedUserTools ?? (await getCachedTools({ userId }));
|
// Only cache server-specific tools, no merging with global
|
||||||
const mergedTools = { ...cachedTools, ...userTools };
|
await setCachedTools(serverTools, { serverName });
|
||||||
await setCachedTools(mergedTools, { userId });
|
logger.debug(`Cached ${count} MCP server tools for ${serverName}`);
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
|
||||||
await cache.delete(CacheKeys.TOOLS);
|
|
||||||
logger.debug(`Merged ${count} user-level tools`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to merge user-level tools:', error);
|
logger.error(`Failed to cache MCP server tools for ${serverName}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,13 +87,12 @@ async function mergeUserTools({ userId, cachedUserTools, userTools }) {
|
||||||
/**
|
/**
|
||||||
* Clears all MCP tools for a specific server
|
* Clears all MCP tools for a specific server
|
||||||
* @param {Object} params - Parameters for clearing MCP tools
|
* @param {Object} params - Parameters for clearing MCP tools
|
||||||
* @param {string} [params.userId] - User ID (if clearing user-specific tools)
|
|
||||||
* @param {string} params.serverName - MCP server name
|
* @param {string} params.serverName - MCP server name
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function clearMCPServerTools({ userId, serverName }) {
|
async function clearMCPServerTools({ serverName }) {
|
||||||
try {
|
try {
|
||||||
const tools = await getCachedTools({ userId, includeGlobal: !userId });
|
const tools = await getCachedTools();
|
||||||
|
|
||||||
// Remove all tools for this server
|
// Remove all tools for this server
|
||||||
const mcpDelimiter = Constants.mcp_delimiter;
|
const mcpDelimiter = Constants.mcp_delimiter;
|
||||||
|
|
@ -120,14 +105,14 @@ async function clearMCPServerTools({ userId, serverName }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (removedCount > 0) {
|
if (removedCount > 0) {
|
||||||
await setCachedTools(tools, userId ? { userId } : { isGlobal: true });
|
await setCachedTools(tools);
|
||||||
|
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
await cache.delete(CacheKeys.TOOLS);
|
await cache.delete(CacheKeys.TOOLS);
|
||||||
|
// Also clear the server-specific cache
|
||||||
|
await cache.delete(`tools:mcp:${serverName}`);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(`[MCP Cache] Removed ${removedCount} tools for ${serverName} (global)`);
|
||||||
`[MCP Cache] Removed ${removedCount} tools for ${serverName}${userId ? ` user ${userId}` : ' (global)'}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[MCP Cache] Failed to clear tools for ${serverName}:`, error);
|
logger.error(`[MCP Cache] Failed to clear tools for ${serverName}:`, error);
|
||||||
|
|
@ -137,7 +122,7 @@ async function clearMCPServerTools({ userId, serverName }) {
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mergeAppTools,
|
mergeAppTools,
|
||||||
mergeUserTools,
|
cacheMCPServerTools,
|
||||||
updateMCPUserTools,
|
|
||||||
clearMCPServerTools,
|
clearMCPServerTools,
|
||||||
|
updateMCPServerTools,
|
||||||
};
|
};
|
||||||
|
|
@ -2,7 +2,7 @@ const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||||
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
|
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
|
||||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||||
const { updateMCPUserTools } = require('~/server/services/Config');
|
const { updateMCPServerTools } = require('~/server/services/Config');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -97,8 +97,7 @@ async function reinitMCPServer({
|
||||||
|
|
||||||
if (userConnection && !oauthRequired) {
|
if (userConnection && !oauthRequired) {
|
||||||
tools = await userConnection.fetchTools();
|
tools = await userConnection.fetchTools();
|
||||||
availableTools = await updateMCPUserTools({
|
availableTools = await updateMCPServerTools({
|
||||||
userId: req.user.id,
|
|
||||||
serverName,
|
serverName,
|
||||||
tools,
|
tools,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import React, { createContext, useContext, useState, useMemo } from 'react';
|
import React, { createContext, useContext, useState, useMemo } from 'react';
|
||||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||||
import type { MCP, Action, TPlugin, AgentToolType } 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 { useAvailableToolsQuery, useGetActionsQuery, useGetStartupConfig } from '~/data-provider';
|
import {
|
||||||
|
useAvailableToolsQuery,
|
||||||
|
useGetActionsQuery,
|
||||||
|
useGetStartupConfig,
|
||||||
|
useMCPToolsQuery,
|
||||||
|
} from '~/data-provider';
|
||||||
import { useLocalize, useGetAgentsConfig, useMCPConnectionStatus } from '~/hooks';
|
import { useLocalize, useGetAgentsConfig, useMCPConnectionStatus } from '~/hooks';
|
||||||
import { Panel } from '~/common';
|
import { Panel } from '~/common';
|
||||||
|
|
||||||
type GroupedToolType = AgentToolType & { tools?: AgentToolType[] };
|
|
||||||
type GroupedToolsRecord = Record<string, GroupedToolType>;
|
|
||||||
|
|
||||||
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
|
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function useAgentPanelContext() {
|
export function useAgentPanelContext() {
|
||||||
|
|
@ -32,11 +34,16 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||||
enabled: !!agent_id,
|
enabled: !!agent_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: pluginTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||||
|
enabled: !!agent_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: mcpTools } = useMCPToolsQuery({
|
||||||
enabled: !!agent_id,
|
enabled: !!agent_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
|
const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
|
||||||
const mcpServerNames = useMemo(
|
const mcpServerNames = useMemo(
|
||||||
() => Object.keys(startupConfig?.mcpServers ?? {}),
|
() => Object.keys(startupConfig?.mcpServers ?? {}),
|
||||||
[startupConfig],
|
[startupConfig],
|
||||||
|
|
@ -46,61 +53,43 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||||
enabled: !!agent_id && mcpServerNames.length > 0,
|
enabled: !!agent_id && mcpServerNames.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const processedData = useMemo(() => {
|
const mcpServersMap = useMemo(() => {
|
||||||
if (!pluginTools) {
|
|
||||||
return {
|
|
||||||
tools: [],
|
|
||||||
groupedTools: {},
|
|
||||||
mcpServersMap: new Map<string, MCPServerInfo>(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const tools: AgentToolType[] = [];
|
|
||||||
const groupedTools: GroupedToolsRecord = {};
|
|
||||||
|
|
||||||
const configuredServers = new Set(mcpServerNames);
|
const configuredServers = new Set(mcpServerNames);
|
||||||
const mcpServersMap = new Map<string, MCPServerInfo>();
|
const serversMap = new Map<string, MCPServerInfo>();
|
||||||
|
|
||||||
for (const pluginTool of pluginTools) {
|
if (mcpTools) {
|
||||||
const tool: AgentToolType = {
|
for (const pluginTool of mcpTools) {
|
||||||
tool_id: pluginTool.pluginKey,
|
if (pluginTool.pluginKey.includes(Constants.mcp_delimiter)) {
|
||||||
metadata: pluginTool as TPlugin,
|
const [_toolName, serverName] = pluginTool.pluginKey.split(Constants.mcp_delimiter);
|
||||||
};
|
|
||||||
|
|
||||||
tools.push(tool);
|
if (!serversMap.has(serverName)) {
|
||||||
|
const metadata = {
|
||||||
|
name: serverName,
|
||||||
|
pluginKey: serverName,
|
||||||
|
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
|
||||||
|
icon: pluginTool.icon || '',
|
||||||
|
} as TPlugin;
|
||||||
|
|
||||||
if (tool.tool_id.includes(Constants.mcp_delimiter)) {
|
serversMap.set(serverName, {
|
||||||
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
|
serverName,
|
||||||
|
tools: [],
|
||||||
|
isConfigured: configuredServers.has(serverName),
|
||||||
|
isConnected: connectionStatus?.[serverName]?.connectionState === 'connected',
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!mcpServersMap.has(serverName)) {
|
serversMap.get(serverName)!.tools.push({
|
||||||
const metadata = {
|
tool_id: pluginTool.pluginKey,
|
||||||
name: serverName,
|
metadata: pluginTool as TPlugin,
|
||||||
pluginKey: serverName,
|
|
||||||
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
|
|
||||||
icon: pluginTool.icon || '',
|
|
||||||
} as TPlugin;
|
|
||||||
|
|
||||||
mcpServersMap.set(serverName, {
|
|
||||||
serverName,
|
|
||||||
tools: [],
|
|
||||||
isConfigured: configuredServers.has(serverName),
|
|
||||||
isConnected: connectionStatus?.[serverName]?.connectionState === 'connected',
|
|
||||||
metadata,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
mcpServersMap.get(serverName)!.tools.push(tool);
|
|
||||||
} else {
|
|
||||||
// Non-MCP tool
|
|
||||||
groupedTools[tool.tool_id] = {
|
|
||||||
tool_id: tool.tool_id,
|
|
||||||
metadata: tool.metadata,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add configured servers that don't have tools yet
|
||||||
for (const mcpServerName of mcpServerNames) {
|
for (const mcpServerName of mcpServerNames) {
|
||||||
if (mcpServersMap.has(mcpServerName)) {
|
if (serversMap.has(mcpServerName)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const metadata = {
|
const metadata = {
|
||||||
|
|
@ -110,7 +99,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||||
description: `${localize('com_ui_tool_collection_prefix')} ${mcpServerName}`,
|
description: `${localize('com_ui_tool_collection_prefix')} ${mcpServerName}`,
|
||||||
} as TPlugin;
|
} as TPlugin;
|
||||||
|
|
||||||
mcpServersMap.set(mcpServerName, {
|
serversMap.set(mcpServerName, {
|
||||||
tools: [],
|
tools: [],
|
||||||
metadata,
|
metadata,
|
||||||
isConfigured: true,
|
isConfigured: true,
|
||||||
|
|
@ -119,14 +108,8 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return serversMap;
|
||||||
tools,
|
}, [mcpTools, localize, mcpServerNames, connectionStatus]);
|
||||||
groupedTools,
|
|
||||||
mcpServersMap,
|
|
||||||
};
|
|
||||||
}, [pluginTools, localize, mcpServerNames, connectionStatus]);
|
|
||||||
|
|
||||||
const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
|
|
||||||
|
|
||||||
const value: AgentPanelContextType = {
|
const value: AgentPanelContextType = {
|
||||||
mcp,
|
mcp,
|
||||||
|
|
@ -137,16 +120,15 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||||
setMcps,
|
setMcps,
|
||||||
agent_id,
|
agent_id,
|
||||||
setAction,
|
setAction,
|
||||||
pluginTools,
|
mcpTools,
|
||||||
activePanel,
|
activePanel,
|
||||||
|
regularTools,
|
||||||
agentsConfig,
|
agentsConfig,
|
||||||
startupConfig,
|
startupConfig,
|
||||||
|
mcpServersMap,
|
||||||
setActivePanel,
|
setActivePanel,
|
||||||
endpointsConfig,
|
endpointsConfig,
|
||||||
setCurrentAgentId,
|
setCurrentAgentId,
|
||||||
tools: processedData.tools,
|
|
||||||
groupedTools: processedData.groupedTools,
|
|
||||||
mcpServersMap: processedData.mcpServersMap,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;
|
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -232,10 +232,9 @@ export type AgentPanelContextType = {
|
||||||
mcps?: t.MCP[];
|
mcps?: t.MCP[];
|
||||||
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
|
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
|
||||||
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
|
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
|
||||||
groupedTools: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
|
|
||||||
activePanel?: string;
|
activePanel?: string;
|
||||||
tools: t.AgentToolType[];
|
regularTools?: t.TPlugin[];
|
||||||
pluginTools?: 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;
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,12 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||||
const {
|
const {
|
||||||
actions,
|
actions,
|
||||||
setAction,
|
setAction,
|
||||||
|
regularTools,
|
||||||
agentsConfig,
|
agentsConfig,
|
||||||
startupConfig,
|
startupConfig,
|
||||||
mcpServersMap,
|
mcpServersMap,
|
||||||
setActivePanel,
|
setActivePanel,
|
||||||
endpointsConfig,
|
endpointsConfig,
|
||||||
groupedTools: allTools,
|
|
||||||
} = useAgentPanelContext();
|
} = useAgentPanelContext();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -177,7 +177,7 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||||
Icon = icons[iconKey];
|
Icon = icons[iconKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { toolIds, mcpServerNames } = useVisibleTools(tools, allTools, mcpServersMap);
|
const { toolIds, mcpServerNames } = useVisibleTools(tools, regularTools, mcpServersMap);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -326,16 +326,15 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
{/* Render all visible IDs (including groups with subtools selected) */}
|
{/* Render all visible IDs */}
|
||||||
{toolIds.map((toolId, i) => {
|
{toolIds.map((toolId, i) => {
|
||||||
if (!allTools) return null;
|
const tool = regularTools?.find((t) => t.pluginKey === toolId);
|
||||||
const tool = allTools[toolId];
|
|
||||||
if (!tool) return null;
|
if (!tool) return null;
|
||||||
return (
|
return (
|
||||||
<AgentTool
|
<AgentTool
|
||||||
key={`${toolId}-${i}-${agent_id}`}
|
key={`${toolId}-${i}-${agent_id}`}
|
||||||
tool={toolId}
|
tool={toolId}
|
||||||
allTools={allTools}
|
regularTools={regularTools}
|
||||||
agent_id={agent_id}
|
agent_id={agent_id}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,47 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import * as Ariakit from '@ariakit/react';
|
|
||||||
import { ChevronDown } from 'lucide-react';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
|
||||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
OGDialog,
|
||||||
AccordionItem,
|
|
||||||
AccordionContent,
|
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
CircleHelpIcon,
|
CircleHelpIcon,
|
||||||
OGDialog,
|
|
||||||
OGDialogTrigger,
|
|
||||||
Label,
|
|
||||||
Checkbox,
|
|
||||||
OGDialogTemplate,
|
|
||||||
useToastContext,
|
useToastContext,
|
||||||
|
OGDialogTrigger,
|
||||||
|
OGDialogTemplate,
|
||||||
} from '@librechat/client';
|
} from '@librechat/client';
|
||||||
import type { AgentToolType } from 'librechat-data-provider';
|
import type { TPlugin } from 'librechat-data-provider';
|
||||||
import type { AgentForm } from '~/common';
|
import type { AgentForm } from '~/common';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
export default function AgentTool({
|
export default function AgentTool({
|
||||||
tool,
|
tool,
|
||||||
allTools,
|
regularTools,
|
||||||
}: {
|
}: {
|
||||||
tool: string;
|
tool: string;
|
||||||
allTools?: Record<string, AgentToolType & { tools?: AgentToolType[] }>;
|
regularTools?: TPlugin[];
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
}) {
|
}) {
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
|
||||||
const [hoveredToolId, setHoveredToolId] = useState<string | null>(null);
|
|
||||||
const [accordionValue, setAccordionValue] = useState<string>('');
|
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
const updateUserPlugins = useUpdateUserPluginsMutation();
|
const updateUserPlugins = useUpdateUserPluginsMutation();
|
||||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||||
if (!allTools) {
|
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
|
||||||
|
if (!regularTools) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const currentTool = allTools[tool];
|
|
||||||
const getSelectedTools = () => {
|
|
||||||
if (!currentTool?.tools) return [];
|
|
||||||
const formTools = getValues('tools') || [];
|
|
||||||
return currentTool.tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateFormTools = (newSelectedTools: string[]) => {
|
const currentTool = regularTools.find((t) => t.pluginKey === tool);
|
||||||
const currentTools = getValues('tools') || [];
|
|
||||||
const otherTools = currentTools.filter(
|
if (!currentTool) {
|
||||||
(t: string) => !currentTool?.tools?.some((st) => st.tool_id === t),
|
return null;
|
||||||
);
|
}
|
||||||
setValue('tools', [...otherTools, ...newSelectedTools]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTool = (toolId: string) => {
|
const removeTool = (toolId: string) => {
|
||||||
if (toolId) {
|
if (toolId) {
|
||||||
const toolIdsToRemove =
|
|
||||||
isGroup && currentTool.tools
|
|
||||||
? [toolId, ...currentTool.tools.map((t) => t.tool_id)]
|
|
||||||
: [toolId];
|
|
||||||
|
|
||||||
updateUserPlugins.mutate(
|
updateUserPlugins.mutate(
|
||||||
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
|
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
|
||||||
{
|
{
|
||||||
|
|
@ -70,9 +49,7 @@ export default function AgentTool({
|
||||||
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
|
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
const remainingToolIds = getValues('tools')?.filter(
|
const remainingToolIds = getValues('tools')?.filter((id: string) => id !== toolId);
|
||||||
(toolId: string) => !toolIdsToRemove.includes(toolId),
|
|
||||||
);
|
|
||||||
setValue('tools', remainingToolIds);
|
setValue('tools', remainingToolIds);
|
||||||
showToast({ message: 'Tool deleted successfully', status: 'success' });
|
showToast({ message: 'Tool deleted successfully', status: 'success' });
|
||||||
},
|
},
|
||||||
|
|
@ -81,327 +58,82 @@ export default function AgentTool({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!currentTool) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isGroup = currentTool.tools && currentTool.tools.length > 0;
|
|
||||||
const selectedTools = getSelectedTools();
|
|
||||||
const isExpanded = accordionValue === currentTool.tool_id;
|
|
||||||
|
|
||||||
if (!isGroup) {
|
|
||||||
return (
|
|
||||||
<OGDialog>
|
|
||||||
<div
|
|
||||||
className="group relative flex w-full items-center gap-1 rounded-lg p-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
|
||||||
onMouseEnter={() => setIsHovering(true)}
|
|
||||||
onMouseLeave={() => setIsHovering(false)}
|
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
onBlur={(e) => {
|
|
||||||
// Check if focus is moving to a child element
|
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
|
||||||
setIsFocused(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex grow items-center">
|
|
||||||
{currentTool.metadata.icon && (
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
|
||||||
<div
|
|
||||||
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url(${currentTool.metadata.icon})`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="grow px-2 py-1.5"
|
|
||||||
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
{currentTool.metadata.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<OGDialogTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'flex h-7 w-7 items-center justify-center rounded transition-all duration-200',
|
|
||||||
'hover:bg-gray-200 dark:hover:bg-gray-700',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
|
||||||
'focus:opacity-100',
|
|
||||||
isHovering || isFocused ? 'opacity-100' : 'pointer-events-none opacity-0',
|
|
||||||
)}
|
|
||||||
aria-label={`Delete ${currentTool.metadata.name}`}
|
|
||||||
tabIndex={0}
|
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</OGDialogTrigger>
|
|
||||||
</div>
|
|
||||||
<OGDialogTemplate
|
|
||||||
showCloseButton={false}
|
|
||||||
title={localize('com_ui_delete_tool')}
|
|
||||||
mainClassName="px-0"
|
|
||||||
className="max-w-[450px]"
|
|
||||||
main={
|
|
||||||
<Label className="text-left text-sm font-medium">
|
|
||||||
{localize('com_ui_delete_tool_confirm')}
|
|
||||||
</Label>
|
|
||||||
}
|
|
||||||
selection={{
|
|
||||||
selectHandler: () => removeTool(currentTool.tool_id),
|
|
||||||
selectClasses:
|
|
||||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
|
|
||||||
selectText: localize('com_ui_delete'),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</OGDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group tool with accordion
|
|
||||||
return (
|
return (
|
||||||
<OGDialog>
|
<OGDialog>
|
||||||
<Accordion type="single" value={accordionValue} onValueChange={setAccordionValue} collapsible>
|
<div
|
||||||
<AccordionItem value={currentTool.tool_id} className="group relative w-full border-none">
|
className="group relative flex w-full items-center gap-1 rounded-lg p-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||||
<div
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
className="relative flex w-full items-center gap-1 rounded-lg p-1 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
onMouseEnter={() => setIsHovering(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onMouseLeave={() => setIsHovering(false)}
|
onBlur={(e) => {
|
||||||
onFocus={() => setIsFocused(true)}
|
// Check if focus is moving to a child element
|
||||||
onBlur={(e) => {
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||||
// Check if focus is moving to a child element
|
setIsFocused(false);
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
}
|
||||||
setIsFocused(false);
|
}}
|
||||||
}
|
>
|
||||||
}}
|
<div className="flex grow items-center">
|
||||||
>
|
{currentTool.icon && (
|
||||||
<AccordionPrimitive.Header asChild>
|
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
||||||
<AccordionPrimitive.Trigger asChild>
|
<div
|
||||||
<button
|
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
|
||||||
type="button"
|
style={{
|
||||||
className={cn(
|
backgroundImage: `url(${currentTool.icon})`,
|
||||||
'flex grow items-center gap-1 rounded bg-transparent p-0 text-left transition-colors',
|
backgroundSize: 'cover',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
}}
|
||||||
)}
|
/>
|
||||||
>
|
|
||||||
{currentTool.metadata.icon && (
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
|
||||||
<div
|
|
||||||
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url(${currentTool.metadata.icon})`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="grow px-2 py-1.5"
|
|
||||||
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
{currentTool.metadata.name}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{/* Container for grouped checkbox and chevron */}
|
|
||||||
<div className="relative flex items-center">
|
|
||||||
{/* Grouped checkbox and chevron that slide together */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 transition-all duration-300',
|
|
||||||
isHovering || isFocused ? '-translate-x-8' : 'translate-x-0',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-checkbox-container
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="mt-1"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={`select-all-${currentTool.tool_id}`}
|
|
||||||
checked={selectedTools.length === currentTool.tools?.length}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
if (currentTool.tools) {
|
|
||||||
const newSelectedTools = checked
|
|
||||||
? currentTool.tools.map((t) => t.tool_id)
|
|
||||||
: [];
|
|
||||||
updateFormTools(newSelectedTools);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 rounded border border-gray-300 transition-all duration-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500',
|
|
||||||
isExpanded ? 'visible' : 'pointer-events-none invisible',
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const checkbox = e.currentTarget as HTMLButtonElement;
|
|
||||||
checkbox.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
tabIndex={isExpanded ? 0 : -1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'pointer-events-none flex h-4 w-4 items-center justify-center transition-transform duration-300',
|
|
||||||
isExpanded ? 'rotate-180' : '',
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete button slides in from behind */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute right-0 transition-all duration-300',
|
|
||||||
isHovering || isFocused
|
|
||||||
? 'translate-x-0 opacity-100'
|
|
||||||
: 'translate-x-8 opacity-0',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<OGDialogTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
|
|
||||||
'hover:bg-gray-200 dark:hover:bg-gray-700',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
|
||||||
'focus:translate-x-0 focus:opacity-100',
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
aria-label={`Delete ${currentTool.metadata.name}`}
|
|
||||||
tabIndex={0}
|
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</OGDialogTrigger>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</AccordionPrimitive.Trigger>
|
|
||||||
</AccordionPrimitive.Header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AccordionContent className="relative ml-1 pt-1 before:absolute before:bottom-2 before:left-0 before:top-0 before:w-0.5 before:bg-border-medium">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{currentTool.tools?.map((subTool) => (
|
|
||||||
<label
|
|
||||||
key={subTool.tool_id}
|
|
||||||
htmlFor={subTool.tool_id}
|
|
||||||
className={cn(
|
|
||||||
'border-token-border-light hover:bg-token-surface-secondary flex cursor-pointer items-center rounded-lg border p-2',
|
|
||||||
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setHoveredToolId(subTool.tool_id)}
|
|
||||||
onMouseLeave={() => setHoveredToolId(null)}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={subTool.tool_id}
|
|
||||||
checked={selectedTools.includes(subTool.tool_id)}
|
|
||||||
onCheckedChange={(_checked) => {
|
|
||||||
const newSelectedTools = selectedTools.includes(subTool.tool_id)
|
|
||||||
? selectedTools.filter((t) => t !== subTool.tool_id)
|
|
||||||
: [...selectedTools, subTool.tool_id];
|
|
||||||
updateFormTools(newSelectedTools);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
const checkbox = e.currentTarget as HTMLButtonElement;
|
|
||||||
checkbox.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-gray-300 transition-[border-color] duration-200 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background dark:border-gray-600 dark:hover:border-gray-500"
|
|
||||||
/>
|
|
||||||
<span className="text-token-text-primary">{subTool.metadata.name}</span>
|
|
||||||
{subTool.metadata.description && (
|
|
||||||
<Ariakit.HovercardProvider placement="left-start">
|
|
||||||
<div className="ml-auto flex h-6 w-6 items-center justify-center">
|
|
||||||
<Ariakit.HovercardAnchor
|
|
||||||
render={
|
|
||||||
<Ariakit.Button
|
|
||||||
className={cn(
|
|
||||||
'flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary transition-opacity duration-200',
|
|
||||||
hoveredToolId === subTool.tool_id ? 'opacity-100' : 'opacity-0',
|
|
||||||
)}
|
|
||||||
aria-label={localize('com_ui_tool_info')}
|
|
||||||
>
|
|
||||||
<CircleHelpIcon className="h-4 w-4" />
|
|
||||||
<Ariakit.VisuallyHidden>
|
|
||||||
{localize('com_ui_tool_info')}
|
|
||||||
</Ariakit.VisuallyHidden>
|
|
||||||
</Ariakit.Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Ariakit.HovercardDisclosure
|
|
||||||
className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
aria-label={localize('com_ui_tool_more_info')}
|
|
||||||
aria-expanded={hoveredToolId === subTool.tool_id}
|
|
||||||
aria-controls={`tool-description-${subTool.tool_id}`}
|
|
||||||
>
|
|
||||||
<Ariakit.VisuallyHidden>
|
|
||||||
{localize('com_ui_tool_more_info')}
|
|
||||||
</Ariakit.VisuallyHidden>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Ariakit.HovercardDisclosure>
|
|
||||||
</div>
|
|
||||||
<Ariakit.Hovercard
|
|
||||||
id={`tool-description-${subTool.tool_id}`}
|
|
||||||
gutter={14}
|
|
||||||
shift={40}
|
|
||||||
flip={false}
|
|
||||||
className="z-[999] w-80 scale-95 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary opacity-0 shadow-md transition-all duration-200 data-[enter]:scale-100 data-[leave]:scale-95 data-[enter]:opacity-100 data-[leave]:opacity-0"
|
|
||||||
portal={true}
|
|
||||||
unmountOnHide={true}
|
|
||||||
role="tooltip"
|
|
||||||
aria-label={subTool.metadata.description}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-text-secondary">
|
|
||||||
{subTool.metadata.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Ariakit.Hovercard>
|
|
||||||
</Ariakit.HovercardProvider>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
)}
|
||||||
</AccordionItem>
|
<div
|
||||||
</Accordion>
|
className="grow px-2 py-1.5"
|
||||||
|
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{currentTool.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OGDialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 w-7 items-center justify-center rounded transition-all duration-200',
|
||||||
|
'hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||||
|
'focus:opacity-100',
|
||||||
|
isHovering || isFocused ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||||
|
)}
|
||||||
|
aria-label={`Delete ${currentTool.name}`}
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</OGDialogTrigger>
|
||||||
|
</div>
|
||||||
<OGDialogTemplate
|
<OGDialogTemplate
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
title={localize('com_ui_delete_tool')}
|
title={localize('com_ui_delete_tool')}
|
||||||
mainClassName="px-0"
|
|
||||||
className="max-w-[450px]"
|
className="max-w-[450px]"
|
||||||
main={
|
main={
|
||||||
<Label className="text-left text-sm font-medium">
|
<>
|
||||||
{localize('com_ui_delete_tool_confirm')}
|
<div className="flex w-full flex-col items-start gap-2 text-sm text-text-secondary">
|
||||||
</Label>
|
<p>
|
||||||
|
{localize('com_ui_delete_tool_confirm')}{' '}
|
||||||
|
<strong>"{currentTool.name}"</strong>?
|
||||||
|
</p>
|
||||||
|
{currentTool.description && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<CircleHelpIcon className="h-4 w-4 flex-shrink-0 text-text-secondary" />
|
||||||
|
<p className="text-sm">{currentTool.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
selection={{
|
selection={{
|
||||||
selectHandler: () => removeTool(currentTool.tool_id),
|
selectHandler: () => removeTool(tool),
|
||||||
selectClasses:
|
selectClasses:
|
||||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
|
'bg-red-700 hover:bg-red-800 dark:bg-red-600 dark:hover:bg-red-800 text-white',
|
||||||
selectText: localize('com_ui_delete'),
|
selectText: localize('com_ui_delete'),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ function MCPPanelContent() {
|
||||||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.refetchQueries([QueryKeys.tools]),
|
queryClient.refetchQueries([QueryKeys.mcpTools]),
|
||||||
queryClient.refetchQueries([QueryKeys.mcpAuthValues]),
|
queryClient.refetchQueries([QueryKeys.mcpAuthValues]),
|
||||||
queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]),
|
queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-quer
|
||||||
import type { TError, AgentToolType } from 'librechat-data-provider';
|
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 { useGetStartupConfig, useAvailableToolsQuery } from '~/data-provider';
|
|
||||||
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 MCPToolItem from './MCPToolItem';
|
import MCPToolItem from './MCPToolItem';
|
||||||
|
|
@ -27,8 +27,8 @@ function MCPToolSelectDialog({
|
||||||
const { mcpServersMap } = useAgentPanelContext();
|
const { mcpServersMap } = useAgentPanelContext();
|
||||||
const { initializeServer } = useMCPServerManager();
|
const { initializeServer } = useMCPServerManager();
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
|
const { refetch: refetchMCPTools } = useMCPToolsQuery();
|
||||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||||
const { refetch: refetchAvailableTools } = useAvailableToolsQuery(EModelEndpoint.agents);
|
|
||||||
|
|
||||||
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,15 +90,15 @@ function MCPToolSelectDialog({
|
||||||
setIsInitializing(null);
|
setIsInitializing(null);
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
const { data: updatedAvailableTools } = await refetchAvailableTools();
|
const { data: updatedMCPTools } = 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 (updatedAvailableTools) {
|
if (updatedMCPTools) {
|
||||||
updatedAvailableTools.forEach((tool) => {
|
updatedMCPTools.forEach((tool) => {
|
||||||
if (tool.pluginKey.endsWith(`${Constants.mcp_delimiter}${serverName}`)) {
|
if (tool.pluginKey.endsWith(`${Constants.mcp_delimiter}${serverName}`)) {
|
||||||
toolsToAdd.push(tool.pluginKey);
|
toolsToAdd.push(tool.pluginKey);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
AssistantsEndpoint,
|
AssistantsEndpoint,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
TPluginAction,
|
TPluginAction,
|
||||||
AgentToolType,
|
TPlugin,
|
||||||
TError,
|
TError,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import type { AgentForm, TPluginStoreDialogProps } from '~/common';
|
import type { AgentForm, TPluginStoreDialogProps } from '~/common';
|
||||||
|
|
@ -27,7 +27,8 @@ function ToolSelectDialog({
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const isAgentTools = isAgentsEndpoint(endpoint);
|
const isAgentTools = isAgentsEndpoint(endpoint);
|
||||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||||
const { groupedTools, pluginTools } = useAgentPanelContext();
|
// Only use regular tools, not MCP tools
|
||||||
|
const { regularTools } = useAgentPanelContext();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
maxPage,
|
maxPage,
|
||||||
|
|
@ -68,19 +69,8 @@ function ToolSelectDialog({
|
||||||
const handleInstall = (pluginAction: TPluginAction) => {
|
const handleInstall = (pluginAction: TPluginAction) => {
|
||||||
const addFunction = () => {
|
const addFunction = () => {
|
||||||
const installedToolIds: string[] = getValues('tools') || [];
|
const installedToolIds: string[] = getValues('tools') || [];
|
||||||
// Add the parent
|
|
||||||
installedToolIds.push(pluginAction.pluginKey);
|
installedToolIds.push(pluginAction.pluginKey);
|
||||||
|
setValue('tools', Array.from(new Set(installedToolIds)));
|
||||||
// If this tool is a group, add subtools too
|
|
||||||
const groupObj = groupedTools?.[pluginAction.pluginKey];
|
|
||||||
if (groupObj?.tools && groupObj.tools.length > 0) {
|
|
||||||
for (const sub of groupObj.tools) {
|
|
||||||
if (!installedToolIds.includes(sub.tool_id)) {
|
|
||||||
installedToolIds.push(sub.tool_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setValue('tools', Array.from(new Set(installedToolIds))); // no duplicates just in case
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!pluginAction.auth) {
|
if (!pluginAction.auth) {
|
||||||
|
|
@ -98,19 +88,12 @@ function ToolSelectDialog({
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemoveTool = (toolId: string) => {
|
const onRemoveTool = (toolId: string) => {
|
||||||
const groupObj = groupedTools?.[toolId];
|
|
||||||
const toolIdsToRemove = [toolId];
|
|
||||||
if (groupObj?.tools && groupObj.tools.length > 0) {
|
|
||||||
toolIdsToRemove.push(...groupObj.tools.map((sub) => sub.tool_id));
|
|
||||||
}
|
|
||||||
// Remove these from the formTools
|
|
||||||
updateUserPlugins.mutate(
|
updateUserPlugins.mutate(
|
||||||
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
|
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
|
||||||
{
|
{
|
||||||
onError: (error: unknown) => handleInstallError(error as TError),
|
onError: (error: unknown) => handleInstallError(error as TError),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
const remainingToolIds =
|
const remainingToolIds = getValues('tools')?.filter((id) => id !== toolId) || [];
|
||||||
getValues('tools')?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || [];
|
|
||||||
setValue('tools', remainingToolIds);
|
setValue('tools', remainingToolIds);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -119,7 +102,8 @@ function ToolSelectDialog({
|
||||||
|
|
||||||
const onAddTool = (pluginKey: string) => {
|
const onAddTool = (pluginKey: string) => {
|
||||||
setShowPluginAuthForm(false);
|
setShowPluginAuthForm(false);
|
||||||
const availablePluginFromKey = pluginTools?.find((p) => p.pluginKey === pluginKey);
|
// Find the tool in regularTools
|
||||||
|
const availablePluginFromKey = regularTools?.find((p) => p.pluginKey === pluginKey);
|
||||||
setSelectedPlugin(availablePluginFromKey);
|
setSelectedPlugin(availablePluginFromKey);
|
||||||
|
|
||||||
const { authConfig, authenticated = false } = availablePluginFromKey ?? {};
|
const { authConfig, authenticated = false } = availablePluginFromKey ?? {};
|
||||||
|
|
@ -134,30 +118,19 @@ function ToolSelectDialog({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredTools = Object.values(groupedTools || {}).filter(
|
const filteredTools = (regularTools || []).filter((tool: TPlugin) => {
|
||||||
(currentTool: AgentToolType & { tools?: AgentToolType[] }) => {
|
return tool.name?.toLowerCase().includes(searchValue.toLowerCase());
|
||||||
if (currentTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) {
|
});
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (currentTool.tools) {
|
|
||||||
return currentTool.tools.some((childTool) =>
|
|
||||||
childTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filteredTools) {
|
if (filteredTools) {
|
||||||
setMaxPage(Math.ceil(Object.keys(filteredTools || {}).length / itemsPerPage));
|
setMaxPage(Math.ceil(filteredTools.length / itemsPerPage));
|
||||||
if (searchChanged) {
|
if (searchChanged) {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setSearchChanged(false);
|
setSearchChanged(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
pluginTools,
|
|
||||||
searchValue,
|
searchValue,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
filteredTools,
|
filteredTools,
|
||||||
|
|
@ -254,10 +227,13 @@ function ToolSelectDialog({
|
||||||
.map((tool, index) => (
|
.map((tool, index) => (
|
||||||
<ToolItem
|
<ToolItem
|
||||||
key={index}
|
key={index}
|
||||||
tool={tool}
|
tool={{
|
||||||
isInstalled={getValues('tools')?.includes(tool.tool_id) || false}
|
tool_id: tool.pluginKey,
|
||||||
onAddTool={() => onAddTool(tool.tool_id)}
|
metadata: tool,
|
||||||
onRemoveTool={() => onRemoveTool(tool.tool_id)}
|
}}
|
||||||
|
isInstalled={getValues('tools')?.includes(tool.pluginKey) || false}
|
||||||
|
onAddTool={() => onAddTool(tool.pluginKey)}
|
||||||
|
onRemoveTool={() => onRemoveTool(tool.pluginKey)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,6 @@ export * from './connection';
|
||||||
export * from './mutations';
|
export * from './mutations';
|
||||||
export * from './prompts';
|
export * from './prompts';
|
||||||
export * from './queries';
|
export * from './queries';
|
||||||
|
export * from './mcp';
|
||||||
export * from './roles';
|
export * from './roles';
|
||||||
export * from './tags';
|
export * from './tags';
|
||||||
|
|
|
||||||
29
client/src/data-provider/mcp.ts
Normal file
29
client/src/data-provider/mcp.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* Dedicated queries for MCP (Model Context Protocol) tools
|
||||||
|
* Decoupled from regular LibreChat tools
|
||||||
|
*/
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { QueryKeys, dataService } from 'librechat-data-provider';
|
||||||
|
import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
|
||||||
|
import type { TPlugin } from 'librechat-data-provider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching MCP-specific tools
|
||||||
|
* @param config - React Query configuration
|
||||||
|
* @returns MCP tools grouped by server
|
||||||
|
*/
|
||||||
|
export const useMCPToolsQuery = <TData = TPlugin[]>(
|
||||||
|
config?: UseQueryOptions<TPlugin[], unknown, TData>,
|
||||||
|
): QueryObserverResult<TData> => {
|
||||||
|
return useQuery<TPlugin[], unknown, TData>(
|
||||||
|
[QueryKeys.mcpTools],
|
||||||
|
() => dataService.getMCPTools(),
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
...config,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -178,7 +178,8 @@ export const useConversationTagsQuery = (
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for getting all available tools for Assistants
|
* Hook for getting available LibreChat tools (excludes MCP tools)
|
||||||
|
* For MCP tools, use `useMCPToolsQuery` from mcp-queries.ts
|
||||||
*/
|
*/
|
||||||
export const useAvailableToolsQuery = <TData = t.TPlugin[]>(
|
export const useAvailableToolsQuery = <TData = t.TPlugin[]>(
|
||||||
endpoint: t.AssistantsEndpoint | EModelEndpoint.agents,
|
endpoint: t.AssistantsEndpoint | EModelEndpoint.agents,
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,37 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
import { Constants } from 'librechat-data-provider';
|
||||||
import type { TPlugin } from 'librechat-data-provider';
|
import type { TPlugin } from 'librechat-data-provider';
|
||||||
import { useAvailableToolsQuery, useGetStartupConfig } from '~/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() {
|
export function useGetMCPTools() {
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
const { data: rawMcpTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
|
||||||
|
// Use dedicated MCP tools query
|
||||||
|
const { data: rawMcpTools } = useMCPToolsQuery({
|
||||||
select: (data: TPlugin[]) => {
|
select: (data: TPlugin[]) => {
|
||||||
|
// Group tools by server for easier management
|
||||||
const mcpToolsMap = new Map<string, TPlugin>();
|
const mcpToolsMap = new Map<string, TPlugin>();
|
||||||
data.forEach((tool) => {
|
data.forEach((tool) => {
|
||||||
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
||||||
if (isMCP) {
|
const serverName = parts[parts.length - 1];
|
||||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
if (!mcpToolsMap.has(serverName)) {
|
||||||
const serverName = parts[parts.length - 1];
|
mcpToolsMap.set(serverName, {
|
||||||
if (!mcpToolsMap.has(serverName)) {
|
name: serverName,
|
||||||
mcpToolsMap.set(serverName, {
|
pluginKey: tool.pluginKey,
|
||||||
name: serverName,
|
authConfig: tool.authConfig,
|
||||||
pluginKey: tool.pluginKey,
|
authenticated: tool.authenticated,
|
||||||
authConfig: tool.authConfig,
|
});
|
||||||
authenticated: tool.authenticated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return Array.from(mcpToolsMap.values());
|
return Array.from(mcpToolsMap.values());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filter out servers that have chatMenu disabled
|
||||||
const mcpToolDetails = useMemo(() => {
|
const mcpToolDetails = useMemo(() => {
|
||||||
if (!rawMcpTools || !startupConfig?.mcpServers) {
|
if (!rawMcpTools || !startupConfig?.mcpServers) {
|
||||||
return rawMcpTools;
|
return rawMcpTools;
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.refetchQueries([QueryKeys.tools]),
|
queryClient.refetchQueries([QueryKeys.mcpTools]),
|
||||||
queryClient.refetchQueries([QueryKeys.mcpAuthValues]),
|
queryClient.refetchQueries([QueryKeys.mcpAuthValues]),
|
||||||
queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]),
|
queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]),
|
||||||
]);
|
]);
|
||||||
|
|
@ -170,7 +170,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||||
setMCPValues([...currentValues, serverName]);
|
setMCPValues([...currentValues, serverName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await queryClient.invalidateQueries([QueryKeys.tools]);
|
await queryClient.invalidateQueries([QueryKeys.mcpTools]);
|
||||||
|
|
||||||
// This delay is to ensure UI has updated with new connection status before cleanup
|
// This delay is to ensure UI has updated with new connection status before cleanup
|
||||||
// Otherwise servers will show as disconnected for a second after OAuth flow completes
|
// Otherwise servers will show as disconnected for a second after OAuth flow completes
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,52 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Constants } from 'librechat-data-provider';
|
import { Constants } from 'librechat-data-provider';
|
||||||
import type { AgentToolType } from 'librechat-data-provider';
|
import type { TPlugin } from 'librechat-data-provider';
|
||||||
import type { MCPServerInfo } from '~/common';
|
import type { MCPServerInfo } from '~/common';
|
||||||
|
|
||||||
type GroupedToolType = AgentToolType & { tools?: AgentToolType[] };
|
|
||||||
type GroupedToolsRecord = Record<string, GroupedToolType>;
|
|
||||||
|
|
||||||
interface VisibleToolsResult {
|
interface VisibleToolsResult {
|
||||||
toolIds: string[];
|
toolIds: string[];
|
||||||
mcpServerNames: string[];
|
mcpServerNames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook to calculate visible tool IDs based on selected tools and their parent groups.
|
* Custom hook to calculate visible tool IDs based on selected tools.
|
||||||
* If any subtool of a group is selected, the parent group tool is also made visible.
|
* Separates regular LibreChat tools from MCP servers.
|
||||||
*
|
*
|
||||||
* @param selectedToolIds - Array of selected tool IDs
|
* @param selectedToolIds - Array of selected tool IDs
|
||||||
* @param allTools - Record of all available tools
|
* @param regularTools - Array of regular LibreChat tools
|
||||||
* @param mcpServersMap - Map of all MCP servers
|
* @param mcpServersMap - Map of all MCP servers
|
||||||
* @returns Object containing separate arrays of visible tool IDs for regular and MCP tools
|
* @returns Object containing separate arrays of visible tool IDs for regular and MCP tools
|
||||||
*/
|
*/
|
||||||
export function useVisibleTools(
|
export function useVisibleTools(
|
||||||
selectedToolIds: string[] | undefined,
|
selectedToolIds: string[] | undefined,
|
||||||
allTools: GroupedToolsRecord | undefined,
|
regularTools: TPlugin[] | undefined,
|
||||||
mcpServersMap: Map<string, MCPServerInfo>,
|
mcpServersMap: Map<string, MCPServerInfo>,
|
||||||
): VisibleToolsResult {
|
): VisibleToolsResult {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const mcpServers = new Set<string>();
|
const mcpServers = new Set<string>();
|
||||||
const selectedSet = new Set<string>();
|
const regularToolIds: string[] = [];
|
||||||
const regularToolIds = new Set<string>();
|
|
||||||
|
|
||||||
for (const toolId of selectedToolIds ?? []) {
|
for (const toolId of selectedToolIds ?? []) {
|
||||||
if (!toolId.includes(Constants.mcp_delimiter)) {
|
// MCP tools/servers
|
||||||
selectedSet.add(toolId);
|
if (toolId.includes(Constants.mcp_delimiter)) {
|
||||||
continue;
|
const serverName = toolId.split(Constants.mcp_delimiter)[1];
|
||||||
}
|
if (serverName) {
|
||||||
const serverName = toolId.split(Constants.mcp_delimiter)[1];
|
mcpServers.add(serverName);
|
||||||
if (!serverName) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
mcpServers.add(serverName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allTools) {
|
|
||||||
for (const [toolId, toolObj] of Object.entries(allTools)) {
|
|
||||||
if (selectedSet.has(toolId)) {
|
|
||||||
regularToolIds.add(toolId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toolObj.tools?.length) {
|
|
||||||
for (const subtool of toolObj.tools) {
|
|
||||||
if (selectedSet.has(subtool.tool_id)) {
|
|
||||||
regularToolIds.add(toolId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// Legacy MCP server check (just server name)
|
||||||
|
else if (mcpServersMap.has(toolId)) {
|
||||||
if (mcpServersMap) {
|
mcpServers.add(toolId);
|
||||||
for (const [mcpServerName] of mcpServersMap) {
|
}
|
||||||
if (mcpServers.has(mcpServerName)) {
|
// Regular LibreChat tools
|
||||||
continue;
|
else if (regularTools?.some((t) => t.pluginKey === toolId)) {
|
||||||
}
|
regularToolIds.push(toolId);
|
||||||
/** Legacy check */
|
|
||||||
if (selectedSet.has(mcpServerName)) {
|
|
||||||
mcpServers.add(mcpServerName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
toolIds: Array.from(regularToolIds).sort((a, b) => a.localeCompare(b)),
|
toolIds: regularToolIds.sort((a, b) => a.localeCompare(b)),
|
||||||
mcpServerNames: Array.from(mcpServers).sort((a, b) => a.localeCompare(b)),
|
mcpServerNames: Array.from(mcpServers).sort((a, b) => a.localeCompare(b)),
|
||||||
};
|
};
|
||||||
}, [allTools, mcpServersMap, selectedToolIds]);
|
}, [regularTools, mcpServersMap, selectedToolIds]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,10 @@ export const agents = ({ path = '', options }: { path?: string; options?: object
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const mcp = {
|
||||||
|
tools: `${BASE_URL}/api/mcp/tools`,
|
||||||
|
};
|
||||||
|
|
||||||
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
|
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
|
||||||
|
|
||||||
export const files = () => `${BASE_URL}/api/files`;
|
export const files = () => `${BASE_URL}/api/files`;
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,12 @@ export const getAvailableTools = (
|
||||||
return request.get(path);
|
return request.get(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* MCP Tools - Decoupled from regular tools */
|
||||||
|
|
||||||
|
export const getMCPTools = (): Promise<s.TPlugin[]> => {
|
||||||
|
return request.get(endpoints.mcp.tools);
|
||||||
|
};
|
||||||
|
|
||||||
export const getVerifyAgentToolAuth = (
|
export const getVerifyAgentToolAuth = (
|
||||||
params: q.VerifyToolAuthParams,
|
params: q.VerifyToolAuthParams,
|
||||||
): Promise<q.VerifyToolAuthResponse> => {
|
): Promise<q.VerifyToolAuthResponse> => {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export enum QueryKeys {
|
||||||
tools = 'tools',
|
tools = 'tools',
|
||||||
toolAuth = 'toolAuth',
|
toolAuth = 'toolAuth',
|
||||||
toolCalls = 'toolCalls',
|
toolCalls = 'toolCalls',
|
||||||
|
mcpTools = 'mcpTools',
|
||||||
mcpConnectionStatus = 'mcpConnectionStatus',
|
mcpConnectionStatus = 'mcpConnectionStatus',
|
||||||
mcpAuthValues = 'mcpAuthValues',
|
mcpAuthValues = 'mcpAuthValues',
|
||||||
agentTools = 'agentTools',
|
agentTools = 'agentTools',
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ export const useRevokeUserKeyMutation = (name: string): UseMutationResult<unknow
|
||||||
queryClient.invalidateQueries([QueryKeys.assistantDocs]);
|
queryClient.invalidateQueries([QueryKeys.assistantDocs]);
|
||||||
queryClient.invalidateQueries([QueryKeys.assistants]);
|
queryClient.invalidateQueries([QueryKeys.assistants]);
|
||||||
queryClient.invalidateQueries([QueryKeys.assistant]);
|
queryClient.invalidateQueries([QueryKeys.assistant]);
|
||||||
|
queryClient.invalidateQueries([QueryKeys.mcpTools]);
|
||||||
queryClient.invalidateQueries([QueryKeys.actions]);
|
queryClient.invalidateQueries([QueryKeys.actions]);
|
||||||
queryClient.invalidateQueries([QueryKeys.tools]);
|
queryClient.invalidateQueries([QueryKeys.tools]);
|
||||||
}
|
}
|
||||||
|
|
@ -172,6 +173,7 @@ export const useRevokeAllUserKeysMutation = (): UseMutationResult<unknown> => {
|
||||||
queryClient.invalidateQueries([QueryKeys.assistantDocs]);
|
queryClient.invalidateQueries([QueryKeys.assistantDocs]);
|
||||||
queryClient.invalidateQueries([QueryKeys.assistants]);
|
queryClient.invalidateQueries([QueryKeys.assistants]);
|
||||||
queryClient.invalidateQueries([QueryKeys.assistant]);
|
queryClient.invalidateQueries([QueryKeys.assistant]);
|
||||||
|
queryClient.invalidateQueries([QueryKeys.mcpTools]);
|
||||||
queryClient.invalidateQueries([QueryKeys.actions]);
|
queryClient.invalidateQueries([QueryKeys.actions]);
|
||||||
queryClient.invalidateQueries([QueryKeys.tools]);
|
queryClient.invalidateQueries([QueryKeys.tools]);
|
||||||
},
|
},
|
||||||
|
|
@ -337,7 +339,7 @@ export const useReinitializeMCPServerMutation = (): UseMutationResult<
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation((serverName: string) => dataService.reinitializeMCPServer(serverName), {
|
return useMutation((serverName: string) => dataService.reinitializeMCPServer(serverName), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.refetchQueries([QueryKeys.tools]);
|
queryClient.refetchQueries([QueryKeys.mcpTools]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue