Compare commits

...

13 Commits

Author SHA1 Message Date
constanttime
4fe600990c feat: Add subfolder support for model specs (#9165)
- Add optional `folder` field to TModelSpec type and schema
- Create ModelSpecFolder component for hierarchical display
- Implement expand/collapse functionality for folders
- Update search results to show folder context
- Sort folders alphabetically and specs by order/label
- Maintain backward compatibility (specs without folders appear at root)

This enhancement allows organizing model specs into categories/folders
for better organization and improved user experience when dealing with
many model configurations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 23:37:53 +05:30
Danny Avila
543b617e1c 📦 chore: bump cipher-base and sha.js (#9227) 2025-08-23 03:32:40 -04:00
Danny Avila
c827fdd10e 🚦 feat: Auto-reinitialize MCP Servers on Request (#9226) 2025-08-23 03:27:05 -04:00
Marco Beretta
ac608ded46 feat: Add cursor pagination utilities and refine user/group/role types in @librechat/data-schemas (#9218)
* feat: Add pagination interfaces and update user and group types for better data handling

* fix: Update data-schemas version to 0.0.19
2025-08-23 00:18:31 -04:00
mattmueller-stripe
0e00f357a6 🔗 chore: Remove <link href='#' /> from index.html (#9222) 2025-08-23 00:16:32 -04:00
Danny Avila
c465d7b732 🪪 fix: Preserve Existing Interface Permissions When Updating Config (#9199) 2025-08-21 12:49:45 -04:00
Danny Avila
aba0a93d1d 🧰 fix: Available Tools Retrieval with correct MCP Caching (#9181)
* fix: available tools retrieval with correct mcp caching and conversion

* test: Enhance PluginController tests with MCP tool mocking and conversion

* refactor: Simplify PluginController tests by removing unused mocks and enhancing test clarity
2025-08-20 22:33:54 -04:00
Danny Avila
a49b2b2833 🛣️ feat: directEndpoint Fetch Override for Custom Endpoints (#9179)
* feat: Add directEndpoint option to OpenAIConfigOptions and update fetch logic to override /chat/completions URL

* feat: Add directEndpoint support to fetchModels and update loadConfigModels logic
2025-08-20 15:55:32 -04:00
Danny Avila
e0ebb7097e fix: AbortSignal Cleanup Logic for New Chats (#9177)
* chore: import paths for isEnabled and logger in title.js

*  fix: `AbortSignal` Cleanup Logic for New Chats

* test: Add `isNewConvo` parameter to onStart expectation in BaseClient tests
2025-08-20 14:56:07 -04:00
Dustin Healy
9a79635012 🌍 i18n: Add Bosnian and Norsk Bokmål Languages (#9176) 2025-08-20 14:17:23 -04:00
Danny Avila
ce19abc968 🆔 feat: Add User ID to Anthropic API Payload as Metadata (#9174) 2025-08-20 13:42:08 -04:00
Danny Avila
49cd3894aa 🛠️ fix: Restrict Editable Content Types & Consolidate Typing (#9173)
* fix: only allow editing expected content types & align typing app-wide

* chore: update TPayload to use TEditedContent type for editedContent
2025-08-20 13:21:47 -04:00
Danny Avila
da4aa37493 🛠️ refactor: Consolidate MCP Tool Caching (#9172)
* 🛠️ refactor: Consolidate MCP Tool Caching

* 🐍 fix: Correctly mock and utilize updateMCPUserTools in MCP route tests
2025-08-20 12:19:29 -04:00
73 changed files with 2090 additions and 880 deletions

147
MODEL_SPEC_FOLDERS.md Normal file
View File

@@ -0,0 +1,147 @@
# Model Spec Subfolder Support
This enhancement adds the ability to organize model specs into subfolders/categories for better organization and user experience.
## Feature Overview
Model specs can now be grouped into folders by adding an optional `folder` field to each spec. This helps organize related models together, making it easier for users to find and select the appropriate model for their needs.
## Configuration
### Basic Usage
Add a `folder` field to any model spec in your `librechat.yaml`:
```yaml
modelSpecs:
list:
- name: "gpt4_turbo"
label: "GPT-4 Turbo"
folder: "OpenAI Models" # This spec will appear under "OpenAI Models" folder
preset:
endpoint: "openAI"
model: "gpt-4-turbo-preview"
```
### Folder Structure
- **With Folder**: Model specs with the `folder` field will be grouped under that folder name
- **Without Folder**: Model specs without the `folder` field appear at the root level
- **Multiple Folders**: You can create as many folders as needed to organize your models
- **Alphabetical Sorting**: Folders are sorted alphabetically, and specs within folders are sorted by their `order` field or label
### Example Configuration
```yaml
modelSpecs:
list:
# OpenAI Models Category
- name: "gpt4_turbo"
label: "GPT-4 Turbo"
folder: "OpenAI Models"
preset:
endpoint: "openAI"
model: "gpt-4-turbo-preview"
- name: "gpt35_turbo"
label: "GPT-3.5 Turbo"
folder: "OpenAI Models"
preset:
endpoint: "openAI"
model: "gpt-3.5-turbo"
# Anthropic Models Category
- name: "claude3_opus"
label: "Claude 3 Opus"
folder: "Anthropic Models"
preset:
endpoint: "anthropic"
model: "claude-3-opus-20240229"
# Root level model (no folder)
- name: "quick_chat"
label: "Quick Chat"
preset:
endpoint: "openAI"
model: "gpt-3.5-turbo"
```
## UI Features
### Folder Display
- Folders are displayed with expand/collapse functionality
- Folder icons change between open/closed states
- Indentation shows the hierarchy clearly
### Search Integration
- When searching for models, the folder path is shown for context
- Search works across all models regardless of folder structure
### User Experience
- Folders start expanded by default for easy access
- Click on folder header to expand/collapse
- Selected model is highlighted with a checkmark
- Folder state is preserved during the session
## Benefits
1. **Better Organization**: Group related models together (e.g., by provider, capability, or use case)
2. **Improved Navigation**: Users can quickly find models in organized categories
3. **Scalability**: Handles large numbers of model specs without overwhelming the UI
4. **Backward Compatible**: Existing configurations without folders continue to work
5. **Flexible Structure**: Mix foldered and non-foldered specs as needed
## Use Cases
### By Provider
```yaml
folder: "OpenAI Models"
folder: "Anthropic Models"
folder: "Google Models"
```
### By Capability
```yaml
folder: "Vision Models"
folder: "Code Models"
folder: "Creative Writing"
```
### By Performance Tier
```yaml
folder: "Premium Models"
folder: "Standard Models"
folder: "Budget Models"
```
### By Department/Team
```yaml
folder: "Engineering Team"
folder: "Marketing Team"
folder: "Research Team"
```
## Implementation Details
### Type Changes
- Added optional `folder?: string` field to `TModelSpec` type
- Updated `tModelSpecSchema` to include the folder field validation
### Components
- Created `ModelSpecFolder` component for rendering folder structure
- Updated `ModelSelector` to use folder-aware rendering
- Enhanced search results to show folder context
### Behavior
- Folders are collapsible with state management
- Models are sorted within folders by order/label
- Root-level models appear after all folders
## Migration
No migration needed - the feature is fully backward compatible. Existing model specs without the `folder` field will continue to work and appear at the root level.
## See Also
- `librechat.example.subfolder.yaml` - Complete example configuration
- GitHub Issue #9165 - Original feature request

View File

@@ -187,7 +187,8 @@ class BaseClient {
this.user = user;
const saveOptions = this.getSaveOptions();
this.abortController = opts.abortController ?? new AbortController();
const conversationId = overrideConvoId ?? opts.conversationId ?? crypto.randomUUID();
const requestConvoId = overrideConvoId ?? opts.conversationId;
const conversationId = requestConvoId ?? crypto.randomUUID();
const parentMessageId = opts.parentMessageId ?? Constants.NO_PARENT;
const userMessageId =
overrideUserMessageId ?? opts.overrideParentMessageId ?? crypto.randomUUID();
@@ -212,11 +213,12 @@ class BaseClient {
...opts,
user,
head,
saveOptions,
userMessageId,
requestConvoId,
conversationId,
parentMessageId,
userMessageId,
responseMessageId,
saveOptions,
};
}
@@ -235,11 +237,12 @@ class BaseClient {
const {
user,
head,
saveOptions,
userMessageId,
requestConvoId,
conversationId,
parentMessageId,
userMessageId,
responseMessageId,
saveOptions,
} = await this.setMessageOptions(opts);
const userMessage = opts.isEdited
@@ -261,7 +264,8 @@ class BaseClient {
}
if (typeof opts?.onStart === 'function') {
opts.onStart(userMessage, responseMessageId);
const isNewConvo = !requestConvoId && parentMessageId === Constants.NO_PARENT;
opts.onStart(userMessage, responseMessageId, isNewConvo);
}
return {

View File

@@ -579,6 +579,8 @@ describe('BaseClient', () => {
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({ text: 'Hello, world!' }),
expect.any(String),
/** `isNewConvo` */
true,
);
});

View File

@@ -3,7 +3,7 @@ const { SerpAPI } = require('@langchain/community/tools/serpapi');
const { Calculator } = require('@langchain/community/tools/calculator');
const { mcpToolPattern, loadWebSearchAuth } = require('@librechat/api');
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
const { Tools, EToolResources, replaceSpecialVars } = require('librechat-data-provider');
const { Tools, Constants, EToolResources, replaceSpecialVars } = require('librechat-data-provider');
const {
availableTools,
manifestToolMap,
@@ -24,9 +24,9 @@ const {
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { createMCPTool, createMCPTools } = require('~/server/services/MCP');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getCachedTools } = require('~/server/services/Config');
const { createMCPTool } = require('~/server/services/MCP');
/**
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
@@ -123,6 +123,8 @@ const getAuthFields = (toolKey) => {
*
* @param {object} object
* @param {string} object.user
* @param {Record<string, Record<string, string>>} [object.userMCPAuthMap]
* @param {AbortSignal} [object.signal]
* @param {Pick<Agent, 'id' | 'provider' | 'model'>} [object.agent]
* @param {string} [object.model]
* @param {EModelEndpoint} [object.endpoint]
@@ -137,7 +139,9 @@ const loadTools = async ({
user,
agent,
model,
signal,
endpoint,
userMCPAuthMap,
tools = [],
options = {},
functions = true,
@@ -231,6 +235,7 @@ const loadTools = async ({
/** @type {Record<string, string>} */
const toolContextMap = {};
const cachedTools = (await getCachedTools({ userId: user, includeGlobal: true })) ?? {};
const requestedMCPTools = {};
for (const tool of tools) {
if (tool === Tools.execute_code) {
@@ -299,14 +304,35 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
};
continue;
} else if (tool && cachedTools && mcpToolPattern.test(tool)) {
requestedTools[tool] = async () =>
const [toolName, serverName] = tool.split(Constants.mcp_delimiter);
if (toolName === Constants.mcp_all) {
const currentMCPGenerator = async (index) =>
createMCPTools({
req: options.req,
res: options.res,
index,
serverName,
userMCPAuthMap,
model: agent?.model ?? model,
provider: agent?.provider ?? endpoint,
signal,
});
requestedMCPTools[serverName] = [currentMCPGenerator];
continue;
}
const currentMCPGenerator = async (index) =>
createMCPTool({
index,
req: options.req,
res: options.res,
toolKey: tool,
userMCPAuthMap,
model: agent?.model ?? model,
provider: agent?.provider ?? endpoint,
signal,
});
requestedMCPTools[serverName] = requestedMCPTools[serverName] || [];
requestedMCPTools[serverName].push(currentMCPGenerator);
continue;
}
@@ -346,6 +372,34 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
}
const loadedTools = (await Promise.all(toolPromises)).flatMap((plugin) => plugin || []);
const mcpToolPromises = [];
/** MCP server tools are initialized sequentially by server */
let index = -1;
for (const [serverName, generators] of Object.entries(requestedMCPTools)) {
index++;
for (const generator of generators) {
try {
if (generator && generators.length === 1) {
mcpToolPromises.push(
generator(index).catch((error) => {
logger.error(`Error loading ${serverName} tools:`, error);
return null;
}),
);
continue;
}
const mcpTool = await generator(index);
if (Array.isArray(mcpTool)) {
loadedTools.push(...mcpTool);
} else if (mcpTool) {
loadedTools.push(mcpTool);
}
} catch (error) {
logger.error(`Error loading MCP tool for server ${serverName}:`, error);
}
}
}
loadedTools.push(...(await Promise.all(mcpToolPromises)).flatMap((plugin) => plugin || []));
return { loadedTools, toolContextMap };
};

View File

@@ -31,7 +31,6 @@ const namespaces = {
[CacheKeys.SAML_SESSION]: sessionCache(CacheKeys.SAML_SESSION),
[CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
[CacheKeys.MCP_TOOLS]: standardCache(CacheKeys.MCP_TOOLS),
[CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
[CacheKeys.STATIC_CONFIG]: standardCache(CacheKeys.STATIC_CONFIG),
[CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ),

View File

@@ -2,7 +2,7 @@ const mongoose = require('mongoose');
const crypto = require('node:crypto');
const { logger } = require('@librechat/data-schemas');
const { ResourceType, SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_all, mcp_delimiter } =
require('librechat-data-provider').Constants;
const {
removeAgentFromAllProjects,
@@ -78,6 +78,7 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
tools.push(Tools.web_search);
}
const addedServers = new Set();
if (mcpServers.size > 0) {
for (const toolName of Object.keys(availableTools)) {
if (!toolName.includes(mcp_delimiter)) {
@@ -85,9 +86,17 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
}
const mcpServer = toolName.split(mcp_delimiter)?.[1];
if (mcpServer && mcpServers.has(mcpServer)) {
addedServers.add(mcpServer);
tools.push(toolName);
}
}
for (const mcpServer of mcpServers) {
if (addedServers.has(mcpServer)) {
continue;
}
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
}
}
const instructions = req.body.promptPrefix;

View File

@@ -4,11 +4,18 @@ const {
getToolkitKey,
checkPluginAuth,
filterUniquePlugins,
convertMCPToolToPlugin,
convertMCPToolsToPlugins,
} = require('@librechat/api');
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
const {
getCachedTools,
setCachedTools,
mergeUserTools,
getCustomConfig,
} = require('~/server/services/Config');
const { loadAndFormatTools } = require('~/server/services/ToolService');
const { availableTools, toolkits } = require('~/app/clients/tools');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache');
const getAvailablePluginsController = async (req, res) => {
@@ -22,6 +29,7 @@ const getAvailablePluginsController = async (req, res) => {
/** @type {{ filteredTools: string[], includedTools: string[] }} */
const { filteredTools = [], includedTools = [] } = req.app.locals;
/** @type {import('@librechat/api').LCManifestTool[]} */
const pluginManifest = availableTools;
const uniquePlugins = filterUniquePlugins(pluginManifest);
@@ -47,45 +55,6 @@ const getAvailablePluginsController = async (req, res) => {
}
};
function createServerToolsCallback() {
/**
* @param {string} serverName
* @param {TPlugin[] | null} serverTools
*/
return async function (serverName, serverTools) {
try {
const mcpToolsCache = getLogStores(CacheKeys.MCP_TOOLS);
if (!serverName || !mcpToolsCache) {
return;
}
await mcpToolsCache.set(serverName, serverTools);
logger.debug(`MCP tools for ${serverName} added to cache.`);
} catch (error) {
logger.error('Error retrieving MCP tools from cache:', error);
}
};
}
function createGetServerTools() {
/**
* Retrieves cached server tools
* @param {string} serverName
* @returns {Promise<TPlugin[] | null>}
*/
return async function (serverName) {
try {
const mcpToolsCache = getLogStores(CacheKeys.MCP_TOOLS);
if (!mcpToolsCache) {
return null;
}
return await mcpToolsCache.get(serverName);
} catch (error) {
logger.error('Error retrieving MCP tools from cache:', error);
return null;
}
};
}
/**
* Retrieves and returns a list of available tools, either from a cache or by reading a plugin manifest file.
*
@@ -101,11 +70,18 @@ function createGetServerTools() {
const getAvailableTools = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
logger.warn('[getAvailableTools] User ID not found in request');
return res.status(401).json({ message: 'Unauthorized' });
}
const customConfig = await getCustomConfig();
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
const cachedUserTools = await getCachedTools({ userId });
const userPlugins = convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig });
const userPlugins =
cachedUserTools != null
? convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig })
: undefined;
if (cachedToolsArray != null && userPlugins != null) {
const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
@@ -113,31 +89,51 @@ const getAvailableTools = async (req, res) => {
return;
}
/** @type {Record<string, FunctionTool> | null} Get tool definitions to filter which tools are actually available */
let toolDefinitions = await getCachedTools({ includeGlobal: true });
let prelimCachedTools;
// TODO: this is a temp fix until app config is refactored
if (!toolDefinitions) {
toolDefinitions = loadAndFormatTools({
adminFilter: req.app.locals?.filteredTools,
adminIncluded: req.app.locals?.includedTools,
directory: req.app.locals?.paths.structuredTools,
});
prelimCachedTools = toolDefinitions;
}
/** @type {import('@librechat/api').LCManifestTool[]} */
let pluginManifest = availableTools;
if (customConfig?.mcpServers != null) {
try {
const mcpManager = getMCPManager();
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
const serverToolsCallback = createServerToolsCallback();
const getServerTools = createGetServerTools();
const mcpTools = await mcpManager.loadManifestTools({
flowManager,
serverToolsCallback,
getServerTools,
});
pluginManifest = [...mcpTools, ...pluginManifest];
const mcpTools = await mcpManager.getAllToolFunctions(userId);
prelimCachedTools = prelimCachedTools ?? {};
for (const [toolKey, toolData] of Object.entries(mcpTools)) {
const plugin = convertMCPToolToPlugin({
toolKey,
toolData,
customConfig,
});
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[]} */
/** @type {TPlugin[]} Deduplicate and authenticate plugins */
const uniquePlugins = filterUniquePlugins(pluginManifest);
const authenticatedPlugins = uniquePlugins.map((plugin) => {
if (checkPluginAuth(plugin)) {
return { ...plugin, authenticated: true };
@@ -146,8 +142,7 @@ const getAvailableTools = async (req, res) => {
}
});
const toolDefinitions = (await getCachedTools({ includeGlobal: true })) || {};
/** Filter plugins based on availability and add MCP-specific auth config */
const toolsOutput = [];
for (const plugin of authenticatedPlugins) {
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
@@ -163,41 +158,36 @@ const getAvailableTools = async (req, res) => {
const toolToAdd = { ...plugin };
if (!plugin.pluginKey.includes(Constants.mcp_delimiter)) {
toolsOutput.push(toolToAdd);
continue;
}
if (plugin.pluginKey.includes(Constants.mcp_delimiter)) {
const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
if (!serverConfig?.customUserVars) {
toolsOutput.push(toolToAdd);
continue;
}
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;
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);
await cache.set(CacheKeys.TOOLS, finalTools);
const dedupedTools = filterUniquePlugins([...userPlugins, ...finalTools]);
const dedupedTools = filterUniquePlugins([...(userPlugins ?? []), ...finalTools]);
res.status(200).json(dedupedTools);
} catch (error) {
logger.error('[getAvailableTools]', error);

View File

@@ -13,15 +13,18 @@ jest.mock('@librechat/data-schemas', () => ({
jest.mock('~/server/services/Config', () => ({
getCustomConfig: jest.fn(),
getCachedTools: jest.fn(),
setCachedTools: jest.fn(),
mergeUserTools: jest.fn(),
}));
jest.mock('~/server/services/ToolService', () => ({
getToolkitKey: jest.fn(),
loadAndFormatTools: jest.fn(),
}));
jest.mock('~/config', () => ({
getMCPManager: jest.fn(() => ({
loadManifestTools: jest.fn().mockResolvedValue([]),
loadAllManifestTools: jest.fn().mockResolvedValue([]),
})),
getFlowStateManager: jest.fn(),
}));
@@ -35,31 +38,31 @@ jest.mock('~/cache', () => ({
getLogStores: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
getToolkitKey: jest.fn(),
checkPluginAuth: jest.fn(),
filterUniquePlugins: jest.fn(),
convertMCPToolsToPlugins: jest.fn(),
}));
// Import the actual module with the function we want to test
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
const {
filterUniquePlugins,
checkPluginAuth,
convertMCPToolsToPlugins,
getToolkitKey,
} = require('@librechat/api');
const { loadAndFormatTools } = require('~/server/services/ToolService');
describe('PluginController', () => {
let mockReq, mockRes, mockCache;
beforeEach(() => {
jest.clearAllMocks();
mockReq = { user: { id: 'test-user-id' } };
mockReq = {
user: { id: 'test-user-id' },
app: {
locals: {
paths: { structuredTools: '/mock/path' },
filteredTools: null,
includedTools: null,
},
},
};
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
mockCache = { get: jest.fn(), set: jest.fn() };
getLogStores.mockReturnValue(mockCache);
// Clear availableTools and toolkits arrays before each test
require('~/app/clients/tools').availableTools.length = 0;
require('~/app/clients/tools').toolkits.length = 0;
});
describe('getAvailablePluginsController', () => {
@@ -68,38 +71,39 @@ describe('PluginController', () => {
});
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
// Add plugins with duplicates to availableTools
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
require('~/app/clients/tools').availableTools.push(...mockPlugins);
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue(mockPlugins);
checkPluginAuth.mockReturnValue(true);
await getAvailablePluginsController(mockReq, mockRes);
expect(filterUniquePlugins).toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(200);
// The response includes authenticated: true for each plugin when checkPluginAuth returns true
expect(mockRes.json).toHaveBeenCalledWith([
{ name: 'Plugin1', pluginKey: 'key1', description: 'First', authenticated: true },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second', authenticated: true },
]);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(2);
expect(responseData[0].pluginKey).toBe('key1');
expect(responseData[1].pluginKey).toBe('key2');
});
it('should use checkPluginAuth to verify plugin authentication', async () => {
// checkPluginAuth returns false for plugins without authConfig
// so authenticated property won't be added
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
require('~/app/clients/tools').availableTools.push(mockPlugin);
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue([mockPlugin]);
checkPluginAuth.mockReturnValueOnce(true);
await getAvailablePluginsController(mockReq, mockRes);
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData[0].authenticated).toBe(true);
// checkPluginAuth returns false, so authenticated property is not added
expect(responseData[0].authenticated).toBeUndefined();
});
it('should return cached plugins when available', async () => {
@@ -111,8 +115,7 @@ describe('PluginController', () => {
await getAvailablePluginsController(mockReq, mockRes);
expect(filterUniquePlugins).not.toHaveBeenCalled();
expect(checkPluginAuth).not.toHaveBeenCalled();
// When cache is hit, we return immediately without processing
expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
});
@@ -122,10 +125,9 @@ describe('PluginController', () => {
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
require('~/app/clients/tools').availableTools.push(...mockPlugins);
mockReq.app.locals.includedTools = ['key1'];
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue(mockPlugins);
checkPluginAuth.mockReturnValue(false);
await getAvailablePluginsController(mockReq, mockRes);
@@ -139,70 +141,102 @@ describe('PluginController', () => {
it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => {
const mockUserTools = {
[`tool1${Constants.mcp_delimiter}server1`]: {
function: { name: 'tool1', description: 'Tool 1' },
type: 'function',
function: {
name: `tool1${Constants.mcp_delimiter}server1`,
description: 'Tool 1',
parameters: { type: 'object', properties: {} },
},
},
};
const mockConvertedPlugins = [
{
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}server1`,
description: 'Tool 1',
},
];
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce(mockUserTools);
convertMCPToolsToPlugins.mockReturnValue(mockConvertedPlugins);
filterUniquePlugins.mockImplementation((plugins) => plugins);
getCustomConfig.mockResolvedValue(null);
// Mock second call to return tool definitions
getCachedTools.mockResolvedValueOnce(mockUserTools);
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: mockUserTools,
customConfig: null,
});
const responseData = mockRes.json.mock.calls[0][0];
// convertMCPToolsToPlugins should have converted the tool
expect(responseData.length).toBeGreaterThan(0);
const convertedTool = responseData.find(
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}server1`,
);
expect(convertedTool).toBeDefined();
expect(convertedTool.name).toBe('tool1');
});
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
const mockUserPlugins = [
{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' },
];
const mockManifestPlugins = [
const mockUserTools = {
'user-tool': {
type: 'function',
function: {
name: 'user-tool',
description: 'User tool',
parameters: { type: 'object', properties: {} },
},
},
};
const mockCachedPlugins = [
{ name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' },
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
];
mockCache.get.mockResolvedValue(mockManifestPlugins);
getCachedTools.mockResolvedValueOnce({});
convertMCPToolsToPlugins.mockReturnValue(mockUserPlugins);
filterUniquePlugins.mockReturnValue([...mockUserPlugins, ...mockManifestPlugins]);
mockCache.get.mockResolvedValue(mockCachedPlugins);
getCachedTools.mockResolvedValueOnce(mockUserTools);
getCustomConfig.mockResolvedValue(null);
// Mock second call to return tool definitions
getCachedTools.mockResolvedValueOnce(mockUserTools);
await getAvailableTools(mockReq, mockRes);
// Should be called to deduplicate the combined array
expect(filterUniquePlugins).toHaveBeenLastCalledWith([
...mockUserPlugins,
...mockManifestPlugins,
]);
const responseData = mockRes.json.mock.calls[0][0];
// Should have deduplicated tools with same pluginKey
const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
expect(userToolCount).toBe(1);
});
it('should use checkPluginAuth to verify authentication status', async () => {
const mockPlugin = { name: 'Tool1', pluginKey: 'tool1', description: 'Tool 1' };
// Add a plugin to availableTools that will be checked
const mockPlugin = {
name: 'Tool1',
pluginKey: 'tool1',
description: 'Tool 1',
// No authConfig means checkPluginAuth returns false
};
require('~/app/clients/tools').availableTools.push(mockPlugin);
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockPlugin]);
checkPluginAuth.mockReturnValue(true);
getCachedTools.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return tool definitions
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ tool1: true });
// Mock loadAndFormatTools to return tool definitions including our tool
loadAndFormatTools.mockReturnValue({
tool1: {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
});
await getAvailableTools(mockReq, mockRes);
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(Array.isArray(responseData)).toBe(true);
const tool = responseData.find((t) => t.pluginKey === 'tool1');
expect(tool).toBeDefined();
// checkPluginAuth returns false, so authenticated property is not added
expect(tool.authenticated).toBeUndefined();
});
it('should use getToolkitKey for toolkit validation', async () => {
@@ -213,22 +247,38 @@ describe('PluginController', () => {
toolkit: true,
};
require('~/app/clients/tools').availableTools.push(mockToolkit);
// Mock toolkits to have a mapping
require('~/app/clients/tools').toolkits.push({
name: 'Toolkit1',
pluginKey: 'toolkit1',
tools: ['toolkit1_function'],
});
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockToolkit]);
checkPluginAuth.mockReturnValue(false);
getToolkitKey.mockReturnValue('toolkit1');
getCachedTools.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return tool definitions
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({
toolkit1_function: true,
// Mock loadAndFormatTools to return tool definitions
loadAndFormatTools.mockReturnValue({
toolkit1_function: {
type: 'function',
function: {
name: 'toolkit1_function',
description: 'Toolkit function',
parameters: {},
},
},
});
await getAvailableTools(mockReq, mockRes);
expect(getToolkitKey).toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(Array.isArray(responseData)).toBe(true);
const toolkit = responseData.find((t) => t.pluginKey === 'toolkit1');
expect(toolkit).toBeDefined();
});
});
@@ -239,32 +289,33 @@ describe('PluginController', () => {
const functionTools = {
[`test-tool${Constants.mcp_delimiter}test-server`]: {
function: { name: 'test-tool', description: 'A test tool' },
type: 'function',
function: {
name: `test-tool${Constants.mcp_delimiter}test-server`,
description: 'A test tool',
parameters: { type: 'object', properties: {} },
},
},
};
const mockConvertedPlugin = {
name: 'test-tool',
pluginKey: `test-tool${Constants.mcp_delimiter}test-server`,
description: 'A test tool',
icon: mcpServers['test-server']?.iconPath,
authenticated: true,
authConfig: [],
// Mock the MCP manager to return tools
const mockMCPManager = {
getAllToolFunctions: jest.fn().mockResolvedValue(functionTools),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
getCachedTools.mockResolvedValueOnce({});
// Mock loadAndFormatTools to return empty object since these are MCP tools
loadAndFormatTools.mockReturnValue({});
getCachedTools.mockResolvedValueOnce(functionTools);
convertMCPToolsToPlugins.mockReturnValue([mockConvertedPlugin]);
filterUniquePlugins.mockImplementation((plugins) => plugins);
checkPluginAuth.mockReturnValue(true);
getToolkitKey.mockReturnValue(undefined);
getCachedTools.mockResolvedValueOnce({
[`test-tool${Constants.mcp_delimiter}test-server`]: true,
});
await getAvailableTools(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
return responseData.find((tool) => tool.name === 'test-tool');
return responseData.find(
(tool) => tool.pluginKey === `test-tool${Constants.mcp_delimiter}test-server`,
);
};
it('should set plugin.icon when iconPath is defined', async () => {
@@ -298,19 +349,21 @@ describe('PluginController', () => {
},
};
// We need to test the actual flow where MCP manager tools are included
const mcpManagerTools = [
{
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
description: 'Tool 1',
authenticated: true,
// 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 = {
loadManifestTools: jest.fn().mockResolvedValue(mcpManagerTools),
getAllToolFunctions: jest.fn().mockResolvedValue(mcpToolFunctions),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
@@ -320,19 +373,11 @@ describe('PluginController', () => {
// First call returns user tools (empty in this case)
getCachedTools.mockResolvedValueOnce({});
// Mock convertMCPToolsToPlugins to return empty array for user tools
convertMCPToolsToPlugins.mockReturnValue([]);
// Mock loadAndFormatTools to return empty object for MCP tools
loadAndFormatTools.mockReturnValue({});
// Mock filterUniquePlugins to pass through
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
// Mock checkPluginAuth
checkPluginAuth.mockReturnValue(true);
// Second call returns tool definitions
getCachedTools.mockResolvedValueOnce({
[`tool1${Constants.mcp_delimiter}test-server`]: true,
});
// Second call returns tool definitions including our MCP tool
getCachedTools.mockResolvedValueOnce(mcpToolFunctions);
await getAvailableTools(mockReq, mockRes);
@@ -373,25 +418,23 @@ describe('PluginController', () => {
it('should handle null cachedTools and cachedUserTools', async () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue(null);
convertMCPToolsToPlugins.mockReturnValue(undefined);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
// Mock loadAndFormatTools to return empty object when getCachedTools returns null
loadAndFormatTools.mockReturnValue({});
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: null,
customConfig: null,
});
// Should handle null values gracefully
expect(mockRes.status).toHaveBeenCalledWith(200);
});
it('should handle when getCachedTools returns undefined', async () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue(undefined);
convertMCPToolsToPlugins.mockReturnValue(undefined);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
checkPluginAuth.mockReturnValue(false);
// Mock loadAndFormatTools to return empty object when getCachedTools returns undefined
loadAndFormatTools.mockReturnValue({});
// Mock getCachedTools to return undefined for both calls
getCachedTools.mockReset();
@@ -399,37 +442,40 @@ describe('PluginController', () => {
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: undefined,
customConfig: null,
});
// Should handle undefined values gracefully
expect(mockRes.status).toHaveBeenCalledWith(200);
});
it('should handle cachedToolsArray and userPlugins 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': { function: { name: 'user-tool', description: 'User tool' } },
[`user-tool${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: {
name: `user-tool${Constants.mcp_delimiter}server1`,
description: 'User tool',
parameters: {},
},
},
};
const userPlugins = [{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' }];
mockCache.get.mockResolvedValue(cachedTools);
getCachedTools.mockResolvedValue(userTools);
convertMCPToolsToPlugins.mockReturnValue(userPlugins);
filterUniquePlugins.mockReturnValue([...userPlugins, ...cachedTools]);
getCustomConfig.mockResolvedValue(null);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([...userPlugins, ...cachedTools]);
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 () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
checkPluginAuth.mockReturnValue(true);
await getAvailableTools(mockReq, mockRes);
@@ -456,18 +502,6 @@ describe('PluginController', () => {
getCustomConfig.mockResolvedValue(customConfig);
getCachedTools.mockResolvedValueOnce(mockUserTools);
const mockPlugin = {
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
description: 'Tool 1',
authenticated: true,
authConfig: [],
};
convertMCPToolsToPlugins.mockReturnValue([mockPlugin]);
filterUniquePlugins.mockImplementation((plugins) => plugins);
checkPluginAuth.mockReturnValue(true);
getCachedTools.mockResolvedValueOnce({
[`tool1${Constants.mcp_delimiter}test-server`]: true,
});
@@ -483,8 +517,6 @@ describe('PluginController', () => {
it('should handle req.app.locals with undefined filteredTools and includedTools', async () => {
mockReq.app = { locals: {} };
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue([]);
checkPluginAuth.mockReturnValue(false);
await getAvailablePluginsController(mockReq, mockRes);
@@ -500,14 +532,25 @@ describe('PluginController', () => {
toolkit: true,
};
// Ensure req.app.locals is properly mocked
mockReq.app = {
locals: {
filteredTools: [],
includedTools: [],
paths: { structuredTools: '/mock/path' },
},
};
// Add the toolkit to availableTools
require('~/app/clients/tools').availableTools.push(mockToolkit);
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockToolkit]);
checkPluginAuth.mockReturnValue(false);
getToolkitKey.mockReturnValue(undefined);
getCustomConfig.mockResolvedValue(null);
// Mock loadAndFormatTools to return an empty object when toolDefinitions is null
loadAndFormatTools.mockReturnValue({});
// Mock getCachedTools second call to return null
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce(null);

View File

@@ -33,18 +33,13 @@ const {
bedrockInputSchema,
removeNullishValues,
} = require('librechat-data-provider');
const {
findPluginAuthsByKeys,
getFormattedMemories,
deleteMemory,
setMemory,
} = require('~/models');
const { getMCPAuthMap, checkCapability, hasCustomUserVars } = require('~/server/services/Config');
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { getProviderConfig } = require('~/server/services/Endpoints');
const { checkCapability } = require('~/server/services/Config');
const BaseClient = require('~/app/clients/BaseClient');
const { getRoleByName } = require('~/models/Role');
const { loadAgent } = require('~/models/Agent');
@@ -615,6 +610,7 @@ class AgentClient extends BaseClient {
await this.chatCompletion({
payload,
onProgress: opts.onProgress,
userMCPAuthMap: opts.userMCPAuthMap,
abortController: opts.abortController,
});
return this.contentParts;
@@ -747,7 +743,13 @@ class AgentClient extends BaseClient {
return currentMessageTokens > 0 ? currentMessageTokens : originalEstimate;
}
async chatCompletion({ payload, abortController = null }) {
/**
* @param {object} params
* @param {string | ChatCompletionMessageParam[]} params.payload
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
* @param {AbortController} [params.abortController]
*/
async chatCompletion({ payload, userMCPAuthMap, abortController = null }) {
/** @type {Partial<GraphRunnableConfig>} */
let config;
/** @type {ReturnType<createRun>} */
@@ -903,21 +905,9 @@ class AgentClient extends BaseClient {
run.Graph.contentData = contentData;
}
try {
if (await hasCustomUserVars()) {
config.configurable.userMCPAuthMap = await getMCPAuthMap({
tools: agent.tools,
userId: this.options.req.user.id,
findPluginAuthsByKeys,
});
}
} catch (err) {
logger.error(
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent ${agent.id}`,
err,
);
if (userMCPAuthMap != null) {
config.configurable.userMCPAuthMap = userMCPAuthMap;
}
await run.processStream({ messages }, config, {
keepContent: i !== 0,
tokenCounter: createTokenCounter(this.getEncoding()),

View File

@@ -9,6 +9,24 @@ const {
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
const { saveMessage } = require('~/models');
function createCloseHandler(abortController) {
return function (manual) {
if (!manual) {
logger.debug('[AgentController] Request closed');
}
if (!abortController) {
return;
} else if (abortController.signal.aborted) {
return;
} else if (abortController.requestCompleted) {
return;
}
abortController.abort();
logger.debug('[AgentController] Request aborted on close');
};
}
const AgentController = async (req, res, next, initializeClient, addTitle) => {
let {
text,
@@ -31,7 +49,6 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
let userMessagePromise;
let getAbortData;
let client = null;
// Initialize as an array
let cleanupHandlers = [];
const newConvo = !conversationId;
@@ -62,9 +79,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
// Create a function to handle final cleanup
const performCleanup = () => {
logger.debug('[AgentController] Performing cleanup');
// Make sure cleanupHandlers is an array before iterating
if (Array.isArray(cleanupHandlers)) {
// Execute all cleanup handlers
for (const handler of cleanupHandlers) {
try {
if (typeof handler === 'function') {
@@ -105,8 +120,33 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
};
try {
/** @type {{ client: TAgentClient }} */
const result = await initializeClient({ req, res, endpointOption });
let prelimAbortController = new AbortController();
const prelimCloseHandler = createCloseHandler(prelimAbortController);
res.on('close', prelimCloseHandler);
const removePrelimHandler = (manual) => {
try {
prelimCloseHandler(manual);
res.removeListener('close', prelimCloseHandler);
} catch (e) {
logger.error('[AgentController] Error removing close listener', e);
}
};
cleanupHandlers.push(removePrelimHandler);
/** @type {{ client: TAgentClient; userMCPAuthMap?: Record<string, Record<string, string>> }} */
const result = await initializeClient({
req,
res,
endpointOption,
signal: prelimAbortController.signal,
});
if (prelimAbortController.signal?.aborted) {
prelimAbortController = null;
throw new Error('Request was aborted before initialization could complete');
} else {
prelimAbortController = null;
removePrelimHandler(true);
cleanupHandlers.pop();
}
client = result.client;
// Register client with finalization registry if available
@@ -138,22 +178,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
};
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
// Simple handler to avoid capturing scope
const closeHandler = () => {
logger.debug('[AgentController] Request closed');
if (!abortController) {
return;
} else if (abortController.signal.aborted) {
return;
} else if (abortController.requestCompleted) {
return;
}
abortController.abort();
logger.debug('[AgentController] Request aborted on close');
};
const closeHandler = createCloseHandler(abortController);
res.on('close', closeHandler);
cleanupHandlers.push(() => {
try {
@@ -175,6 +200,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
abortController,
overrideParentMessageId,
isEdited: !!editedContent,
userMCPAuthMap: result.userMCPAuthMap,
responseMessageId: editedResponseMessageId,
progressOptions: {
res,

View File

@@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { countTokens, isEnabled, sendEvent } = require('@librechat/api');
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const { isAssistantsEndpoint, ErrorTypes, Constants } = require('librechat-data-provider');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const clearPendingReq = require('~/cache/clearPendingReq');
const { sendError } = require('~/server/middleware/error');
@@ -11,6 +11,10 @@ const { abortRun } = require('./abortRun');
const abortDataMap = new WeakMap();
/**
* @param {string} abortKey
* @returns {boolean}
*/
function cleanupAbortController(abortKey) {
if (!abortControllers.has(abortKey)) {
return false;
@@ -71,6 +75,20 @@ function cleanupAbortController(abortKey) {
return true;
}
/**
* @param {string} abortKey
* @returns {function(): void}
*/
function createCleanUpHandler(abortKey) {
return function () {
try {
cleanupAbortController(abortKey);
} catch {
// Ignore cleanup errors
}
};
}
async function abortMessage(req, res) {
let { abortKey, endpoint } = req.body;
@@ -172,11 +190,15 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
/**
* @param {TMessage} userMessage
* @param {string} responseMessageId
* @param {boolean} [isNewConvo]
*/
const onStart = (userMessage, responseMessageId) => {
const onStart = (userMessage, responseMessageId, isNewConvo) => {
sendEvent(res, { message: userMessage, created: true });
const abortKey = userMessage?.conversationId ?? req.user.id;
const prelimAbortKey = userMessage?.conversationId ?? req.user.id;
const abortKey = isNewConvo
? `${prelimAbortKey}${Constants.COMMON_DIVIDER}${Constants.NEW_CONVO}`
: prelimAbortKey;
getReqData({ abortKey });
const prevRequest = abortControllers.get(abortKey);
const { overrideUserMessageId } = req?.body ?? {};
@@ -194,16 +216,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
};
abortControllers.set(addedAbortKey, { abortController, ...minimalOptions });
// Use a simple function for cleanup to avoid capturing context
const cleanupHandler = () => {
try {
cleanupAbortController(addedAbortKey);
} catch (e) {
// Ignore cleanup errors
}
};
const cleanupHandler = createCleanUpHandler(addedAbortKey);
res.on('finish', cleanupHandler);
return;
}
@@ -216,16 +229,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
};
abortControllers.set(abortKey, { abortController, ...minimalOptions });
// Use a simple function for cleanup to avoid capturing context
const cleanupHandler = () => {
try {
cleanupAbortController(abortKey);
} catch (e) {
// Ignore cleanup errors
}
};
const cleanupHandler = createCleanUpHandler(abortKey);
res.on('finish', cleanupHandler);
};
@@ -364,15 +368,7 @@ const handleAbortError = async (res, req, error, data) => {
};
}
// Create a simple callback without capturing parent scope
const callback = async () => {
try {
cleanupAbortController(conversationId);
} catch (e) {
// Ignore cleanup errors
}
};
const callback = createCleanUpHandler(conversationId);
await sendError(req, res, options, callback);
};

View File

@@ -1,15 +1,17 @@
const { MongoMemoryServer } = require('mongodb-memory-server');
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const express = require('express');
const { MongoMemoryServer } = require('mongodb-memory-server');
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
MCPOAuthHandler: {
initiateOAuthFlow: jest.fn(),
getFlowState: jest.fn(),
completeOAuthFlow: jest.fn(),
generateFlowId: jest.fn(),
},
getUserMCPAuthMap: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
@@ -36,6 +38,7 @@ jest.mock('~/models', () => ({
updateToken: jest.fn(),
createToken: jest.fn(),
deleteTokens: jest.fn(),
findPluginAuthsByKeys: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
@@ -44,6 +47,10 @@ jest.mock('~/server/services/Config', () => ({
loadCustomConfig: jest.fn(),
}));
jest.mock('~/server/services/Config/mcpToolsCache', () => ({
updateMCPUserTools: jest.fn(),
}));
jest.mock('~/server/services/MCP', () => ({
getMCPSetupData: jest.fn(),
getServerConnectionStatus: jest.fn(),
@@ -66,6 +73,10 @@ jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, res, next) => next(),
}));
jest.mock('~/server/services/Tools/mcp', () => ({
reinitMCPServer: jest.fn(),
}));
describe('MCP Routes', () => {
let app;
let mongoServer;
@@ -677,6 +688,13 @@ describe('MCP Routes', () => {
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
success: true,
message: "MCP server 'oauth-server' ready for OAuth authentication",
serverName: 'oauth-server',
oauthRequired: true,
oauthUrl: 'https://oauth.example.com/auth',
});
const response = await request(app).post('/api/mcp/oauth-server/reinitialize');
@@ -701,6 +719,7 @@ describe('MCP Routes', () => {
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue(null);
const response = await request(app).post('/api/mcp/error-server/reinitialize');
@@ -759,8 +778,18 @@ describe('MCP Routes', () => {
require('~/cache').getLogStores.mockReturnValue({});
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
getCachedTools.mockResolvedValue({});
setCachedTools.mockResolvedValue();
updateMCPUserTools.mockResolvedValue();
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
success: true,
message: "MCP server 'test-server' reinitialized successfully",
serverName: 'test-server',
oauthRequired: false,
oauthUrl: null,
});
const response = await request(app).post('/api/mcp/test-server/reinitialize');
@@ -776,7 +805,6 @@ describe('MCP Routes', () => {
'test-user-id',
'test-server',
);
expect(setCachedTools).toHaveBeenCalled();
});
it('should handle server with custom user variables', async () => {
@@ -798,21 +826,38 @@ describe('MCP Routes', () => {
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
require('~/server/services/PluginService').getUserPluginAuthValue.mockResolvedValue(
'api-key-value',
);
require('@librechat/api').getUserMCPAuthMap.mockResolvedValue({
'mcp:test-server': {
API_KEY: 'api-key-value',
},
});
require('~/models').findPluginAuthsByKeys.mockResolvedValue([
{ key: 'API_KEY', value: 'api-key-value' },
]);
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
getCachedTools.mockResolvedValue({});
setCachedTools.mockResolvedValue();
updateMCPUserTools.mockResolvedValue();
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
success: true,
message: "MCP server 'test-server' reinitialized successfully",
serverName: 'test-server',
oauthRequired: false,
oauthUrl: null,
});
const response = await request(app).post('/api/mcp/test-server/reinitialize');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(
require('~/server/services/PluginService').getUserPluginAuthValue,
).toHaveBeenCalledWith('test-user-id', 'API_KEY', false);
expect(require('@librechat/api').getUserMCPAuthMap).toHaveBeenCalledWith({
userId: 'test-user-id',
servers: ['test-server'],
findPluginAuthsByKeys: require('~/models').findPluginAuthsByKeys,
});
});
});

View File

@@ -1,13 +1,15 @@
const { logger } = require('@librechat/data-schemas');
const { MCPOAuthHandler } = require('@librechat/api');
const { Router } = require('express');
const { logger } = require('@librechat/data-schemas');
const { MCPOAuthHandler, getUserMCPAuthMap } = require('@librechat/api');
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
const { setCachedTools, getCachedTools } = require('~/server/services/Config');
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { requireJwtAuth } = require('~/server/middleware');
const { findPluginAuthsByKeys } = require('~/models');
const { getLogStores } = require('~/cache');
const router = Router();
@@ -142,33 +144,12 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
);
const userTools = (await getCachedTools({ userId: flowState.userId })) || {};
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
delete userTools[key];
}
}
const tools = await userConnection.fetchTools();
for (const tool of tools) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
userTools[name] = {
type: 'function',
['function']: {
name,
description: tool.description,
parameters: tool.inputSchema,
},
};
}
await setCachedTools(userTools, { userId: flowState.userId });
logger.debug(
`[MCP OAuth] Cached ${tools.length} tools for ${serverName} user ${flowState.userId}`,
);
await updateMCPUserTools({
userId: flowState.userId,
serverName,
tools,
});
} else {
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
}
@@ -323,124 +304,39 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
});
}
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
await mcpManager.disconnectUserConnection(user.id, serverName);
logger.info(
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
);
let customUserVars = {};
/** @type {Record<string, Record<string, string>> | undefined} */
let userMCPAuthMap;
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
for (const varName of Object.keys(serverConfig.customUserVars)) {
try {
const value = await getUserPluginAuthValue(user.id, varName, false);
customUserVars[varName] = value;
} catch (err) {
logger.error(`[MCP Reinitialize] Error fetching ${varName} for user ${user.id}:`, err);
}
}
}
let userConnection = null;
let oauthRequired = false;
let oauthUrl = null;
try {
userConnection = await mcpManager.getUserConnection({
user,
serverName,
flowManager,
customUserVars,
tokenMethods: {
findToken,
updateToken,
createToken,
deleteTokens,
},
returnOnOAuth: true,
oauthStart: async (authURL) => {
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
oauthUrl = authURL;
oauthRequired = true;
},
userMCPAuthMap = await getUserMCPAuthMap({
userId: user.id,
servers: [serverName],
findPluginAuthsByKeys,
});
logger.info(`[MCP Reinitialize] Successfully established connection for ${serverName}`);
} catch (err) {
logger.info(`[MCP Reinitialize] getUserConnection threw error: ${err.message}`);
logger.info(
`[MCP Reinitialize] OAuth state - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
const isOAuthError =
err.message?.includes('OAuth') ||
err.message?.includes('authentication') ||
err.message?.includes('401');
const isOAuthFlowInitiated = err.message === 'OAuth flow initiated - return early';
if (isOAuthError || oauthRequired || isOAuthFlowInitiated) {
logger.info(
`[MCP Reinitialize] OAuth required for ${serverName} (isOAuthError: ${isOAuthError}, oauthRequired: ${oauthRequired}, isOAuthFlowInitiated: ${isOAuthFlowInitiated})`,
);
oauthRequired = true;
} else {
logger.error(
`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`,
err,
);
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
}
}
if (userConnection && !oauthRequired) {
const userTools = (await getCachedTools({ userId: user.id })) || {};
const result = await reinitMCPServer({
req,
serverName,
userMCPAuthMap,
});
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
delete userTools[key];
}
}
const tools = await userConnection.fetchTools();
for (const tool of tools) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
userTools[name] = {
type: 'function',
['function']: {
name,
description: tool.description,
parameters: tool.inputSchema,
},
};
}
await setCachedTools(userTools, { userId: user.id });
if (!result) {
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
}
logger.debug(
`[MCP Reinitialize] Sending response for ${serverName} - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
const getResponseMessage = () => {
if (oauthRequired) {
return `MCP server '${serverName}' ready for OAuth authentication`;
}
if (userConnection) {
return `MCP server '${serverName}' reinitialized successfully`;
}
return `Failed to reinitialize MCP server '${serverName}'`;
};
const { success, message, oauthRequired, oauthUrl } = result;
res.json({
success: Boolean((userConnection && !oauthRequired) || (oauthRequired && oauthUrl)),
message: getResponseMessage(),
success,
message,
oauthUrl,
serverName,
oauthRequired,
oauthUrl,
});
} catch (error) {
logger.error('[MCP Reinitialize] Unexpected error', error);

View File

@@ -26,7 +26,7 @@ const ToolCacheKeys = {
* @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<Object|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 = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
@@ -41,13 +41,13 @@ async function getCachedTools(options = {}) {
// Future implementation will merge tools from multiple sources
// based on user permissions, roles, and groups
if (userId) {
// Check if we have pre-computed effective tools for this user
/** @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;
}
// Otherwise, compute from individual sources
/** @type {LCAvailableTools | null} Otherwise, compute from individual sources */
const toolSources = [];
if (includeGlobal) {

View File

@@ -1,5 +1,4 @@
const { logger } = require('@librechat/data-schemas');
const { isEnabled, getUserMCPAuthMap } = require('@librechat/api');
const { isEnabled } = require('@librechat/api');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
const { normalizeEndpointName } = require('~/server/utils');
const loadCustomConfig = require('./loadCustomConfig');
@@ -53,31 +52,6 @@ const getCustomEndpointConfig = async (endpoint) => {
);
};
/**
* @param {Object} params
* @param {string} params.userId
* @param {GenericTool[]} [params.tools]
* @param {import('@librechat/data-schemas').PluginAuthMethods['findPluginAuthsByKeys']} params.findPluginAuthsByKeys
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
*/
async function getMCPAuthMap({ userId, tools, findPluginAuthsByKeys }) {
try {
if (!tools || tools.length === 0) {
return;
}
return await getUserMCPAuthMap({
tools,
userId,
findPluginAuthsByKeys,
});
} catch (err) {
logger.error(
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
err,
);
}
}
/**
* @returns {Promise<boolean>}
*/
@@ -88,7 +62,6 @@ async function hasCustomUserVars() {
}
module.exports = {
getMCPAuthMap,
getCustomConfig,
getBalanceConfig,
hasCustomUserVars,

View File

@@ -1,6 +1,7 @@
const { config } = require('./EndpointService');
const getCachedTools = require('./getCachedTools');
const getCustomConfig = require('./getCustomConfig');
const mcpToolsCache = require('./mcpToolsCache');
const loadCustomConfig = require('./loadCustomConfig');
const loadConfigModels = require('./loadConfigModels');
const loadDefaultModels = require('./loadDefaultModels');
@@ -17,5 +18,6 @@ module.exports = {
loadAsyncEndpoints,
...getCachedTools,
...getCustomConfig,
...mcpToolsCache,
...getEndpointsConfig,
};

View File

@@ -76,10 +76,11 @@ async function loadConfigModels(req) {
fetchPromisesMap[uniqueKey] =
fetchPromisesMap[uniqueKey] ||
fetchModels({
user: req.user.id,
baseURL: BASE_URL,
apiKey: API_KEY,
name,
apiKey: API_KEY,
baseURL: BASE_URL,
user: req.user.id,
direct: endpoint.directEndpoint,
userIdQuery: models.userIdQuery,
});
uniqueKeyToEndpointsMap[uniqueKey] = uniqueKeyToEndpointsMap[uniqueKey] || [];

View File

@@ -0,0 +1,143 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getCachedTools, setCachedTools } = require('./getCachedTools');
const { getLogStores } = require('~/cache');
/**
* Updates MCP tools in the cache for a specific server and user
* @param {Object} params - Parameters for updating MCP tools
* @param {string} params.userId - User ID
* @param {string} params.serverName - MCP server name
* @param {Array} params.tools - Array of tool objects from MCP server
* @returns {Promise<LCAvailableTools>}
*/
async function updateMCPUserTools({ userId, serverName, tools }) {
try {
const userTools = await getCachedTools({ userId });
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) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
userTools[name] = {
type: 'function',
['function']: {
name,
description: tool.description,
parameters: tool.inputSchema,
},
};
}
await setCachedTools(userTools, { userId });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`[MCP Cache] Updated ${tools.length} tools for ${serverName} user ${userId}`);
return userTools;
} catch (error) {
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
throw error;
}
}
/**
* Merges app-level tools with global tools
* @param {import('@librechat/api').LCAvailableTools} appTools
* @returns {Promise<void>}
*/
async function mergeAppTools(appTools) {
try {
const count = Object.keys(appTools).length;
if (!count) {
return;
}
const cachedTools = await getCachedTools({ includeGlobal: true });
const mergedTools = { ...cachedTools, ...appTools };
await setCachedTools(mergedTools, { isGlobal: true });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`Merged ${count} app-level tools`);
} catch (error) {
logger.error('Failed to merge app-level tools:', error);
throw error;
}
}
/**
* Merges user-level tools with global tools
* @param {object} params
* @param {string} params.userId
* @param {Record<string, FunctionTool>} params.cachedUserTools
* @param {import('@librechat/api').LCAvailableTools} params.userTools
* @returns {Promise<void>}
*/
async function mergeUserTools({ userId, cachedUserTools, userTools }) {
try {
if (!userId) {
return;
}
const count = Object.keys(userTools).length;
if (!count) {
return;
}
const cachedTools = cachedUserTools ?? (await getCachedTools({ userId }));
const mergedTools = { ...cachedTools, ...userTools };
await setCachedTools(mergedTools, { userId });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`Merged ${count} user-level tools`);
} catch (error) {
logger.error('Failed to merge user-level tools:', error);
throw error;
}
}
/**
* Clears all MCP tools for a specific server
* @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
* @returns {Promise<void>}
*/
async function clearMCPServerTools({ userId, serverName }) {
try {
const tools = await getCachedTools({ userId, includeGlobal: !userId });
// Remove all tools for this server
const mcpDelimiter = Constants.mcp_delimiter;
let removedCount = 0;
for (const key of Object.keys(tools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
delete tools[key];
removedCount++;
}
}
if (removedCount > 0) {
await setCachedTools(tools, userId ? { userId } : { isGlobal: true });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(
`[MCP Cache] Removed ${removedCount} tools for ${serverName}${userId ? ` user ${userId}` : ' (global)'}`,
);
}
} catch (error) {
logger.error(`[MCP Cache] Failed to clear tools for ${serverName}:`, error);
throw error;
}
}
module.exports = {
mergeAppTools,
mergeUserTools,
updateMCPUserTools,
clearMCPServerTools,
};

View File

@@ -30,7 +30,13 @@ const { getModelMaxTokens } = require('~/utils');
* @param {TEndpointOption} [params.endpointOption]
* @param {Set<string>} [params.allowedProviders]
* @param {boolean} [params.isInitialAgent]
* @returns {Promise<Agent & { tools: StructuredTool[], attachments: Array<MongoFile>, toolContextMap: Record<string, unknown>, maxContextTokens: number }>}
* @returns {Promise<Agent & {
* tools: StructuredTool[],
* attachments: Array<MongoFile>,
* toolContextMap: Record<string, unknown>,
* maxContextTokens: number,
* userMCPAuthMap?: Record<string, Record<string, string>>
* }>}
*/
const initializeAgent = async ({
req,
@@ -91,16 +97,19 @@ const initializeAgent = async ({
});
const provider = agent.provider;
const { tools: structuredTools, toolContextMap } =
(await loadTools?.({
req,
res,
provider,
agentId: agent.id,
tools: agent.tools,
model: agent.model,
tool_resources,
})) ?? {};
const {
tools: structuredTools,
toolContextMap,
userMCPAuthMap,
} = (await loadTools?.({
req,
res,
provider,
agentId: agent.id,
tools: agent.tools,
model: agent.model,
tool_resources,
})) ?? {};
agent.endpoint = provider;
const { getOptions, overrideProvider } = await getProviderConfig(provider);
@@ -189,6 +198,7 @@ const initializeAgent = async ({
tools,
attachments,
resendFiles,
userMCPAuthMap,
toolContextMap,
useLegacyContent: !!options.useLegacyContent,
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9),

View File

@@ -19,7 +19,10 @@ const AgentClient = require('~/server/controllers/agents/client');
const { getAgent } = require('~/models/Agent');
const { logViolation } = require('~/cache');
function createToolLoader() {
/**
* @param {AbortSignal} signal
*/
function createToolLoader(signal) {
/**
* @param {object} params
* @param {ServerRequest} params.req
@@ -29,7 +32,11 @@ function createToolLoader() {
* @param {string} params.provider
* @param {string} params.model
* @param {AgentToolResources} params.tool_resources
* @returns {Promise<{ tools: StructuredTool[], toolContextMap: Record<string, unknown> } | undefined>}
* @returns {Promise<{
* tools: StructuredTool[],
* toolContextMap: Record<string, unknown>,
* userMCPAuthMap?: Record<string, Record<string, string>>
* } | undefined>}
*/
return async function loadTools({ req, res, agentId, tools, provider, model, tool_resources }) {
const agent = { id: agentId, tools, provider, model };
@@ -38,6 +45,7 @@ function createToolLoader() {
req,
res,
agent,
signal,
tool_resources,
});
} catch (error) {
@@ -46,7 +54,7 @@ function createToolLoader() {
};
}
const initializeClient = async ({ req, res, endpointOption }) => {
const initializeClient = async ({ req, res, signal, endpointOption }) => {
if (!endpointOption) {
throw new Error('Endpoint option not provided');
}
@@ -92,7 +100,7 @@ const initializeClient = async ({ req, res, endpointOption }) => {
/** @type {Set<string>} */
const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders);
const loadTools = createToolLoader();
const loadTools = createToolLoader(signal);
/** @type {Array<MongoFile>} */
const requestFiles = req.body.files ?? [];
/** @type {string} */
@@ -111,6 +119,7 @@ const initializeClient = async ({ req, res, endpointOption }) => {
});
const agent_ids = primaryConfig.agent_ids;
let userMCPAuthMap = primaryConfig.userMCPAuthMap;
if (agent_ids?.length) {
for (const agentId of agent_ids) {
const agent = await getAgent({ id: agentId });
@@ -140,6 +149,7 @@ const initializeClient = async ({ req, res, endpointOption }) => {
endpointOption,
allowedProviders,
});
Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {});
agentConfigs.set(agentId, config);
}
}
@@ -188,7 +198,7 @@ const initializeClient = async ({ req, res, endpointOption }) => {
: EModelEndpoint.agents,
});
return { client };
return { client, userMCPAuthMap };
};
module.exports = { initializeClient };

View File

@@ -1,8 +1,8 @@
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const { isEnabled } = require('~/server/utils');
const { saveConvo } = require('~/models');
const { logger } = require('~/config');
/**
* Add title to conversation in a way that avoids memory retention

View File

@@ -39,8 +39,9 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
if (optionsOnly) {
clientOptions = Object.assign(
{
reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? null,
proxy: PROXY ?? null,
userId: req.user.id,
reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? null,
modelOptions: endpointOption?.model_parameters ?? {},
},
clientOptions,

View File

@@ -15,6 +15,7 @@ const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = requir
* @param {number} [options.modelOptions.topK] - Controls the number of top tokens to consider.
* @param {string[]} [options.modelOptions.stop] - Sequences where the API will stop generating further tokens.
* @param {boolean} [options.modelOptions.stream] - Whether to stream the response.
* @param {string} options.userId - The user ID for tracking and personalization.
* @param {string} [options.proxy] - Proxy server URL.
* @param {string} [options.reverseProxyUrl] - URL for a reverse proxy, if used.
*
@@ -47,6 +48,11 @@ function getLLMConfig(apiKey, options = {}) {
maxTokens:
mergedOptions.maxOutputTokens || anthropicSettings.maxOutputTokens.reset(mergedOptions.model),
clientOptions: {},
invocationKwargs: {
metadata: {
user_id: options.userId,
},
},
};
requestOptions = configureReasoning(requestOptions, systemOptions);

View File

@@ -1,7 +1,12 @@
const { z } = require('zod');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents');
const {
Providers,
StepTypes,
GraphEvents,
Constants: AgentConstants,
} = require('@librechat/agents');
const {
sendEvent,
MCPOAuthHandler,
@@ -11,14 +16,14 @@ const {
const {
Time,
CacheKeys,
StepTypes,
Constants,
ContentTypes,
isAssistantsEndpoint,
} = require('librechat-data-provider');
const { getCachedTools, loadCustomConfig } = require('./Config');
const { findToken, createToken, updateToken } = require('~/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getCachedTools, loadCustomConfig } = require('./Config');
const { reinitMCPServer } = require('./Tools/mcp');
const { getLogStores } = require('~/cache');
/**
@@ -26,16 +31,13 @@ const { getLogStores } = require('~/cache');
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.stepId - The ID of the step in the flow.
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
* @param {string} params.loginFlowId - The ID of the login flow.
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
*/
function createOAuthStart({ res, stepId, toolCall, loginFlowId, flowManager, signal }) {
function createRunStepDeltaEmitter({ res, stepId, toolCall }) {
/**
* Creates a function to handle OAuth login requests.
* @param {string} authURL - The URL to redirect the user for OAuth authentication.
* @returns {Promise<boolean>} Returns true to indicate the event was sent successfully.
* @returns {void}
*/
return async function (authURL) {
return function (authURL) {
/** @type {{ id: string; delta: AgentToolCallDelta }} */
const data = {
id: stepId,
@@ -46,17 +48,54 @@ function createOAuthStart({ res, stepId, toolCall, loginFlowId, flowManager, sig
expires_at: Date.now() + Time.TWO_MINUTES,
},
};
/** Used to ensure the handler (use of `sendEvent`) is only invoked once */
await flowManager.createFlowWithHandler(
loginFlowId,
'oauth_login',
async () => {
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
logger.debug('Sent OAuth login request to client');
return true;
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
};
}
/**
* @param {object} params
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.runId - The Run ID, i.e. message ID
* @param {string} params.stepId - The ID of the step in the flow.
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
* @param {number} [params.index]
*/
function createRunStepEmitter({ res, runId, stepId, toolCall, index }) {
return function () {
/** @type {import('@librechat/agents').RunStep} */
const data = {
runId: runId ?? Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
id: stepId,
type: StepTypes.TOOL_CALLS,
index: index ?? 0,
stepDetails: {
type: StepTypes.TOOL_CALLS,
tool_calls: [toolCall],
},
signal,
);
};
sendEvent(res, { event: GraphEvents.ON_RUN_STEP, data });
};
}
/**
* Creates a function used to ensure the flow handler is only invoked once
* @param {object} params
* @param {string} params.flowId - The ID of the login flow.
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
* @param {(authURL: string) => void} [params.callback]
*/
function createOAuthStart({ flowId, flowManager, callback }) {
/**
* Creates a function to handle OAuth login requests.
* @param {string} authURL - The URL to redirect the user for OAuth authentication.
* @returns {Promise<boolean>} Returns true to indicate the event was sent successfully.
*/
return async function (authURL) {
await flowManager.createFlowWithHandler(flowId, 'oauth_login', async () => {
callback?.(authURL);
logger.debug('Sent OAuth login request to client');
return true;
});
};
}
@@ -99,23 +138,166 @@ function createAbortHandler({ userId, serverName, toolName, flowManager }) {
}
/**
* Creates a general tool for an entire action set.
* @param {Object} params
* @param {() => void} params.runStepEmitter
* @param {(authURL: string) => void} params.runStepDeltaEmitter
* @returns {(authURL: string) => void}
*/
function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) {
return function (authURL) {
runStepEmitter();
runStepDeltaEmitter(authURL);
};
}
/**
* @param {Object} params
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.serverName
* @param {AbortSignal} params.signal
* @param {string} params.model
* @param {number} [params.index]
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
* @returns { Promise<Array<typeof tool | { _call: (toolInput: Object | string) => unknown}>> } An object with `_call` method to execute the tool input.
*/
async function reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap }) {
const runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID;
const flowId = `${req.user?.id}:${serverName}:${Date.now()}`;
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const stepId = 'step_oauth_login_' + serverName;
const toolCall = {
id: flowId,
name: serverName,
type: 'tool_call_chunk',
};
const runStepEmitter = createRunStepEmitter({
res,
index,
runId,
stepId,
toolCall,
});
const runStepDeltaEmitter = createRunStepDeltaEmitter({
res,
stepId,
toolCall,
});
const callback = createOAuthCallback({ runStepEmitter, runStepDeltaEmitter });
const oauthStart = createOAuthStart({
res,
flowId,
callback,
flowManager,
});
return await reinitMCPServer({
req,
signal,
serverName,
oauthStart,
flowManager,
userMCPAuthMap,
forceNew: true,
returnOnOAuth: false,
connectionTimeout: Time.TWO_MINUTES,
});
}
/**
* Creates all tools from the specified MCP Server via `toolKey`.
*
* @param {Object} params - The parameters for loading action sets.
* This function assumes tools could not be aggregated from the cache of tool definitions,
* i.e. `availableTools`, and will reinitialize the MCP server to ensure all tools are generated.
*
* @param {Object} params
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.serverName
* @param {string} params.model
* @param {Providers | EModelEndpoint} params.provider - The provider for the tool.
* @param {number} [params.index]
* @param {AbortSignal} [params.signal]
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
* @returns { Promise<Array<typeof tool | { _call: (toolInput: Object | string) => unknown}>> } An object with `_call` method to execute the tool input.
*/
async function createMCPTools({ req, res, index, signal, serverName, provider, userMCPAuthMap }) {
const result = await reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap });
if (!result || !result.tools) {
logger.warn(`[MCP][${serverName}] Failed to reinitialize MCP server.`);
return;
}
const serverTools = [];
for (const tool of result.tools) {
const toolInstance = await createMCPTool({
req,
res,
provider,
userMCPAuthMap,
availableTools: result.availableTools,
toolKey: `${tool.name}${Constants.mcp_delimiter}${serverName}`,
});
if (toolInstance) {
serverTools.push(toolInstance);
}
}
return serverTools;
}
/**
* Creates a single tool from the specified MCP Server via `toolKey`.
* @param {Object} params
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.toolKey - The toolKey for the tool.
* @param {import('@librechat/agents').Providers | EModelEndpoint} params.provider - The provider for the tool.
* @param {string} params.model - The model for the tool.
* @param {number} [params.index]
* @param {AbortSignal} [params.signal]
* @param {Providers | EModelEndpoint} params.provider - The provider for the tool.
* @param {LCAvailableTools} [params.availableTools]
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
*/
async function createMCPTool({ req, res, toolKey, provider: _provider }) {
const availableTools = await getCachedTools({ userId: req.user?.id, includeGlobal: true });
const toolDefinition = availableTools?.[toolKey]?.function;
async function createMCPTool({
req,
res,
index,
signal,
toolKey,
provider,
userMCPAuthMap,
availableTools: tools,
}) {
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
const availableTools =
tools ?? (await getCachedTools({ userId: req.user?.id, includeGlobal: true }));
/** @type {LCTool | undefined} */
let toolDefinition = availableTools?.[toolKey]?.function;
if (!toolDefinition) {
logger.error(`Tool ${toolKey} not found in available tools`);
return null;
logger.warn(
`[MCP][${serverName}][${toolName}] Requested tool not found in available tools, re-initializing MCP server.`,
);
const result = await reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap });
toolDefinition = result?.availableTools?.[toolKey]?.function;
}
if (!toolDefinition) {
logger.warn(`[MCP][${serverName}][${toolName}] Tool definition not found, cannot create tool.`);
return;
}
return createToolInstance({
res,
provider,
toolName,
serverName,
toolDefinition,
});
}
function createToolInstance({ res, toolName, serverName, toolDefinition, provider: _provider }) {
/** @type {LCTool} */
const { description, parameters } = toolDefinition;
const isGoogle = _provider === Providers.VERTEXAI || _provider === Providers.GOOGLE;
@@ -128,16 +310,8 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
schema = z.object({ input: z.string().optional() });
}
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
const normalizedToolKey = `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`;
if (!req.user?.id) {
logger.error(
`[MCP][${serverName}][${toolName}] User ID not found on request. Cannot create tool.`,
);
throw new Error(`User ID not found on request. Cannot create tool for ${toolKey}.`);
}
/** @type {(toolArguments: Object | string, config?: GraphRunnableConfig) => Promise<unknown>} */
const _call = async (toolArguments, config) => {
const userId = config?.configurable?.user?.id || config?.configurable?.user_id;
@@ -154,14 +328,16 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
const provider = (config?.metadata?.provider || _provider)?.toLowerCase();
const { args: _args, stepId, ...toolCall } = config.toolCall ?? {};
const loginFlowId = `${serverName}:oauth_login:${config.metadata.thread_id}:${config.metadata.run_id}`;
const oauthStart = createOAuthStart({
const flowId = `${serverName}:oauth_login:${config.metadata.thread_id}:${config.metadata.run_id}`;
const runStepDeltaEmitter = createRunStepDeltaEmitter({
res,
stepId,
toolCall,
loginFlowId,
});
const oauthStart = createOAuthStart({
flowId,
flowManager,
signal: derivedSignal,
callback: runStepDeltaEmitter,
});
const oauthEnd = createOAuthEnd({
res,
@@ -207,7 +383,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
return result;
} catch (error) {
logger.error(
`[MCP][User: ${userId}][${serverName}] Error calling "${toolName}" MCP tool:`,
`[MCP][${serverName}][${toolName}][User: ${userId}] Error calling MCP tool:`,
error,
);
@@ -220,12 +396,12 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
if (isOAuthError) {
throw new Error(
`OAuth authentication required for ${serverName}. Please check the server logs for the authentication URL.`,
`[MCP][${serverName}][${toolName}] OAuth authentication required. Please check the server logs for the authentication URL.`,
);
}
throw new Error(
`"${toolKey}" tool call failed${error?.message ? `: ${error?.message}` : '.'}`,
`[MCP][${serverName}][${toolName}] tool call failed${error?.message ? `: ${error?.message}` : '.'}`,
);
} finally {
// Clean up abort handler to prevent memory leaks
@@ -380,6 +556,7 @@ async function getServerConnectionStatus(
module.exports = {
createMCPTool,
createMCPTools,
getMCPSetupData,
checkOAuthFlowStatus,
getServerConnectionStatus,

View File

@@ -34,6 +34,7 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
* @param {string} params.apiKey - The API key for authentication with the API.
* @param {string} params.baseURL - The base path URL for the API.
* @param {string} [params.name='OpenAI'] - The name of the API; defaults to 'OpenAI'.
* @param {boolean} [params.direct=false] - Whether `directEndpoint` was configured
* @param {boolean} [params.azure=false] - Whether to fetch models from Azure.
* @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter.
* @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response.
@@ -44,14 +45,16 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
const fetchModels = async ({
user,
apiKey,
baseURL,
baseURL: _baseURL,
name = EModelEndpoint.openAI,
direct,
azure = false,
userIdQuery = false,
createTokenConfig = true,
tokenKey,
}) => {
let models = [];
const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL;
if (!baseURL && !azure) {
return models;

View File

@@ -1,9 +1,9 @@
const fs = require('fs');
const path = require('path');
const { sleep } = require('@librechat/agents');
const { getToolkitKey } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { zodToJsonSchema } = require('zod-to-json-schema');
const { getToolkitKey, getUserMCPAuthMap } = require('@librechat/api');
const { Calculator } = require('@langchain/community/tools/calculator');
const { tool: toolFn, Tool, DynamicStructuredTool } = require('@langchain/core/tools');
const {
@@ -33,12 +33,17 @@ const {
toolkits,
} = require('~/app/clients/tools');
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
const { getEndpointsConfig, getCachedTools } = require('~/server/services/Config');
const {
getEndpointsConfig,
hasCustomUserVars,
getCachedTools,
} = require('~/server/services/Config');
const { createOnSearchResults } = require('~/server/services/Tools/search');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { recordUsage } = require('~/server/services/Threads');
const { loadTools } = require('~/app/clients/tools/util');
const { redactMessage } = require('~/config/parsers');
const { findPluginAuthsByKeys } = require('~/models');
/**
* Loads and formats tools from the specified tool directory.
@@ -469,11 +474,12 @@ async function processRequiredActions(client, requiredActions) {
* @param {Object} params - Run params containing user and request information.
* @param {ServerRequest} params.req - The request object.
* @param {ServerResponse} params.res - The request object.
* @param {AbortSignal} params.signal
* @param {Pick<Agent, 'id' | 'provider' | 'model' | 'tools'} params.agent - The agent to load tools for.
* @param {string | undefined} [params.openAIApiKey] - The OpenAI API key.
* @returns {Promise<{ tools?: StructuredTool[] }>} The agent tools.
* @returns {Promise<{ tools?: StructuredTool[]; userMCPAuthMap?: Record<string, Record<string, string>> }>} The agent tools.
*/
async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey }) {
async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIApiKey }) {
if (!agent.tools || agent.tools.length === 0) {
return {};
} else if (agent.tools && agent.tools.length === 1 && agent.tools[0] === AgentCapabilities.ocr) {
@@ -523,8 +529,20 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
webSearchCallbacks = createOnSearchResults(res);
}
/** @type {Record<string, Record<string, string>>} */
let userMCPAuthMap;
if (await hasCustomUserVars()) {
userMCPAuthMap = await getUserMCPAuthMap({
tools: agent.tools,
userId: req.user.id,
findPluginAuthsByKeys,
});
}
const { loadedTools, toolContextMap } = await loadTools({
agent,
signal,
userMCPAuthMap,
functions: true,
user: req.user.id,
tools: _agentTools,
@@ -588,6 +606,7 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
if (!checkCapability(AgentCapabilities.actions)) {
return {
tools: agentTools,
userMCPAuthMap,
toolContextMap,
};
}
@@ -599,6 +618,7 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
}
return {
tools: agentTools,
userMCPAuthMap,
toolContextMap,
};
}
@@ -707,6 +727,7 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
return {
tools: agentTools,
toolContextMap,
userMCPAuthMap,
};
}

View File

@@ -0,0 +1,142 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { updateMCPUserTools } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
/**
* @param {Object} params
* @param {ServerRequest} params.req
* @param {string} params.serverName - The name of the MCP server
* @param {boolean} params.returnOnOAuth - Whether to initiate OAuth and return, or wait for OAuth flow to finish
* @param {AbortSignal} [params.signal] - The abort signal to handle cancellation.
* @param {boolean} [params.forceNew]
* @param {number} [params.connectionTimeout]
* @param {FlowStateManager<any>} [params.flowManager]
* @param {(authURL: string) => Promise<boolean>} [params.oauthStart]
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
*/
async function reinitMCPServer({
req,
signal,
forceNew,
serverName,
userMCPAuthMap,
connectionTimeout,
returnOnOAuth = true,
oauthStart: _oauthStart,
flowManager: _flowManager,
}) {
/** @type {MCPConnection | null} */
let userConnection = null;
/** @type {LCAvailableTools | null} */
let availableTools = null;
/** @type {ReturnType<MCPConnection['fetchTools']> | null} */
let tools = null;
let oauthRequired = false;
let oauthUrl = null;
try {
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
const flowManager = _flowManager ?? getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const mcpManager = getMCPManager();
const oauthStart =
_oauthStart ??
(async (authURL) => {
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
oauthUrl = authURL;
oauthRequired = true;
});
try {
userConnection = await mcpManager.getUserConnection({
user: req.user,
signal,
forceNew,
oauthStart,
serverName,
flowManager,
returnOnOAuth,
customUserVars,
connectionTimeout,
tokenMethods: {
findToken,
updateToken,
createToken,
deleteTokens,
},
});
logger.info(`[MCP Reinitialize] Successfully established connection for ${serverName}`);
} catch (err) {
logger.info(`[MCP Reinitialize] getUserConnection threw error: ${err.message}`);
logger.info(
`[MCP Reinitialize] OAuth state - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
const isOAuthError =
err.message?.includes('OAuth') ||
err.message?.includes('authentication') ||
err.message?.includes('401');
const isOAuthFlowInitiated = err.message === 'OAuth flow initiated - return early';
if (isOAuthError || oauthRequired || isOAuthFlowInitiated) {
logger.info(
`[MCP Reinitialize] OAuth required for ${serverName} (isOAuthError: ${isOAuthError}, oauthRequired: ${oauthRequired}, isOAuthFlowInitiated: ${isOAuthFlowInitiated})`,
);
oauthRequired = true;
} else {
logger.error(
`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`,
err,
);
}
}
if (userConnection && !oauthRequired) {
tools = await userConnection.fetchTools();
availableTools = await updateMCPUserTools({
userId: req.user.id,
serverName,
tools,
});
}
logger.debug(
`[MCP Reinitialize] Sending response for ${serverName} - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
const getResponseMessage = () => {
if (oauthRequired) {
return `MCP server '${serverName}' ready for OAuth authentication`;
}
if (userConnection) {
return `MCP server '${serverName}' reinitialized successfully`;
}
return `Failed to reinitialize MCP server '${serverName}'`;
};
const result = {
availableTools,
success: Boolean((userConnection && !oauthRequired) || (oauthRequired && oauthUrl)),
message: getResponseMessage(),
oauthRequired,
serverName,
oauthUrl,
tools,
};
logger.debug(`[MCP Reinitialize] Response for ${serverName}:`, result);
return result;
} catch (error) {
logger.error(
'[MCP Reinitialize] Error loading MCP Tools, servers may still be initializing:',
error,
);
}
}
module.exports = {
reinitMCPServer,
};

View File

@@ -1,8 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { getCachedTools, setCachedTools } = require('./Config');
const { CacheKeys } = require('librechat-data-provider');
const { createMCPManager } = require('~/config');
const { getLogStores } = require('~/cache');
const { mergeAppTools } = require('./Config');
/**
* Initialize MCP servers
@@ -18,21 +16,12 @@ async function initializeMCPs(app) {
try {
delete app.locals.mcpConfig;
const cachedTools = await getCachedTools();
const mcpTools = mcpManager.getAppToolFunctions() || {};
await mergeAppTools(mcpTools);
if (!cachedTools) {
logger.warn('No available tools found in cache during MCP initialization');
return;
}
const mcpTools = mcpManager.getAppToolFunctions() ?? {};
await setCachedTools({ ...cachedTools, ...mcpTools }, { isGlobal: true });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug('Cleared tools array cache after MCP initialization');
logger.info('MCP servers initialized successfully');
logger.info(
`MCP servers initialized successfully. Added ${Object.keys(mcpTools).length} MCP tools.`,
);
} catch (error) {
logger.error('Failed to initialize MCP servers:', error);
}

View File

@@ -138,9 +138,6 @@ async function loadDefaultInterface(config, configDefaults) {
defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.USE],
defaults.prompts,
),
[Permissions.SHARED_GLOBAL]:
defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.SHARED_GLOBAL],
[Permissions.CREATE]: defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.CREATE],
},
[PermissionTypes.BOOKMARKS]: {
[Permissions.USE]: getPermissionValue(
@@ -155,9 +152,6 @@ async function loadDefaultInterface(config, configDefaults) {
defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.USE],
defaults.memories,
),
[Permissions.CREATE]: defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.CREATE],
[Permissions.UPDATE]: defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.UPDATE],
[Permissions.READ]: defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.READ],
[Permissions.OPT_OUT]: isPersonalizationEnabled,
},
[PermissionTypes.MULTI_CONVO]: {
@@ -173,9 +167,6 @@ async function loadDefaultInterface(config, configDefaults) {
defaultPerms[PermissionTypes.AGENTS]?.[Permissions.USE],
defaults.agents,
),
[Permissions.SHARED_GLOBAL]:
defaultPerms[PermissionTypes.AGENTS]?.[Permissions.SHARED_GLOBAL],
[Permissions.CREATE]: defaultPerms[PermissionTypes.AGENTS]?.[Permissions.CREATE],
},
[PermissionTypes.TEMPORARY_CHAT]: {
[Permissions.USE]: getPermissionValue(

View File

@@ -54,22 +54,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -87,22 +80,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -164,22 +150,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: false,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
@@ -197,22 +176,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: false,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
@@ -274,22 +246,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -307,22 +272,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -397,22 +355,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
@@ -430,22 +381,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
@@ -507,22 +451,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -540,22 +477,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -627,9 +557,6 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
@@ -650,9 +577,6 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
@@ -738,15 +662,10 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
}, // Explicitly configured
// All other permissions that don't exist in the database
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
@@ -766,15 +685,10 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
}, // Explicitly configured
// All other permissions that don't exist in the database
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
@@ -888,43 +802,29 @@ describe('loadDefaultInterface', () => {
// Check PROMPTS permissions use role defaults
expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
});
expect(adminCall[1][PermissionTypes.PROMPTS]).toEqual({
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
});
// Check AGENTS permissions use role defaults
expect(userCall[1][PermissionTypes.AGENTS]).toEqual({
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
});
expect(adminCall[1][PermissionTypes.AGENTS]).toEqual({
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
});
// Check MEMORIES permissions use role defaults
expect(userCall[1][PermissionTypes.MEMORIES]).toEqual({
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
});
expect(adminCall[1][PermissionTypes.MEMORIES]).toEqual({
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
});
});
@@ -1164,12 +1064,6 @@ describe('loadDefaultInterface', () => {
// Explicitly configured permissions should be updated
expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS][
Permissions.SHARED_GLOBAL
],
[Permissions.CREATE]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS][Permissions.CREATE],
});
expect(userCall[1][PermissionTypes.BOOKMARKS]).toEqual({ [Permissions.USE]: true });
expect(userCall[1][PermissionTypes.MARKETPLACE]).toEqual({ [Permissions.USE]: true });

View File

@@ -1115,6 +1115,18 @@
* @memberof typedefs
*/
/**
* @exports MCPConnection
* @typedef {import('@librechat/api').MCPConnection} MCPConnection
* @memberof typedefs
*/
/**
* @exports LCFunctionTool
* @typedef {import('@librechat/api').LCFunctionTool} LCFunctionTool
* @memberof typedefs
*/
/**
* @exports FlowStateManager
* @typedef {import('@librechat/api').FlowStateManager} FlowStateManager
@@ -1825,6 +1837,7 @@
* @param {object} opts - Options for the completion
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
* @param {AbortController} opts.abortController - AbortController instance
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
* @returns {Promise<string>}
* @memberof typedefs
*/

View File

@@ -8,7 +8,6 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="LibreChat - An open source chat application with support for multiple AI models" />
<title>LibreChat</title>
<link rel="shortcut icon" href="#" />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" />
<link rel="apple-touch-icon" href="/assets/apple-touch-icon-180x180.png" />

View File

@@ -336,6 +336,7 @@ export type TAskProps = {
export type TOptions = {
editedMessageId?: string | null;
editedContent?: t.TEditedContent;
editedText?: string | null;
isRegenerate?: boolean;
isContinued?: boolean;

View File

@@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
import type { ModelSelectorProps } from '~/common';
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
import { ModelSelectorChatProvider } from './ModelSelectorChatContext';
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components';
import { renderModelSpecsWithFolders, renderEndpoints, renderSearchResults } from './components';
import { getSelectedIcon, getDisplayValue } from './utils';
import { CustomMenu as Menu } from './CustomMenu';
import DialogManager from './DialogManager';
@@ -86,7 +86,7 @@ function ModelSelectorContent() {
renderSearchResults(searchResults, localize, searchValue)
) : (
<>
{renderModelSpecs(modelSpecs, selectedValues.modelSpec || '')}
{renderModelSpecsWithFolders(modelSpecs, selectedValues.modelSpec || '')}
{renderEndpoints(mappedEndpoints ?? [])}
</>
)}

View File

@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react';
import type { TModelSpec } from 'librechat-data-provider';
import { ModelSpecItem } from './ModelSpecItem';
import { cn } from '~/utils';
interface ModelSpecFolderProps {
folderName: string;
specs: TModelSpec[];
selectedSpec: string;
level?: number;
}
export function ModelSpecFolder({
folderName,
specs,
selectedSpec,
level = 0
}: ModelSpecFolderProps) {
const [isExpanded, setIsExpanded] = useState(true);
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
};
const indent = level * 16;
return (
<div className="w-full">
<button
onClick={handleToggle}
className={cn(
'flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-hover',
'text-text-secondary transition-colors'
)}
style={{ paddingLeft: `${8 + indent}px` }}
>
<span className="flex-shrink-0">
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</span>
<span className="flex-shrink-0">
{isExpanded ? (
<FolderOpen className="h-3.5 w-3.5" />
) : (
<Folder className="h-3.5 w-3.5" />
)}
</span>
<span className="truncate text-left font-medium">{folderName}</span>
</button>
{isExpanded && (
<div className="mt-0.5">
{specs.map((spec) => (
<div key={spec.name} style={{ paddingLeft: `${indent}px` }}>
<ModelSpecItem spec={spec} isSelected={selectedSpec === spec.name} />
</div>
))}
</div>
)}
</div>
);
}
interface GroupedSpecs {
[folder: string]: TModelSpec[];
}
export function renderModelSpecsWithFolders(specs: TModelSpec[], selectedSpec: string) {
if (!specs || specs.length === 0) {
return null;
}
// Group specs by folder
const grouped: GroupedSpecs = {};
const rootSpecs: TModelSpec[] = [];
specs.forEach((spec) => {
if (spec.folder) {
if (!grouped[spec.folder]) {
grouped[spec.folder] = [];
}
grouped[spec.folder].push(spec);
} else {
rootSpecs.push(spec);
}
});
// Sort folders alphabetically
const sortedFolders = Object.keys(grouped).sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase())
);
// Sort specs within each folder by order or label
sortedFolders.forEach(folder => {
grouped[folder].sort((a, b) => {
if (a.order !== undefined && b.order !== undefined) {
return a.order - b.order;
}
return a.label.toLowerCase().localeCompare(b.label.toLowerCase());
});
});
// Sort root specs
rootSpecs.sort((a, b) => {
if (a.order !== undefined && b.order !== undefined) {
return a.order - b.order;
}
return a.label.toLowerCase().localeCompare(b.label.toLowerCase());
});
return (
<>
{/* Render folders first */}
{sortedFolders.map((folder) => (
<ModelSpecFolder
key={folder}
folderName={folder}
specs={grouped[folder]}
selectedSpec={selectedSpec}
/>
))}
{/* Render root level specs */}
{rootSpecs.map((spec) => (
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
))}
</>
);
}

View File

@@ -67,7 +67,12 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
</div>
)}
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate text-left">{spec.label}</span>
<span className="truncate text-left">
{spec.folder && (
<span className="text-xs text-text-tertiary">{spec.folder} / </span>
)}
{spec.label}
</span>
{spec.description && (
<span className="break-words text-xs font-normal">{spec.description}</span>
)}

View File

@@ -1,4 +1,5 @@
export * from './ModelSpecItem';
export * from './ModelSpecFolder';
export * from './EndpointModelItem';
export * from './EndpointItem';
export * from './SearchResults';

View File

@@ -94,6 +94,12 @@ const ContentParts = memo(
return null;
}
const isToolCall =
part.type === ContentTypes.TOOL_CALL || part['tool_call_ids'] != null;
if (isToolCall) {
return null;
}
return (
<EditTextPart
index={idx}

View File

@@ -62,17 +62,26 @@ const EditTextPart = ({
const messages = getMessages();
const parentMessage = messages?.find((msg) => msg.messageId === message?.parentMessageId);
const editedContent =
part.type === ContentTypes.THINK
? {
index,
type: ContentTypes.THINK as const,
[ContentTypes.THINK]: data.text,
}
: {
index,
type: ContentTypes.TEXT as const,
[ContentTypes.TEXT]: data.text,
};
if (!parentMessage) {
return;
}
ask(
{ ...parentMessage },
{
editedContent: {
index,
text: data.text,
type: part.type,
},
editedContent,
editedMessageId: messageId,
isRegenerate: true,
isEdited: true,

View File

@@ -77,6 +77,7 @@ export const LangSelector = ({
{ value: 'zh-Hans', label: localize('com_nav_lang_chinese') },
{ value: 'zh-Hant', label: localize('com_nav_lang_traditional_chinese') },
{ value: 'ar-EG', label: localize('com_nav_lang_arabic') },
{ value: 'bs', label: localize('com_nav_lang_bosnian') },
{ value: 'da-DK', label: localize('com_nav_lang_danish') },
{ value: 'de-DE', label: localize('com_nav_lang_german') },
{ value: 'es-ES', label: localize('com_nav_lang_spanish') },
@@ -88,6 +89,7 @@ export const LangSelector = ({
{ value: 'hu-HU', label: localize('com_nav_lang_hungarian') },
{ value: 'hy-AM', label: localize('com_nav_lang_armenian') },
{ value: 'it-IT', label: localize('com_nav_lang_italian') },
{ value: 'nb', label: localize('com_nav_lang_norwegian_bokmal') },
{ value: 'pl-PL', label: localize('com_nav_lang_polish') },
{ value: 'pt-BR', label: localize('com_nav_lang_brazilian_portuguese') },
{ value: 'pt-PT', label: localize('com_nav_lang_portuguese') },

View File

@@ -230,15 +230,19 @@ export default function useChatFunctions({
const responseMessageId =
editedMessageId ??
(latestMessage?.messageId && isRegenerate ? latestMessage?.messageId + '_' : null) ??
(latestMessage?.messageId && isRegenerate
? latestMessage.messageId.replace(/_+$/, '') + '_'
: null) ??
null;
const initialResponseId =
responseMessageId ?? `${isRegenerate ? messageId : intermediateId}`.replace(/_+$/, '') + '_';
const initialResponse: TMessage = {
sender: responseSender,
text: '',
endpoint: endpoint ?? '',
parentMessageId: isRegenerate ? messageId : intermediateId,
messageId: responseMessageId ?? `${isRegenerate ? messageId : intermediateId}_`,
messageId: initialResponseId,
thread_id,
conversationId,
unfinished: false,
@@ -267,13 +271,13 @@ export default function useChatFunctions({
if (editedContent && latestMessage?.content) {
initialResponse.content = cloneDeep(latestMessage.content);
const { index, text, type } = editedContent;
const { index, type, ...part } = editedContent;
if (initialResponse.content && index >= 0 && index < initialResponse.content.length) {
const contentPart = initialResponse.content[index];
if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
contentPart[ContentTypes.THINK] = text;
contentPart[ContentTypes.THINK] = part[ContentTypes.THINK];
} else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
contentPart[ContentTypes.TEXT] = text;
contentPart[ContentTypes.TEXT] = part[ContentTypes.TEXT];
}
}
} else {

View File

@@ -182,7 +182,7 @@ export default function useEventHandlers({
const { token } = useAuthContext();
const contentHandler = useContentHandler({ setMessages, getMessages });
const stepHandler = useStepHandler({
const { stepHandler, clearStepMaps } = useStepHandler({
setMessages,
getMessages,
announcePolite,
@@ -806,6 +806,7 @@ export default function useEventHandlers({
);
return {
clearStepMaps,
stepHandler,
syncHandler,
finalHandler,

View File

@@ -62,6 +62,7 @@ export default function useSSE(
} = chatHelpers;
const {
clearStepMaps,
stepHandler,
syncHandler,
finalHandler,
@@ -101,6 +102,7 @@ export default function useSSE(
payload = removeNullishValues(payload) as TPayload;
let textIndex = null;
clearStepMaps();
const sse = new SSE(payloadData.server, {
payload: JSON.stringify(payload),

View File

@@ -1,5 +1,11 @@
import { useCallback, useRef } from 'react';
import { StepTypes, ContentTypes, ToolCallTypes, getNonEmptyValue } from 'librechat-data-provider';
import {
Constants,
StepTypes,
ContentTypes,
ToolCallTypes,
getNonEmptyValue,
} from 'librechat-data-provider';
import type {
Agents,
TMessage,
@@ -178,11 +184,12 @@ export default function useStepHandler({
return { ...message, content: updatedContent as TMessageContentParts[] };
};
return useCallback(
const stepHandler = useCallback(
({ event, data }: TStepEvent, submission: EventSubmission) => {
const messages = getMessages() || [];
const { userMessage } = submission;
setIsSubmitting(true);
let parentMessageId = userMessage.messageId;
const currentTime = Date.now();
if (currentTime - lastAnnouncementTimeRef.current > MESSAGE_UPDATE_INTERVAL) {
@@ -197,7 +204,11 @@ export default function useStepHandler({
if (event === 'on_run_step') {
const runStep = data as Agents.RunStep;
const responseMessageId = runStep.runId ?? '';
let responseMessageId = runStep.runId ?? '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!responseMessageId) {
console.warn('No message id found in run step event');
return;
@@ -211,7 +222,7 @@ export default function useStepHandler({
response = {
...responseMessage,
parentMessageId: userMessage.messageId,
parentMessageId,
conversationId: userMessage.conversationId,
messageId: responseMessageId,
content: initialContent,
@@ -246,14 +257,18 @@ export default function useStepHandler({
messageMap.current.set(responseMessageId, updatedResponse);
const updatedMessages = messages.map((msg) =>
msg.messageId === runStep.runId ? updatedResponse : msg,
msg.messageId === responseMessageId ? updatedResponse : msg,
);
setMessages(updatedMessages);
}
} else if (event === 'on_agent_update') {
const { agent_update } = data as Agents.AgentUpdate;
const responseMessageId = agent_update.runId || '';
let responseMessageId = agent_update.runId || '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!responseMessageId) {
console.warn('No message id found in agent update event');
return;
@@ -271,7 +286,11 @@ export default function useStepHandler({
} else if (event === 'on_message_delta') {
const messageDelta = data as Agents.MessageDeltaEvent;
const runStep = stepMap.current.get(messageDelta.id);
const responseMessageId = runStep?.runId ?? '';
let responseMessageId = runStep?.runId ?? '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!runStep || !responseMessageId) {
console.warn('No run step or runId found for message delta event');
@@ -299,7 +318,11 @@ export default function useStepHandler({
} else if (event === 'on_reasoning_delta') {
const reasoningDelta = data as Agents.ReasoningDeltaEvent;
const runStep = stepMap.current.get(reasoningDelta.id);
const responseMessageId = runStep?.runId ?? '';
let responseMessageId = runStep?.runId ?? '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!runStep || !responseMessageId) {
console.warn('No run step or runId found for reasoning delta event');
@@ -327,7 +350,11 @@ export default function useStepHandler({
} else if (event === 'on_run_step_delta') {
const runStepDelta = data as Agents.RunStepDeltaEvent;
const runStep = stepMap.current.get(runStepDelta.id);
const responseMessageId = runStep?.runId ?? '';
let responseMessageId = runStep?.runId ?? '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!runStep || !responseMessageId) {
console.warn('No run step or runId found for run step delta event');
@@ -366,7 +393,7 @@ export default function useStepHandler({
messageMap.current.set(responseMessageId, updatedResponse);
const updatedMessages = messages.map((msg) =>
msg.messageId === runStep.runId ? updatedResponse : msg,
msg.messageId === responseMessageId ? updatedResponse : msg,
);
setMessages(updatedMessages);
@@ -377,7 +404,11 @@ export default function useStepHandler({
const { id: stepId } = result;
const runStep = stepMap.current.get(stepId);
const responseMessageId = runStep?.runId ?? '';
let responseMessageId = runStep?.runId ?? '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!runStep || !responseMessageId) {
console.warn('No run step or runId found for completed tool call event');
@@ -399,7 +430,7 @@ export default function useStepHandler({
messageMap.current.set(responseMessageId, updatedResponse);
const updatedMessages = messages.map((msg) =>
msg.messageId === runStep.runId ? updatedResponse : msg,
msg.messageId === responseMessageId ? updatedResponse : msg,
);
setMessages(updatedMessages);
@@ -414,4 +445,11 @@ export default function useStepHandler({
},
[getMessages, setIsSubmitting, lastAnnouncementTimeRef, announcePolite, setMessages],
);
const clearStepMaps = useCallback(() => {
toolCallIdMap.current.clear();
messageMap.current.clear();
stepMap.current.clear();
}, []);
return { stepHandler, clearStepMaps };
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -478,6 +478,7 @@
"com_nav_lang_arabic": "العربية",
"com_nav_lang_armenian": "Հայերեն",
"com_nav_lang_auto": "Auto detect",
"com_nav_lang_bosnian": "Босански",
"com_nav_lang_brazilian_portuguese": "Português Brasileiro",
"com_nav_lang_catalan": "Català",
"com_nav_lang_chinese": "中文",
@@ -497,6 +498,7 @@
"com_nav_lang_japanese": "日本語",
"com_nav_lang_korean": "한국어",
"com_nav_lang_latvian": "Latviski",
"com_nav_lang_norwegian_bokmal": "Norsk Bokmål",
"com_nav_lang_persian": "فارسی",
"com_nav_lang_polish": "Polski",
"com_nav_lang_portuguese": "Português",

View File

@@ -37,12 +37,15 @@ import translationZh_Hans from './zh-Hans/translation.json';
import translationZh_Hant from './zh-Hant/translation.json';
import translationBo from './bo/translation.json';
import translationUk from './uk/translation.json';
import translationBs from './bs/translation.json';
import translationNb from './nb/translation.json';
export const defaultNS = 'translation';
export const resources = {
en: { translation: translationEn },
ar: { translation: translationAr },
bs: { translation: translationBs },
ca: { translation: translationCa },
cs: { translation: translationCs },
'zh-Hans': { translation: translationZh_Hans },
@@ -54,6 +57,7 @@ export const resources = {
fa: { translation: translationFa },
fr: { translation: translationFr },
it: { translation: translationIt },
nb: { translation: translationNb },
pl: { translation: translationPl },
'pt-BR': { translation: translationPt_BR },
'pt-PT': { translation: translationPt_PT },

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,149 @@
# Example configuration demonstrating model spec subfolder/category support
# This shows how to organize model specs into folders for better organization
version: 1.1.7
modelSpecs:
enforce: false
prioritize: true
list:
# OpenAI Models Category
- name: "gpt4_turbo"
label: "GPT-4 Turbo"
folder: "OpenAI Models" # This spec will appear under "OpenAI Models" folder
preset:
endpoint: "openAI"
model: "gpt-4-turbo-preview"
temperature: 0.7
description: "Latest GPT-4 Turbo model with enhanced capabilities"
iconURL: "openAI"
order: 1
- name: "gpt4_vision"
label: "GPT-4 Vision"
folder: "OpenAI Models" # Same folder as above
preset:
endpoint: "openAI"
model: "gpt-4-vision-preview"
temperature: 0.7
description: "GPT-4 with vision capabilities"
iconURL: "openAI"
order: 2
- name: "gpt35_turbo"
label: "GPT-3.5 Turbo"
folder: "OpenAI Models"
preset:
endpoint: "openAI"
model: "gpt-3.5-turbo"
temperature: 0.7
description: "Fast and efficient model for most tasks"
iconURL: "openAI"
order: 3
# Anthropic Models Category
- name: "claude3_opus"
label: "Claude 3 Opus"
folder: "Anthropic Models" # Different folder
preset:
endpoint: "anthropic"
model: "claude-3-opus-20240229"
temperature: 0.7
description: "Most capable Claude model"
iconURL: "anthropic"
order: 1
- name: "claude3_sonnet"
label: "Claude 3 Sonnet"
folder: "Anthropic Models"
preset:
endpoint: "anthropic"
model: "claude-3-sonnet-20240229"
temperature: 0.7
description: "Balanced performance and cost"
iconURL: "anthropic"
order: 2
- name: "claude3_haiku"
label: "Claude 3 Haiku"
folder: "Anthropic Models"
preset:
endpoint: "anthropic"
model: "claude-3-haiku-20240307"
temperature: 0.7
description: "Fast and affordable"
iconURL: "anthropic"
order: 3
# Specialized Models Category
- name: "code_assistant"
label: "Code Assistant"
folder: "Specialized Models"
preset:
endpoint: "openAI"
model: "gpt-4-turbo-preview"
temperature: 0.2
systemMessage: "You are an expert programmer. Provide clear, well-commented code solutions."
description: "Optimized for coding tasks"
iconURL: "openAI"
- name: "creative_writer"
label: "Creative Writer"
folder: "Specialized Models"
preset:
endpoint: "anthropic"
model: "claude-3-opus-20240229"
temperature: 0.9
systemMessage: "You are a creative writer. Generate engaging and imaginative content."
description: "For creative writing tasks"
iconURL: "anthropic"
- name: "research_analyst"
label: "Research Analyst"
folder: "Specialized Models"
preset:
endpoint: "openAI"
model: "gpt-4-turbo-preview"
temperature: 0.3
systemMessage: "You are a research analyst. Provide thorough, fact-based analysis."
description: "For research and analysis"
iconURL: "openAI"
# Models without folders (appear at root level)
- name: "quick_chat"
label: "Quick Chat"
preset:
endpoint: "openAI"
model: "gpt-3.5-turbo"
temperature: 0.7
description: "Fast general-purpose chat"
iconURL: "openAI"
default: true # This is the default model
- name: "advanced_reasoning"
label: "Advanced Reasoning"
preset:
endpoint: "anthropic"
model: "claude-3-opus-20240229"
temperature: 0.5
description: "For complex reasoning tasks"
iconURL: "anthropic"
# Interface configuration
interface:
endpointsMenu: false # Hide endpoints menu when using model specs
modelSelect: false # Hide traditional model selector
parameters: false # Hide parameter controls (using presets)
presets: false # Hide preset selector (using model specs)
# Endpoints configuration (required for the model specs to work)
endpoints:
openAI:
apiKey: "${OPENAI_API_KEY}"
models:
default: ["gpt-3.5-turbo", "gpt-4-turbo-preview", "gpt-4-vision-preview"]
anthropic:
apiKey: "${ANTHROPIC_API_KEY}"
models:
default: ["claude-3-opus-20240229", "claude-3-sonnet-20240229", "claude-3-haiku-20240307"]

34
package-lock.json generated
View File

@@ -31411,13 +31411,17 @@
}
},
"node_modules/cipher-base": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
"integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz",
"integrity": "sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.1",
"safe-buffer": "^5.0.1"
"inherits": "^2.0.4",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/cjs-module-lexer": {
@@ -47184,16 +47188,24 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/sha.js": {
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
"integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
"version": "2.4.12",
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz",
"integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==",
"dev": true,
"license": "(MIT AND BSD-3-Clause)",
"dependencies": {
"inherits": "^2.0.1",
"safe-buffer": "^5.0.1"
"inherits": "^2.0.4",
"safe-buffer": "^5.2.1",
"to-buffer": "^1.2.0"
},
"bin": {
"sha.js": "bin.js"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/shebang-command": {
@@ -51876,7 +51888,7 @@
},
"packages/data-schemas": {
"name": "@librechat/data-schemas",
"version": "0.0.18",
"version": "0.0.19",
"license": "MIT",
"devDependencies": {
"@rollup/plugin-alias": "^5.1.0",

View File

@@ -6,8 +6,11 @@ import type { AzureOpenAIInput } from '@langchain/openai';
import type { OpenAI } from 'openai';
import type * as t from '~/types';
import { sanitizeModelName, constructAzureURL } from '~/utils/azure';
import { createFetch } from '~/utils/generators';
import { isEnabled } from '~/utils/common';
type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
export const knownOpenAIParams = new Set([
// Constructor/Instance Parameters
'model',
@@ -92,6 +95,7 @@ export function getOpenAIConfig(
const {
modelOptions: _modelOptions = {},
reverseProxyUrl,
directEndpoint,
defaultQuery,
headers,
proxy,
@@ -311,6 +315,13 @@ export function getOpenAIConfig(
llmConfig.modelKwargs = modelKwargs;
}
if (directEndpoint === true && configOptions?.baseURL != null) {
configOptions.fetch = createFetch({
directEndpoint: directEndpoint,
reverseProxyUrl: configOptions?.baseURL,
}) as unknown as Fetch;
}
const result: t.LLMConfigResult = {
llmConfig,
configOptions,

View File

@@ -1,5 +1,6 @@
/* MCP */
export * from './mcp/MCPManager';
export * from './mcp/connection';
export * from './mcp/oauth';
export * from './mcp/auth';
export * from './mcp/zod';

View File

@@ -28,6 +28,7 @@ export class MCPConnectionFactory {
protected readonly oauthStart?: (authURL: string) => Promise<void>;
protected readonly oauthEnd?: () => Promise<void>;
protected readonly returnOnOAuth?: boolean;
protected readonly connectionTimeout?: number;
/** Creates a new MCP connection with optional OAuth support */
static async create(
@@ -47,6 +48,7 @@ export class MCPConnectionFactory {
});
this.serverName = basic.serverName;
this.useOAuth = !!oauth?.useOAuth;
this.connectionTimeout = oauth?.connectionTimeout;
this.logPrefix = oauth?.user
? `[MCP][${basic.serverName}][${oauth.user.id}]`
: `[MCP][${basic.serverName}]`;
@@ -82,8 +84,9 @@ export class MCPConnectionFactory {
if (!this.tokenMethods?.findToken) return null;
try {
const flowId = MCPOAuthHandler.generateFlowId(this.userId!, this.serverName);
const tokens = await this.flowManager!.createFlowWithHandler(
`tokens:${this.userId}:${this.serverName}`,
flowId,
'mcp_get_tokens',
async () => {
return await MCPTokenStorage.getTokens({
@@ -203,7 +206,7 @@ export class MCPConnectionFactory {
/** Attempts to establish connection with timeout handling */
protected async attemptToConnect(connection: MCPConnection): Promise<void> {
const connectTimeout = this.serverConfig.initTimeout ?? 30000;
const connectTimeout = this.connectionTimeout ?? this.serverConfig.initTimeout ?? 30000;
const connectionTimeout = new Promise<void>((_, reject) =>
setTimeout(
() => reject(new Error(`Connection timeout after ${connectTimeout}ms`)),
@@ -347,6 +350,7 @@ export class MCPConnectionFactory {
newFlowId,
'mcp_oauth',
flowMetadata as FlowMetadata,
this.signal,
);
if (typeof this.oauthEnd === 'function') {
await this.oauthEnd();

View File

@@ -58,6 +58,21 @@ export class MCPManager extends UserConnectionManager {
public getAppToolFunctions(): t.LCAvailableTools | null {
return this.serversRegistry.toolFunctions!;
}
/** Returns all available tool functions from all connections available to user */
public async getAllToolFunctions(userId: string): Promise<t.LCAvailableTools | null> {
const allToolFunctions: t.LCAvailableTools = this.getAppToolFunctions() ?? {};
const userConnections = this.getUserConnections(userId);
if (!userConnections || userConnections.size === 0) {
return allToolFunctions;
}
for (const [serverName, connection] of userConnections.entries()) {
const toolFunctions = await this.serversRegistry.getToolFunctions(serverName, connection);
Object.assign(allToolFunctions, toolFunctions);
}
return allToolFunctions;
}
/**
* Get instructions for MCP servers
@@ -101,34 +116,36 @@ ${formattedInstructions}
Please follow these instructions when using tools from the respective MCP servers.`;
}
/** Loads tools from all app-level connections into the manifest. */
public async loadManifestTools({
serverToolsCallback,
getServerTools,
}: {
flowManager: FlowStateManager<MCPOAuthTokens | null>;
serverToolsCallback?: (serverName: string, tools: t.LCManifestTool[]) => Promise<void>;
getServerTools?: (serverName: string) => Promise<t.LCManifestTool[] | undefined>;
}): Promise<t.LCToolManifest> {
const mcpTools: t.LCManifestTool[] = [];
private async loadAppManifestTools(): Promise<t.LCManifestTool[]> {
const connections = await this.appConnections!.getAll();
return await this.loadManifestTools(connections);
}
private async loadUserManifestTools(userId: string): Promise<t.LCManifestTool[]> {
const connections = this.getUserConnections(userId);
return await this.loadManifestTools(connections);
}
public async loadAllManifestTools(userId: string): Promise<t.LCManifestTool[]> {
const appTools = await this.loadAppManifestTools();
const userTools = await this.loadUserManifestTools(userId);
return [...appTools, ...userTools];
}
/** Loads tools from all app-level connections into the manifest. */
private async loadManifestTools(
connections?: Map<string, MCPConnection> | null,
): Promise<t.LCToolManifest> {
const mcpTools: t.LCManifestTool[] = [];
if (!connections || connections.size === 0) {
return mcpTools;
}
for (const [serverName, connection] of connections.entries()) {
try {
if (!(await connection.isConnected())) {
logger.warn(
`[MCP][${serverName}] Connection not available for ${serverName} manifest tools.`,
);
if (typeof getServerTools !== 'function') {
logger.warn(
`[MCP][${serverName}] No \`getServerTools\` function provided, skipping tool loading.`,
);
continue;
}
const serverTools = await getServerTools(serverName);
if (serverTools && serverTools.length > 0) {
logger.info(`[MCP][${serverName}] Loaded tools from cache for manifest`);
mcpTools.push(...serverTools);
}
continue;
}
@@ -157,9 +174,6 @@ Please follow these instructions when using tools from the respective MCP server
mcpTools.push(manifestTool);
serverTools.push(manifestTool);
}
if (typeof serverToolsCallback === 'function') {
await serverToolsCallback(serverName, serverTools);
}
} catch (error) {
logger.error(`[MCP][${serverName}] Error fetching tools for manifest:`, error);
}

View File

@@ -118,7 +118,7 @@ export class MCPServersRegistry {
}
/** Converts server tools to LibreChat-compatible tool functions format */
private async getToolFunctions(
public async getToolFunctions(
serverName: string,
conn: MCPConnection,
): Promise<t.LCAvailableTools> {

View File

@@ -1,13 +1,8 @@
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { logger } from '@librechat/data-schemas';
import type { TokenMethods } from '@librechat/data-schemas';
import type { TUser } from 'librechat-data-provider';
import type { FlowStateManager } from '~/flow/manager';
import type { MCPOAuthTokens } from '~/mcp/oauth';
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { MCPServersRegistry } from '~/mcp/MCPServersRegistry';
import { MCPConnection } from './connection';
import type { RequestBody } from '~/types';
import type * as t from './types';
/**
@@ -44,8 +39,9 @@ export abstract class UserConnectionManager {
/** Gets or creates a connection for a specific user */
public async getUserConnection({
user,
serverName,
forceNew,
user,
flowManager,
customUserVars,
requestBody,
@@ -54,25 +50,18 @@ export abstract class UserConnectionManager {
oauthEnd,
signal,
returnOnOAuth = false,
connectionTimeout,
}: {
user: TUser;
serverName: string;
flowManager: FlowStateManager<MCPOAuthTokens | null>;
customUserVars?: Record<string, string>;
requestBody?: RequestBody;
tokenMethods?: TokenMethods;
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
signal?: AbortSignal;
returnOnOAuth?: boolean;
}): Promise<MCPConnection> {
forceNew?: boolean;
} & Omit<t.OAuthConnectionOptions, 'useOAuth'>): Promise<MCPConnection> {
const userId = user.id;
if (!userId) {
throw new McpError(ErrorCode.InvalidRequest, `[MCP] User object missing id property`);
}
const userServerMap = this.userConnections.get(userId);
let connection = userServerMap?.get(serverName);
let connection = forceNew ? undefined : userServerMap?.get(serverName);
const now = Date.now();
// Check if user is idle
@@ -131,6 +120,7 @@ export abstract class UserConnectionManager {
oauthEnd: oauthEnd,
returnOnOAuth: returnOnOAuth,
requestBody: requestBody,
connectionTimeout: connectionTimeout,
},
);

View File

@@ -45,7 +45,7 @@ describe('getUserMCPAuthMap', () => {
},
];
const tools = testCases.map((testCase) =>
const toolInstances = testCases.map((testCase) =>
createMockTool(testCase.normalizedToolName, testCase.originalName),
);
@@ -54,7 +54,7 @@ describe('getUserMCPAuthMap', () => {
await getUserMCPAuthMap({
userId: 'user123',
tools,
toolInstances,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
@@ -69,7 +69,7 @@ describe('getUserMCPAuthMap', () => {
describe('Edge Cases', () => {
it('should return empty object when no tools have mcpRawServerName', async () => {
const tools = [
const toolInstances = [
createMockTool('regular_tool', undefined, false),
createMockTool('another_tool', undefined, false),
createMockTool('test_mcp_Server_no_raw_name', undefined),
@@ -77,7 +77,7 @@ describe('getUserMCPAuthMap', () => {
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
toolInstances,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
@@ -104,14 +104,14 @@ describe('getUserMCPAuthMap', () => {
});
it('should handle database errors gracefully', async () => {
const tools = [createMockTool('test_mcp_Server1', 'Server1')];
const toolInstances = [createMockTool('test_mcp_Server1', 'Server1')];
const dbError = new Error('Database connection failed');
mockGetPluginAuthMap.mockRejectedValue(dbError);
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
toolInstances,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
@@ -119,18 +119,119 @@ describe('getUserMCPAuthMap', () => {
});
it('should handle non-Error exceptions gracefully', async () => {
const tools = [createMockTool('test_mcp_Server1', 'Server1')];
const toolInstances = [createMockTool('test_mcp_Server1', 'Server1')];
mockGetPluginAuthMap.mockRejectedValue('String error');
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
toolInstances,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({});
});
it('should handle mixed null/undefined values in tools array', async () => {
const tools = [
'test_mcp_Server1',
null,
'test_mcp_Server2',
undefined,
'regular_tool',
'test_mcp_Server3',
];
mockGetPluginAuthMap.mockResolvedValue({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
const result = await getUserMCPAuthMap({
userId: 'user123',
tools: tools as (string | undefined)[],
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(mockGetPluginAuthMap).toHaveBeenCalledWith({
userId: 'user123',
pluginKeys: ['mcp_Server1', 'mcp_Server2', 'mcp_Server3'],
throwError: false,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
});
it('should handle mixed null/undefined values in servers array', async () => {
const servers = ['Server1', null, 'Server2', undefined, 'Server3'];
mockGetPluginAuthMap.mockResolvedValue({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
const result = await getUserMCPAuthMap({
userId: 'user123',
servers: servers as (string | undefined)[],
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(mockGetPluginAuthMap).toHaveBeenCalledWith({
userId: 'user123',
pluginKeys: ['mcp_Server1', 'mcp_Server2', 'mcp_Server3'],
throwError: false,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
});
it('should handle mixed null/undefined values in toolInstances array', async () => {
const toolInstances = [
createMockTool('test_mcp_Server1', 'Server1'),
null,
createMockTool('test_mcp_Server2', 'Server2'),
undefined,
createMockTool('regular_tool', undefined, false),
createMockTool('test_mcp_Server3', 'Server3'),
];
mockGetPluginAuthMap.mockResolvedValue({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
const result = await getUserMCPAuthMap({
userId: 'user123',
toolInstances: toolInstances as (GenericTool | null)[],
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(mockGetPluginAuthMap).toHaveBeenCalledWith({
userId: 'user123',
pluginKeys: ['mcp_Server1', 'mcp_Server2', 'mcp_Server3'],
throwError: false,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
});
});
describe('Integration', () => {
@@ -138,7 +239,7 @@ describe('getUserMCPAuthMap', () => {
const originalServerName = 'Connector: Company';
const toolName = 'test_auth_mcp_Connector__Company';
const tools = [createMockTool(toolName, originalServerName)];
const toolInstances = [createMockTool(toolName, originalServerName)];
const mockCustomUserVars = {
'mcp_Connector: Company': {
@@ -151,7 +252,7 @@ describe('getUserMCPAuthMap', () => {
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
toolInstances,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});

View File

@@ -7,33 +7,56 @@ import { getPluginAuthMap } from '~/agents/auth';
export async function getUserMCPAuthMap({
userId,
tools,
servers,
toolInstances,
findPluginAuthsByKeys,
}: {
userId: string;
tools: GenericTool[] | undefined;
tools?: (string | undefined)[];
servers?: (string | undefined)[];
toolInstances?: (GenericTool | null)[];
findPluginAuthsByKeys: PluginAuthMethods['findPluginAuthsByKeys'];
}) {
if (!tools || tools.length === 0) {
return {};
}
const uniqueMcpServers = new Set<string>();
for (const tool of tools) {
const mcpTool = tool as GenericTool & { mcpRawServerName?: string };
if (mcpTool.mcpRawServerName) {
uniqueMcpServers.add(`${Constants.mcp_prefix}${mcpTool.mcpRawServerName}`);
}
}
if (uniqueMcpServers.size === 0) {
return {};
}
const mcpPluginKeysToFetch = Array.from(uniqueMcpServers);
let allMcpCustomUserVars: Record<string, Record<string, string>> = {};
let mcpPluginKeysToFetch: string[] = [];
try {
const uniqueMcpServers = new Set<string>();
if (servers != null && servers.length) {
for (const serverName of servers) {
if (!serverName) {
continue;
}
uniqueMcpServers.add(`${Constants.mcp_prefix}${serverName}`);
}
} else if (tools != null && tools.length) {
for (const toolName of tools) {
if (!toolName) {
continue;
}
const delimiterIndex = toolName.indexOf(Constants.mcp_delimiter);
if (delimiterIndex === -1) continue;
const mcpServer = toolName.slice(delimiterIndex + Constants.mcp_delimiter.length);
if (!mcpServer) continue;
uniqueMcpServers.add(`${Constants.mcp_prefix}${mcpServer}`);
}
} else if (toolInstances != null && toolInstances.length) {
for (const tool of toolInstances) {
if (!tool) {
continue;
}
const mcpTool = tool as GenericTool & { mcpRawServerName?: string };
if (mcpTool.mcpRawServerName) {
uniqueMcpServers.add(`${Constants.mcp_prefix}${mcpTool.mcpRawServerName}`);
}
}
}
if (uniqueMcpServers.size === 0) {
return {};
}
mcpPluginKeysToFetch = Array.from(uniqueMcpServers);
allMcpCustomUserVars = await getPluginAuthMap({
userId,
pluginKeys: mcpPluginKeysToFetch,

View File

@@ -446,7 +446,7 @@ export class MCPConnection extends EventEmitter {
const serverUrl = this.url;
logger.debug(`${this.getLogPrefix()} Server URL for OAuth: ${serverUrl}`);
const oauthTimeout = this.options.initTimeout ?? 60000;
const oauthTimeout = this.options.initTimeout ?? 60000 * 2;
/** Promise that will resolve when OAuth is handled */
const oauthHandledPromise = new Promise<void>((resolve, reject) => {
let timeoutId: NodeJS.Timeout | null = null;

View File

@@ -134,4 +134,5 @@ export interface OAuthConnectionOptions {
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
returnOnOAuth?: boolean;
connectionTimeout?: number;
}

View File

@@ -1,5 +1,6 @@
import { AuthType, Constants, EToolResources } from 'librechat-data-provider';
import type { TCustomConfig, TPlugin, FunctionTool } from 'librechat-data-provider';
import type { TCustomConfig, TPlugin } from 'librechat-data-provider';
import { LCAvailableTools, LCFunctionTool } from '~/mcp/types';
/**
* Filters out duplicate plugins from the list of plugins.
@@ -46,6 +47,62 @@ export const checkPluginAuth = (plugin?: TPlugin): boolean => {
});
};
/**
* Converts MCP function format tool to plugin format
* @param params
* @param params.toolKey
* @param params.toolData
* @param params.customConfig
* @returns
*/
export function convertMCPToolToPlugin({
toolKey,
toolData,
customConfig,
}: {
toolKey: string;
toolData: LCFunctionTool;
customConfig?: Partial<TCustomConfig> | null;
}): TPlugin | undefined {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
return;
}
const functionData = toolData.function;
const parts = toolKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
const plugin: TPlugin = {
/** Tool name without server suffix */
name: parts[0],
pluginKey: toolKey,
description: functionData.description || '',
authenticated: true,
icon: serverConfig?.iconPath,
};
if (!serverConfig?.customUserVars) {
/** `authConfig` for MCP tools */
plugin.authConfig = [];
return plugin;
}
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length === 0) {
plugin.authConfig = [];
} else {
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
}));
}
return plugin;
}
/**
* Converts MCP function format tools to plugin format
* @param functionTools - Object with function format tools
@@ -56,7 +113,7 @@ export function convertMCPToolsToPlugins({
functionTools,
customConfig,
}: {
functionTools?: Record<string, FunctionTool>;
functionTools?: LCAvailableTools;
customConfig?: Partial<TCustomConfig> | null;
}): TPlugin[] | undefined {
if (!functionTools || typeof functionTools !== 'object') {
@@ -65,44 +122,10 @@ export function convertMCPToolsToPlugins({
const plugins: TPlugin[] = [];
for (const [toolKey, toolData] of Object.entries(functionTools)) {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
continue;
}
const functionData = toolData.function;
const parts = toolKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
const plugin: TPlugin = {
/** Tool name without server suffix */
name: parts[0],
pluginKey: toolKey,
description: functionData.description || '',
authenticated: true,
icon: serverConfig?.iconPath,
};
if (!serverConfig?.customUserVars) {
/** `authConfig` for MCP tools */
plugin.authConfig = [];
const plugin = convertMCPToolToPlugin({ toolKey, toolData, customConfig });
if (plugin) {
plugins.push(plugin);
continue;
}
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length === 0) {
plugin.authConfig = [];
} else {
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
}));
}
plugins.push(plugin);
}
return plugins;

View File

@@ -12,6 +12,7 @@ export type OpenAIParameters = z.infer<typeof openAISchema>;
*/
export interface OpenAIConfigOptions {
modelOptions?: Partial<OpenAIParameters>;
directEndpoint?: boolean;
reverseProxyUrl?: string;
defaultQuery?: Record<string, string | undefined>;
headers?: Record<string, string>;

View File

@@ -1242,10 +1242,6 @@ export enum CacheKeys {
* Key for in-progress flow states.
*/
FLOWS = 'FLOWS',
/**
* Key for individual MCP Tool Manifests.
*/
MCP_TOOLS = 'MCP_TOOLS',
/**
* Key for pending chat requests (concurrency check)
*/
@@ -1529,6 +1525,8 @@ export enum Constants {
CONFIG_VERSION = '1.2.8',
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
NO_PARENT = '00000000-0000-0000-0000-000000000000',
/** Standard value to use whatever the submission prelim. `responseMessageId` is */
USE_PRELIM_RESPONSE_MESSAGE_ID = 'USE_PRELIM_RESPONSE_MESSAGE_ID',
/** Standard value for the initial conversationId before a request is sent */
NEW_CONVO = 'new',
/** Standard value for the temporary conversationId after a request is sent and before the server responds */
@@ -1555,6 +1553,8 @@ export enum Constants {
mcp_delimiter = '_mcp_',
/** Prefix for MCP plugins */
mcp_prefix = 'mcp_',
/** Unique value to indicate all MCP servers */
mcp_all = 'sys__all__sys',
/** Placeholder Agent ID for Ephemeral Agents */
EPHEMERAL_AGENT_ID = 'ephemeral',
}

View File

@@ -19,6 +19,7 @@ export type TModelSpec = {
showIconInHeader?: boolean;
iconURL?: string | EModelEndpoint; // Allow using project-included icons
authType?: AuthType;
folder?: string; // Optional folder/category for grouping model specs
};
export const tModelSpecSchema = z.object({
@@ -32,6 +33,7 @@ export const tModelSpecSchema = z.object({
showIconInHeader: z.boolean().optional(),
iconURL: z.union([z.string(), eModelEndpointSchema]).optional(),
authType: authTypeSchema.optional(),
folder: z.string().optional(),
});
export const specsConfigSchema = z.object({

View File

@@ -11,6 +11,7 @@ import type {
} from './schemas';
import type { SettingDefinition } from './generate';
import type { TMinimalFeedback } from './feedback';
import type { ContentTypes } from './types/runs';
import type { Agent } from './types/assistants';
export * from './schemas';
@@ -108,13 +109,21 @@ export type TPayload = Partial<TMessage> &
messages?: TMessages;
isTemporary: boolean;
ephemeralAgent?: TEphemeralAgent | null;
editedContent?: {
index: number;
text: string;
type: 'text' | 'think';
} | null;
editedContent?: TEditedContent | null;
};
export type TEditedContent =
| {
index: number;
type: ContentTypes.THINK;
[ContentTypes.THINK]: string;
}
| {
index: number;
type: ContentTypes.TEXT;
[ContentTypes.TEXT]: string;
};
export type TSubmission = {
plugin?: TResPlugin;
plugins?: TResPlugin[];
@@ -129,11 +138,7 @@ export type TSubmission = {
endpointOption: TEndpointOption;
clientTimestamp?: string;
ephemeralAgent?: TEphemeralAgent | null;
editedContent?: {
index: number;
text: string;
type: 'text' | 'think';
} | null;
editedContent?: TEditedContent | null;
};
export type EventSubmission = Omit<TSubmission, 'initialResponse'> & { initialResponse: TMessage };

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/data-schemas",
"version": "0.0.18",
"version": "0.0.19",
"description": "Mongoose schemas and models for LibreChat",
"type": "module",
"main": "dist/index.cjs",

View File

@@ -1 +1,2 @@
export * from './enum';
export * from './pagination';

View File

@@ -0,0 +1,17 @@
export interface CursorPaginationParams {
limit?: number;
cursor?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface CursorPaginationResponse<T> {
data: T[];
pagination: {
hasNextPage: boolean;
hasPreviousPage: boolean;
nextCursor?: string;
previousCursor?: string;
totalCount?: number;
};
}

View File

@@ -1,5 +1,5 @@
import mongoose, { FilterQuery } from 'mongoose';
import type { IUser, BalanceConfig, UserCreateData, UserUpdateResult } from '~/types';
import type { IUser, BalanceConfig, CreateUserRequest, UserDeleteResult } from '~/types';
import { signPayload } from '~/crypto';
/** Factory function that takes mongoose instance and returns the methods */
@@ -31,7 +31,7 @@ export function createUserMethods(mongoose: typeof import('mongoose')) {
* Creates a new user, optionally with a TTL of 1 week.
*/
async function createUser(
data: UserCreateData,
data: CreateUserRequest,
balanceConfig?: BalanceConfig,
disableTTL: boolean = true,
returnUser: boolean = false,
@@ -123,7 +123,7 @@ export function createUserMethods(mongoose: typeof import('mongoose')) {
/**
* Delete a user by their unique ID.
*/
async function deleteUserById(userId: string): Promise<UserUpdateResult> {
async function deleteUserById(userId: string): Promise<UserDeleteResult> {
try {
const User = mongoose.models.User;
const result = await User.deleteOne({ _id: userId });

View File

@@ -24,6 +24,7 @@ const groupSchema = new Schema<IGroup>(
memberIds: [
{
type: String,
required: false,
},
],
source: {

View File

@@ -1,22 +1,44 @@
import type { Document, Types } from 'mongoose';
import { CursorPaginationParams } from '~/common';
export interface IGroup extends Document {
_id: Types.ObjectId;
/** The name of the group */
name: string;
/** Optional description of the group */
description?: string;
/** Optional email address for the group */
email?: string;
/** Optional avatar URL for the group */
avatar?: string;
/** Array of member IDs (stores idOnTheSource values, not ObjectIds) */
memberIds: string[];
/** The source of the group ('local' or 'entra') */
memberIds?: string[];
source: 'local' | 'entra';
/** External ID (e.g., Entra ID) - required for non-local sources */
idOnTheSource?: string;
/** Timestamps */
createdAt?: Date;
updatedAt?: Date;
}
export interface CreateGroupRequest {
name: string;
description?: string;
email?: string;
avatar?: string;
memberIds?: string[];
source: 'local' | 'entra';
idOnTheSource?: string;
}
export interface UpdateGroupRequest {
name?: string;
description?: string;
email?: string;
avatar?: string;
memberIds?: string[];
source?: 'local' | 'entra' | 'ldap';
idOnTheSource?: string;
}
export interface GroupFilterOptions extends CursorPaginationParams {
// Includes email, name and description
search?: string;
source?: 'local' | 'entra' | 'ldap';
hasMember?: string;
}

View File

@@ -1,5 +1,6 @@
import { Document } from 'mongoose';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { Document } from 'mongoose';
import { CursorPaginationParams } from '~/common';
export interface IRole extends Document {
name: string;
@@ -48,3 +49,25 @@ export interface IRole extends Document {
};
};
}
export type RolePermissions = IRole['permissions'];
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
export type RolePermissionsInput = DeepPartial<RolePermissions>;
export interface CreateRoleRequest {
name: string;
permissions: RolePermissionsInput;
}
export interface UpdateRoleRequest {
name?: string;
permissions?: RolePermissionsInput;
}
export interface RoleFilterOptions extends CursorPaginationParams {
// Includes role name
search?: string;
hasPermission?: string;
}

View File

@@ -1,4 +1,5 @@
import { Document, Types } from 'mongoose';
import type { Document, Types } from 'mongoose';
import { CursorPaginationParams } from '~/common';
export interface IUser extends Document {
name?: string;
@@ -48,18 +49,39 @@ export interface BalanceConfig {
refillAmount?: number;
}
export interface UserCreateData extends Partial<IUser> {
export interface CreateUserRequest extends Partial<IUser> {
email: string;
}
export interface UserUpdateResult {
export interface UpdateUserRequest {
name?: string;
username?: string;
email?: string;
role?: string;
emailVerified?: boolean;
avatar?: string;
plugins?: string[];
twoFactorEnabled?: boolean;
termsAccepted?: boolean;
personalization?: {
memories?: boolean;
};
}
export interface UserDeleteResult {
deletedCount: number;
message: string;
}
export interface UserSearchCriteria {
email?: string;
username?: string;
export interface UserFilterOptions extends CursorPaginationParams {
_id?: Types.ObjectId | string;
// Includes email, username and name
search?: string;
role?: string;
emailVerified?: boolean;
provider?: string;
twoFactorEnabled?: boolean;
// External IDs
googleId?: string;
facebookId?: string;
openidId?: string;
@@ -68,7 +90,9 @@ export interface UserSearchCriteria {
githubId?: string;
discordId?: string;
appleId?: string;
_id?: Types.ObjectId | string;
// Date filters
createdAfter?: string;
createdBefore?: string;
}
export interface UserQueryOptions {