Merge remote-tracking branch 'origin/main' into feature/client-side-image-resize

This commit is contained in:
Rakshit Tiwari 2025-06-24 19:29:31 +05:30
commit 0417a38a6e
137 changed files with 8953 additions and 2277 deletions

View file

@ -98,6 +98,8 @@ jobs:
cd client
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt | sort) || echo "")
# Filter out false positives
UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
echo "CLIENT_UNUSED<<EOF" >> $GITHUB_ENV
echo "$UNUSED" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV

1
.gitignore vendored
View file

@ -55,6 +55,7 @@ bower_components/
# AI
.clineignore
.cursor
.aider*
# Floobits
.floo

View file

@ -190,10 +190,11 @@ class AnthropicClient extends BaseClient {
reverseProxyUrl: this.options.reverseProxyUrl,
}),
apiKey: this.apiKey,
fetchOptions: {},
};
if (this.options.proxy) {
options.httpAgent = new HttpsProxyAgent(this.options.proxy);
options.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy);
}
if (this.options.reverseProxyUrl) {

View file

@ -1159,6 +1159,7 @@ ${convo}
logger.debug('[OpenAIClient] chatCompletion', { baseURL, modelOptions });
const opts = {
baseURL,
fetchOptions: {},
};
if (this.useOpenRouter) {
@ -1177,7 +1178,7 @@ ${convo}
}
if (this.options.proxy) {
opts.httpAgent = new HttpsProxyAgent(this.options.proxy);
opts.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy);
}
/** @type {TAzureConfig | undefined} */
@ -1395,7 +1396,7 @@ ${convo}
...modelOptions,
stream: true,
};
const stream = await openai.beta.chat.completions
const stream = await openai.chat.completions
.stream(params)
.on('abort', () => {
/* Do nothing here */

View file

@ -309,7 +309,7 @@ describe('AnthropicClient', () => {
};
client.setOptions({ modelOptions, promptCache: true });
const anthropicClient = client.getClient(modelOptions);
expect(anthropicClient.defaultHeaders).not.toHaveProperty('anthropic-beta');
expect(anthropicClient._options.defaultHeaders).toBeUndefined();
});
it('should not add beta header for other models', () => {
@ -320,7 +320,7 @@ describe('AnthropicClient', () => {
},
});
const anthropicClient = client.getClient();
expect(anthropicClient.defaultHeaders).not.toHaveProperty('anthropic-beta');
expect(anthropicClient._options.defaultHeaders).toBeUndefined();
});
});

View file

@ -1,14 +1,14 @@
const { mcpToolPattern } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { SerpAPI } = require('@langchain/community/tools/serpapi');
const { Calculator } = require('@langchain/community/tools/calculator');
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
const {
Tools,
Constants,
EToolResources,
loadWebSearchAuth,
replaceSpecialVars,
} = require('librechat-data-provider');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const {
availableTools,
manifestToolMap,
@ -28,11 +28,10 @@ const {
} = require('../');
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getCachedTools } = require('~/server/services/Config');
const { createMCPTool } = require('~/server/services/MCP');
const { logger } = require('~/config');
const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
/**
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
@ -93,7 +92,7 @@ const validateTools = async (user, tools = []) => {
return Array.from(validToolsSet.values());
} catch (err) {
logger.error('[validateTools] There was a problem validating tools', err);
throw new Error('There was a problem validating tools');
throw new Error(err);
}
};
@ -236,7 +235,7 @@ const loadTools = async ({
/** @type {Record<string, string>} */
const toolContextMap = {};
const appTools = options.req?.app?.locals?.availableTools ?? {};
const appTools = (await getCachedTools({ includeGlobal: true })) ?? {};
for (const tool of tools) {
if (tool === Tools.execute_code) {
@ -299,6 +298,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
requestedTools[tool] = async () =>
createMCPTool({
req: options.req,
res: options.res,
toolKey: tool,
model: agent?.model ?? model,
provider: agent?.provider ?? endpoint,

View file

@ -29,6 +29,10 @@ const roles = isRedisEnabled
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: CacheKeys.ROLES });
const mcpTools = isRedisEnabled
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: CacheKeys.MCP_TOOLS });
const audioRuns = isRedisEnabled
? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES })
: new Keyv({ namespace: CacheKeys.AUDIO_RUNS, ttl: Time.TEN_MINUTES });
@ -67,6 +71,7 @@ const openIdExchangedTokensCache = isRedisEnabled
const namespaces = {
[CacheKeys.ROLES]: roles,
[CacheKeys.MCP_TOOLS]: mcpTools,
[CacheKeys.CONFIG_STORE]: config,
[CacheKeys.PENDING_REQ]: pending_req,
[ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }),

View file

@ -15,7 +15,7 @@ let flowManager = null;
*/
function getMCPManager(userId) {
if (!mcpManager) {
mcpManager = MCPManager.getInstance(logger);
mcpManager = MCPManager.getInstance();
} else {
mcpManager.checkIdleConnections(userId);
}
@ -30,7 +30,6 @@ function getFlowStateManager(flowsCache) {
if (!flowManager) {
flowManager = new FlowStateManager(flowsCache, {
ttl: Time.ONE_MINUTE * 3,
logger,
});
}
return flowManager;

View file

@ -1,8 +1,11 @@
const mongoose = require('mongoose');
const { MeiliSearch } = require('meilisearch');
const { logger } = require('@librechat/data-schemas');
const { FlowStateManager } = require('@librechat/api');
const { CacheKeys } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const { getLogStores } = require('~/cache');
const Conversation = mongoose.models.Conversation;
const Message = mongoose.models.Message;
@ -28,43 +31,123 @@ class MeiliSearchClient {
}
}
/**
* Performs the actual sync operations for messages and conversations
*/
async function performSync() {
const client = MeiliSearchClient.getInstance();
const { status } = await client.health();
if (status !== 'available') {
throw new Error('Meilisearch not available');
}
if (indexingDisabled === true) {
logger.info('[indexSync] Indexing is disabled, skipping...');
return { messagesSync: false, convosSync: false };
}
let messagesSync = false;
let convosSync = false;
// Check if we need to sync messages
const messageProgress = await Message.getSyncProgress();
if (!messageProgress.isComplete) {
logger.info(
`[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
);
// Check if we should do a full sync or incremental
const messageCount = await Message.countDocuments();
const messagesIndexed = messageProgress.totalProcessed;
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
if (messageCount - messagesIndexed > syncThreshold) {
logger.info('[indexSync] Starting full message sync due to large difference');
await Message.syncWithMeili();
messagesSync = true;
} else if (messageCount !== messagesIndexed) {
logger.warn('[indexSync] Messages out of sync, performing incremental sync');
await Message.syncWithMeili();
messagesSync = true;
}
} else {
logger.info(
`[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`,
);
}
// Check if we need to sync conversations
const convoProgress = await Conversation.getSyncProgress();
if (!convoProgress.isComplete) {
logger.info(
`[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
);
const convoCount = await Conversation.countDocuments();
const convosIndexed = convoProgress.totalProcessed;
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
if (convoCount - convosIndexed > syncThreshold) {
logger.info('[indexSync] Starting full conversation sync due to large difference');
await Conversation.syncWithMeili();
convosSync = true;
} else if (convoCount !== convosIndexed) {
logger.warn('[indexSync] Convos out of sync, performing incremental sync');
await Conversation.syncWithMeili();
convosSync = true;
}
} else {
logger.info(
`[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`,
);
}
return { messagesSync, convosSync };
}
/**
* Main index sync function that uses FlowStateManager to prevent concurrent execution
*/
async function indexSync() {
if (!searchEnabled) {
return;
}
try {
const client = MeiliSearchClient.getInstance();
const { status } = await client.health();
if (status !== 'available') {
throw new Error('Meilisearch not available');
logger.info('[indexSync] Starting index synchronization check...');
try {
// Get or create FlowStateManager instance
const flowsCache = getLogStores(CacheKeys.FLOWS);
if (!flowsCache) {
logger.warn('[indexSync] Flows cache not available, falling back to direct sync');
return await performSync();
}
if (indexingDisabled === true) {
logger.info('[indexSync] Indexing is disabled, skipping...');
const flowManager = new FlowStateManager(flowsCache, {
ttl: 60000 * 10, // 10 minutes TTL for sync operations
});
// Use a unique flow ID for the sync operation
const flowId = 'meili-index-sync';
const flowType = 'MEILI_SYNC';
// This will only execute the handler if no other instance is running the sync
const result = await flowManager.createFlowWithHandler(flowId, flowType, performSync);
if (result.messagesSync || result.convosSync) {
logger.info('[indexSync] Sync completed successfully');
} else {
logger.debug('[indexSync] No sync was needed');
}
return result;
} catch (err) {
if (err.message.includes('flow already exists')) {
logger.info('[indexSync] Sync already running on another instance');
return;
}
const messageCount = await Message.countDocuments();
const convoCount = await Conversation.countDocuments();
const messages = await client.index('messages').getStats();
const convos = await client.index('convos').getStats();
const messagesIndexed = messages.numberOfDocuments;
const convosIndexed = convos.numberOfDocuments;
logger.debug(`[indexSync] There are ${messageCount} messages and ${messagesIndexed} indexed`);
logger.debug(`[indexSync] There are ${convoCount} convos and ${convosIndexed} indexed`);
if (messageCount !== messagesIndexed) {
logger.debug('[indexSync] Messages out of sync, indexing');
Message.syncWithMeili();
}
if (convoCount !== convosIndexed) {
logger.debug('[indexSync] Convos out of sync, indexing');
Conversation.syncWithMeili();
}
} catch (err) {
if (err.message.includes('not found')) {
logger.debug('[indexSync] Creating indices...');
currentTimeout = setTimeout(async () => {

View file

@ -11,6 +11,7 @@ const {
removeAgentIdsFromProject,
removeAgentFromAllProjects,
} = require('./Project');
const { getCachedTools } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores');
const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
@ -55,12 +56,12 @@ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter)
* @param {string} params.agent_id
* @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Agent|null} The agent document as a plain object, or null if not found.
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/
const loadEphemeralAgent = ({ req, agent_id, endpoint, model_parameters: _m }) => {
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
const { model, ...model_parameters } = _m;
/** @type {Record<string, FunctionTool>} */
const availableTools = req.app.locals.availableTools;
const availableTools = await getCachedTools({ includeGlobal: true });
/** @type {TEphemeralAgent | null} */
const ephemeralAgent = req.body.ephemeralAgent;
const mcpServers = new Set(ephemeralAgent?.mcp);
@ -111,7 +112,7 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
return null;
}
if (agent_id === EPHEMERAL_AGENT_ID) {
return loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
}
const agent = await getAgent({
id: agent_id,

View file

@ -6,6 +6,10 @@ const originalEnv = {
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
process.env.CREDS_IV = '0123456789abcdef';
jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn(),
}));
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { agentSchema } = require('@librechat/data-schemas');
@ -23,6 +27,7 @@ const {
generateActionMetadataHash,
revertAgentVersion,
} = require('./Agent');
const { getCachedTools } = require('~/server/services/Config');
/**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
@ -406,6 +411,7 @@ describe('models/Agent', () => {
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
});
@ -1546,6 +1552,12 @@ describe('models/Agent', () => {
test('should test ephemeral agent loading logic', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({
tool1_mcp_server1: {},
tool2_mcp_server2: {},
another_tool: {},
});
const mockReq = {
user: { id: 'user123' },
body: {
@ -1556,15 +1568,6 @@ describe('models/Agent', () => {
mcp: ['server1', 'server2'],
},
},
app: {
locals: {
availableTools: {
tool1_mcp_server1: {},
tool2_mcp_server2: {},
another_tool: {},
},
},
},
};
const result = await loadAgent({
@ -1657,6 +1660,8 @@ describe('models/Agent', () => {
test('should handle ephemeral agent with no MCP servers', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({});
const mockReq = {
user: { id: 'user123' },
body: {
@ -1667,11 +1672,6 @@ describe('models/Agent', () => {
mcp: [],
},
},
app: {
locals: {
availableTools: {},
},
},
};
const result = await loadAgent({
@ -1692,16 +1692,13 @@ describe('models/Agent', () => {
test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({});
const mockReq = {
user: { id: 'user123' },
body: {
promptPrefix: 'Basic instructions',
},
app: {
locals: {
availableTools: {},
},
},
};
const result = await loadAgent({
@ -1734,6 +1731,13 @@ describe('models/Agent', () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
const largeToolList = Array.from({ length: 100 }, (_, i) => `tool_${i}_mcp_server1`);
const availableTools = largeToolList.reduce((acc, tool) => {
acc[tool] = {};
return acc;
}, {});
getCachedTools.mockResolvedValue(availableTools);
const mockReq = {
user: { id: 'user123' },
body: {
@ -1744,14 +1748,6 @@ describe('models/Agent', () => {
mcp: ['server1'],
},
},
app: {
locals: {
availableTools: largeToolList.reduce((acc, tool) => {
acc[tool] = {};
return acc;
}, {}),
},
},
};
const result = await loadAgent({
@ -2272,6 +2268,13 @@ describe('models/Agent', () => {
test('should handle loadEphemeralAgent with malformed MCP tool names', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({
malformed_tool_name: {}, // No mcp delimiter
tool__server1: {}, // Wrong delimiter
tool_mcp_server1: {}, // Correct format
tool_mcp_server2: {}, // Different server
});
const mockReq = {
user: { id: 'user123' },
body: {
@ -2282,16 +2285,6 @@ describe('models/Agent', () => {
mcp: ['server1'],
},
},
app: {
locals: {
availableTools: {
malformed_tool_name: {}, // No mcp delimiter
tool__server1: {}, // Wrong delimiter
tool_mcp_server1: {}, // Correct format
tool_mcp_server2: {}, // Different server
},
},
},
};
const result = await loadAgent({

View file

@ -1,346 +0,0 @@
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const { Conversation, SharedLink } = require('~/db/models');
const { getMessages } = require('./Message');
class ShareServiceError extends Error {
constructor(message, code) {
super(message);
this.name = 'ShareServiceError';
this.code = code;
}
}
const memoizedAnonymizeId = (prefix) => {
const memo = new Map();
return (id) => {
if (!memo.has(id)) {
memo.set(id, `${prefix}_${nanoid()}`);
}
return memo.get(id);
};
};
const anonymizeConvoId = memoizedAnonymizeId('convo');
const anonymizeAssistantId = memoizedAnonymizeId('a');
const anonymizeMessageId = (id) =>
id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id);
function anonymizeConvo(conversation) {
if (!conversation) {
return null;
}
const newConvo = { ...conversation };
if (newConvo.assistant_id) {
newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id);
}
return newConvo;
}
function anonymizeMessages(messages, newConvoId) {
if (!Array.isArray(messages)) {
return [];
}
const idMap = new Map();
return messages.map((message) => {
const newMessageId = anonymizeMessageId(message.messageId);
idMap.set(message.messageId, newMessageId);
const anonymizedAttachments = message.attachments?.map((attachment) => {
return {
...attachment,
messageId: newMessageId,
conversationId: newConvoId,
};
});
return {
...message,
messageId: newMessageId,
parentMessageId:
idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId),
conversationId: newConvoId,
model: message.model?.startsWith('asst_')
? anonymizeAssistantId(message.model)
: message.model,
attachments: anonymizedAttachments,
};
});
}
async function getSharedMessages(shareId) {
try {
const share = await SharedLink.findOne({ shareId, isPublic: true })
.populate({
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();
if (!share?.conversationId || !share.isPublic) {
return null;
}
const newConvoId = anonymizeConvoId(share.conversationId);
const result = {
...share,
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
};
return result;
} catch (error) {
logger.error('[getShare] Error getting share link', {
error: error.message,
shareId,
});
throw new ShareServiceError('Error getting share link', 'SHARE_FETCH_ERROR');
}
}
async function getSharedLinks(user, pageParam, pageSize, isPublic, sortBy, sortDirection, search) {
try {
const query = { user, isPublic };
if (pageParam) {
if (sortDirection === 'desc') {
query[sortBy] = { $lt: pageParam };
} else {
query[sortBy] = { $gt: pageParam };
}
}
if (search && search.trim()) {
try {
const searchResults = await Conversation.meiliSearch(search);
if (!searchResults?.hits?.length) {
return {
links: [],
nextCursor: undefined,
hasNextPage: false,
};
}
const conversationIds = searchResults.hits.map((hit) => hit.conversationId);
query['conversationId'] = { $in: conversationIds };
} catch (searchError) {
logger.error('[getSharedLinks] Meilisearch error', {
error: searchError.message,
user,
});
return {
links: [],
nextCursor: undefined,
hasNextPage: false,
};
}
}
const sort = {};
sort[sortBy] = sortDirection === 'desc' ? -1 : 1;
if (Array.isArray(query.conversationId)) {
query.conversationId = { $in: query.conversationId };
}
const sharedLinks = await SharedLink.find(query)
.sort(sort)
.limit(pageSize + 1)
.select('-__v -user')
.lean();
const hasNextPage = sharedLinks.length > pageSize;
const links = sharedLinks.slice(0, pageSize);
const nextCursor = hasNextPage ? links[links.length - 1][sortBy] : undefined;
return {
links: links.map((link) => ({
shareId: link.shareId,
title: link?.title || 'Untitled',
isPublic: link.isPublic,
createdAt: link.createdAt,
conversationId: link.conversationId,
})),
nextCursor,
hasNextPage,
};
} catch (error) {
logger.error('[getSharedLinks] Error getting shares', {
error: error.message,
user,
});
throw new ShareServiceError('Error getting shares', 'SHARES_FETCH_ERROR');
}
}
async function deleteAllSharedLinks(user) {
try {
const result = await SharedLink.deleteMany({ user });
return {
message: 'All shared links deleted successfully',
deletedCount: result.deletedCount,
};
} catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', {
error: error.message,
user,
});
throw new ShareServiceError('Error deleting shared links', 'BULK_DELETE_ERROR');
}
}
async function createSharedLink(user, conversationId) {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const [existingShare, conversationMessages] = await Promise.all([
SharedLink.findOne({ conversationId, isPublic: true }).select('-_id -__v -user').lean(),
getMessages({ conversationId }),
]);
if (existingShare && existingShare.isPublic) {
throw new ShareServiceError('Share already exists', 'SHARE_EXISTS');
} else if (existingShare) {
await SharedLink.deleteOne({ conversationId });
}
const conversation = await Conversation.findOne({ conversationId }).lean();
const title = conversation?.title || 'Untitled';
const shareId = nanoid();
await SharedLink.create({
shareId,
conversationId,
messages: conversationMessages,
title,
user,
});
return { shareId, conversationId };
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', {
error: error.message,
user,
conversationId,
});
throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR');
}
}
async function getSharedLink(user, conversationId) {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const share = await SharedLink.findOne({ conversationId, user, isPublic: true })
.select('shareId -_id')
.lean();
if (!share) {
return { shareId: null, success: false };
}
return { shareId: share.shareId, success: true };
} catch (error) {
logger.error('[getSharedLink] Error getting shared link', {
error: error.message,
user,
conversationId,
});
throw new ShareServiceError('Error getting shared link', 'SHARE_FETCH_ERROR');
}
}
async function updateSharedLink(user, shareId) {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const share = await SharedLink.findOne({ shareId }).select('-_id -__v -user').lean();
if (!share) {
throw new ShareServiceError('Share not found', 'SHARE_NOT_FOUND');
}
const [updatedMessages] = await Promise.all([
getMessages({ conversationId: share.conversationId }),
]);
const newShareId = nanoid();
const update = {
messages: updatedMessages,
user,
shareId: newShareId,
};
const updatedShare = await SharedLink.findOneAndUpdate({ shareId, user }, update, {
new: true,
upsert: false,
runValidators: true,
}).lean();
if (!updatedShare) {
throw new ShareServiceError('Share update failed', 'SHARE_UPDATE_ERROR');
}
anonymizeConvo(updatedShare);
return { shareId: newShareId, conversationId: updatedShare.conversationId };
} catch (error) {
logger.error('[updateSharedLink] Error updating shared link', {
error: error.message,
user,
shareId,
});
throw new ShareServiceError(
error.code === 'SHARE_UPDATE_ERROR' ? error.message : 'Error updating shared link',
error.code || 'SHARE_UPDATE_ERROR',
);
}
}
async function deleteSharedLink(user, shareId) {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const result = await SharedLink.findOneAndDelete({ shareId, user }).lean();
if (!result) {
return null;
}
return {
success: true,
shareId,
message: 'Share deleted successfully',
};
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', {
error: error.message,
user,
shareId,
});
throw new ShareServiceError('Error deleting shared link', 'SHARE_DELETE_ERROR');
}
}
module.exports = {
getSharedLink,
getSharedLinks,
createSharedLink,
updateSharedLink,
deleteSharedLink,
getSharedMessages,
deleteAllSharedLinks,
};

View file

@ -1,42 +0,0 @@
const { findToken, updateToken, createToken } = require('~/models');
const { encryptV2 } = require('~/server/utils/crypto');
/**
* Handles the OAuth token by creating or updating the token.
* @param {object} fields
* @param {string} fields.userId - The user's ID.
* @param {string} fields.token - The full token to store.
* @param {string} fields.identifier - Unique, alternative identifier for the token.
* @param {number} fields.expiresIn - The number of seconds until the token expires.
* @param {object} fields.metadata - Additional metadata to store with the token.
* @param {string} [fields.type="oauth"] - The type of token. Default is 'oauth'.
*/
async function handleOAuthToken({
token,
userId,
identifier,
expiresIn,
metadata,
type = 'oauth',
}) {
const encrypedToken = await encryptV2(token);
const tokenData = {
type,
userId,
metadata,
identifier,
token: encrypedToken,
expiresIn: parseInt(expiresIn, 10) || 3600,
};
const existingToken = await findToken({ userId, identifier });
if (existingToken) {
return await updateToken({ identifier }, tokenData);
} else {
return await createToken(tokenData);
}
}
module.exports = {
handleOAuthToken,
};

View file

@ -1,6 +1,6 @@
const mongoose = require('mongoose');
const { getRandomValues } = require('@librechat/api');
const { logger, hashToken } = require('@librechat/data-schemas');
const { getRandomValues } = require('~/server/utils/crypto');
const { createToken, findToken } = require('~/models');
/**

View file

@ -78,7 +78,7 @@ const tokenValues = Object.assign(
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
'o4-mini': { prompt: 1.1, completion: 4.4 },
'o3-mini': { prompt: 1.1, completion: 4.4 },
o3: { prompt: 10, completion: 40 },
o3: { prompt: 2, completion: 8 },
'o1-mini': { prompt: 1.1, completion: 4.4 },
'o1-preview': { prompt: 15, completion: 60 },
o1: { prompt: 15, completion: 60 },

View file

@ -34,21 +34,21 @@
},
"homepage": "https://librechat.ai",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/sdk": "^0.52.0",
"@aws-sdk/client-s3": "^3.758.0",
"@aws-sdk/s3-request-presigner": "^3.758.0",
"@azure/identity": "^4.7.0",
"@azure/search-documents": "^12.0.0",
"@azure/storage-blob": "^12.27.0",
"@google/generative-ai": "^0.23.0",
"@google/generative-ai": "^0.24.0",
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
"@langchain/community": "^0.3.44",
"@langchain/core": "^0.3.57",
"@langchain/google-genai": "^0.2.9",
"@langchain/google-vertexai": "^0.2.9",
"@langchain/community": "^0.3.47",
"@langchain/core": "^0.3.60",
"@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.38",
"@librechat/agents": "^2.4.41",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",

View file

@ -1,9 +1,11 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, AuthType } = require('librechat-data-provider');
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
const { getToolkitKey } = require('~/server/services/ToolService');
const { getCustomConfig } = require('~/server/services/Config');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { availableTools } = require('~/app/clients/tools');
const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache');
const { Constants } = require('librechat-data-provider');
/**
* Filters out duplicate plugins from the list of plugins.
@ -84,6 +86,45 @@ 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.
*
@ -109,7 +150,16 @@ const getAvailableTools = async (req, res) => {
const customConfig = await getCustomConfig();
if (customConfig?.mcpServers != null) {
const mcpManager = getMCPManager();
pluginManifest = await mcpManager.loadManifestTools(pluginManifest);
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];
}
/** @type {TPlugin[]} */
@ -123,17 +173,57 @@ const getAvailableTools = async (req, res) => {
}
});
const toolDefinitions = req.app.locals.availableTools;
const tools = authenticatedPlugins.filter(
(plugin) =>
toolDefinitions[plugin.pluginKey] !== undefined ||
(plugin.toolkit === true &&
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey)),
);
const toolDefinitions = await getCachedTools({ includeGlobal: true });
await cache.set(CacheKeys.TOOLS, tools);
res.status(200).json(tools);
const toolsOutput = [];
for (const plugin of authenticatedPlugins) {
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
const isToolkit =
plugin.toolkit === true &&
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey);
if (!isToolDefined && !isToolkit) {
continue;
}
const toolToAdd = { ...plugin };
if (!plugin.pluginKey.includes(Constants.mcp_delimiter)) {
toolsOutput.push(toolToAdd);
continue;
}
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;
}
toolsOutput.push(toolToAdd);
}
const finalTools = filterUniquePlugins(toolsOutput);
await cache.set(CacheKeys.TOOLS, finalTools);
res.status(200).json(finalTools);
} catch (error) {
logger.error('[getAvailableTools]', error);
res.status(500).json({ message: error.message });
}
};

View file

@ -1,3 +1,4 @@
const { encryptV3 } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
verifyTOTP,
@ -7,7 +8,6 @@ const {
generateBackupCodes,
} = require('~/server/services/twoFactorService');
const { getUserById, updateUser } = require('~/models');
const { encryptV3 } = require('~/server/utils/crypto');
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');

View file

@ -1,5 +1,6 @@
const {
Tools,
Constants,
FileSources,
webSearchKeys,
extractWebSearchEnvVars,
@ -21,8 +22,9 @@ const { verifyEmail, resendVerificationEmail } = require('~/server/services/Auth
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { Transaction, Balance, User } = require('~/db/models');
const { deleteAllSharedLinks } = require('~/models/Share');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteAllSharedLinks } = require('~/models');
const { getMCPManager } = require('~/config');
const getUserController = async (req, res) => {
/** @type {MongoUser} */
@ -102,10 +104,22 @@ const updateUserPluginsController = async (req, res) => {
}
let keys = Object.keys(auth);
if (keys.length === 0 && pluginKey !== Tools.web_search) {
const values = Object.values(auth); // Used in 'install' block
const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter);
// Early exit condition:
// If keys are empty (meaning auth: {} was likely sent for uninstall, or auth was empty for install)
// AND it's not web_search (which has special key handling to populate `keys` for uninstall)
// AND it's NOT (an uninstall action FOR an MCP tool - we need to proceed for this case to clear all its auth)
// THEN return.
if (
keys.length === 0 &&
pluginKey !== Tools.web_search &&
!(action === 'uninstall' && isMCPTool)
) {
return res.status(200).send();
}
const values = Object.values(auth);
/** @type {number} */
let status = 200;
@ -132,16 +146,53 @@ const updateUserPluginsController = async (req, res) => {
}
}
} else if (action === 'uninstall') {
for (let i = 0; i < keys.length; i++) {
authService = await deleteUserPluginAuth(user.id, keys[i]);
// const isMCPTool was defined earlier
if (isMCPTool && keys.length === 0) {
// This handles the case where auth: {} is sent for an MCP tool uninstall.
// It means "delete all credentials associated with this MCP pluginKey".
authService = await deleteUserPluginAuth(user.id, null, true, pluginKey);
if (authService instanceof Error) {
logger.error('[authService]', authService);
logger.error(
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
authService,
);
({ status, message } = authService);
}
} else {
// This handles:
// 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
// 2. Other tools uninstall (if keys were provided).
// 3. MCP tool uninstall if specific keys were provided in `auth` (not current frontend behavior).
// If keys is empty for non-MCP tools (and not web_search), this loop won't run, and nothing is deleted.
for (let i = 0; i < keys.length; i++) {
authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
if (authService instanceof Error) {
logger.error('[authService] Error deleting specific auth key:', authService);
({ status, message } = authService);
}
}
}
}
if (status === 200) {
// If auth was updated successfully, disconnect MCP sessions as they might use these credentials
if (pluginKey.startsWith(Constants.mcp_prefix)) {
try {
const mcpManager = getMCPManager(user.id);
if (mcpManager) {
logger.info(
`[updateUserPluginsController] Disconnecting MCP connections for user ${user.id} after plugin auth update for ${pluginKey}.`,
);
await mcpManager.disconnectUserConnections(user.id);
}
} catch (disconnectError) {
logger.error(
`[updateUserPluginsController] Error disconnecting MCP connections for user ${user.id} after plugin auth update:`,
disconnectError,
);
// Do not fail the request for this, but log it.
}
}
return res.status(status).send();
}

View file

@ -31,11 +31,15 @@ const {
} = require('librechat-data-provider');
const { DynamicStructuredTool } = require('@langchain/core/tools');
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
const { getCustomEndpointConfig, checkCapability } = require('~/server/services/Config');
const {
getCustomEndpointConfig,
createGetMCPAuthMap,
checkCapability,
} = 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 { setMemory, deleteMemory, getFormattedMemories } = require('~/models');
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
const { checkAccess } = require('~/server/middleware/roles/access');
@ -679,6 +683,8 @@ class AgentClient extends BaseClient {
version: 'v2',
};
const getUserMCPAuthMap = await createGetMCPAuthMap();
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
payload,
@ -798,6 +804,20 @@ class AgentClient extends BaseClient {
run.Graph.contentData = contentData;
}
try {
if (getUserMCPAuthMap) {
config.configurable.userMCPAuthMap = await getUserMCPAuthMap({
tools: agent.tools,
userId: this.options.req.user.id,
});
}
} catch (err) {
logger.error(
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent ${agent.id}`,
err,
);
}
await run.processStream({ messages }, config, {
keepContent: i !== 0,
tokenCounter: createTokenCounter(this.getEncoding()),

View file

@ -1,9 +1,9 @@
const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const {
Tools,
Constants,
FileContext,
FileSources,
SystemRoles,
EToolResources,
@ -16,16 +16,16 @@ const {
deleteAgent,
getListAgents,
} = require('~/models/Agent');
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { filterFile } = require('~/server/services/Files/process');
const { updateAction, getActions } = require('~/models/Action');
const { getCachedTools } = require('~/server/services/Config');
const { updateAgentProjects } = require('~/models/Agent');
const { getProjectByName } = require('~/models/Project');
const { deleteFileByFilter } = require('~/models/File');
const { revertAgentVersion } = require('~/models/Agent');
const { logger } = require('~/config');
const { deleteFileByFilter } = require('~/models/File');
const systemTools = {
[Tools.execute_code]: true,
@ -47,8 +47,9 @@ const createAgentHandler = async (req, res) => {
agentData.tools = [];
const availableTools = await getCachedTools({ includeGlobal: true });
for (const tool of tools) {
if (req.app.locals.availableTools[tool]) {
if (availableTools[tool]) {
agentData.tools.push(tool);
}
@ -445,7 +446,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
try {
await fs.unlink(req.file.path);
logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
} catch (error) {
} catch {
logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
}
}

View file

@ -1,4 +1,5 @@
const fs = require('fs').promises;
const { logger } = require('@librechat/data-schemas');
const { FileContext } = require('librechat-data-provider');
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
@ -6,9 +7,9 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { deleteAssistantActions } = require('~/server/services/ActionService');
const { updateAssistantDoc, getAssistants } = require('~/models/Assistant');
const { getOpenAIClient, fetchAssistants } = require('./helpers');
const { getCachedTools } = require('~/server/services/Config');
const { manifestToolMap } = require('~/app/clients/tools');
const { deleteFileByFilter } = require('~/models/File');
const { logger } = require('~/config');
/**
* Create an assistant.
@ -30,21 +31,20 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
assistantData.tools = tools
.map((tool) => {
if (typeof tool !== 'string') {
return tool;
}
const toolDefinitions = req.app.locals.availableTools;
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return (
Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val)
);
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;
@ -135,21 +135,21 @@ const patchAssistant = async (req, res) => {
append_current_datetime,
...updateData
} = req.body;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
updateData.tools = (updateData.tools ?? [])
.map((tool) => {
if (typeof tool !== 'string') {
return tool;
}
const toolDefinitions = req.app.locals.availableTools;
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return (
Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val)
);
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;

View file

@ -1,10 +1,11 @@
const { logger } = require('@librechat/data-schemas');
const { ToolCallTypes } = require('librechat-data-provider');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { validateAndUpdateTool } = require('~/server/services/ActionService');
const { getCachedTools } = require('~/server/services/Config');
const { updateAssistantDoc } = require('~/models/Assistant');
const { manifestToolMap } = require('~/app/clients/tools');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/**
* Create an assistant.
@ -27,21 +28,20 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
assistantData.tools = tools
.map((tool) => {
if (typeof tool !== 'string') {
return tool;
}
const toolDefinitions = req.app.locals.availableTools;
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return (
Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val)
);
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;
@ -125,13 +125,13 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
let hasFileSearch = false;
for (const tool of updateData.tools ?? []) {
const toolDefinitions = req.app.locals.availableTools;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool;
if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
actualTool = Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val);
} else if (!actualTool) {
continue;

View file

@ -1,22 +1,22 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
require('module-alias')({ base: path.resolve(__dirname, '..') });
const cors = require('cors');
const axios = require('axios');
const express = require('express');
const compression = require('compression');
const passport = require('passport');
const mongoSanitize = require('express-mongo-sanitize');
const fs = require('fs');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const { connectDb, indexSync } = require('~/db');
const { jwtLogin, passportLogin } = require('~/strategies');
const { isEnabled } = require('~/server/utils');
const { ldapLogin } = require('~/strategies');
const { logger } = require('~/config');
const validateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const errorController = require('./controllers/ErrorController');
const initializeMCP = require('./services/initializeMCP');
const configureSocialLogins = require('./socialLogins');
const AppService = require('./services/AppService');
const staticCache = require('./utils/staticCache');
@ -39,7 +39,9 @@ const startServer = async () => {
await connectDb();
logger.info('Connected to MongoDB');
await indexSync();
indexSync().catch((err) => {
logger.error('[indexSync] Background sync failed:', err);
});
app.disable('x-powered-by');
app.set('trust proxy', trusted_proxy);
@ -119,6 +121,7 @@ const startServer = async () => {
app.use('/api/bedrock', routes.bedrock);
app.use('/api/memories', routes.memories);
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
app.use((req, res) => {
res.set({
@ -142,6 +145,8 @@ const startServer = async () => {
} else {
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
}
initializeMCP(app);
});
};
@ -184,5 +189,5 @@ process.on('uncaughtException', (err) => {
process.exit(1);
});
// export app for easier testing purposes
/** Export app for easier testing purposes */
module.exports = app;

View file

@ -1,8 +1,8 @@
const { isEnabled } = require('@librechat/api');
const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
const { searchConversation } = require('~/models/Conversation');
const denyRequest = require('~/server/middleware/denyRequest');
const { logViolation, getLogStores } = require('~/cache');
const { isEnabled } = require('~/server/utils');
const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {};

View file

@ -1,8 +1,10 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { getAccessToken } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { getAccessToken } = require('~/server/services/TokenService');
const { logger, getFlowStateManager } = require('~/config');
const { findToken, updateToken, createToken } = require('~/models');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const router = express.Router();
@ -28,18 +30,19 @@ router.get('/:action_id/oauth/callback', async (req, res) => {
try {
decodedState = jwt.verify(state, JWT_SECRET);
} catch (err) {
logger.error('Error verifying state parameter:', err);
await flowManager.failFlow(identifier, 'oauth', 'Invalid or expired state parameter');
return res.status(400).send('Invalid or expired state parameter');
return res.redirect('/oauth/error?error=invalid_state');
}
if (decodedState.action_id !== action_id) {
await flowManager.failFlow(identifier, 'oauth', 'Mismatched action ID in state parameter');
return res.status(400).send('Mismatched action ID in state parameter');
return res.redirect('/oauth/error?error=invalid_state');
}
if (!decodedState.user) {
await flowManager.failFlow(identifier, 'oauth', 'Invalid user ID in state parameter');
return res.status(400).send('Invalid user ID in state parameter');
return res.redirect('/oauth/error?error=invalid_state');
}
identifier = `${decodedState.user}:${action_id}`;
const flowState = await flowManager.getFlowState(identifier, 'oauth');
@ -47,91 +50,34 @@ router.get('/:action_id/oauth/callback', async (req, res) => {
throw new Error('OAuth flow not found');
}
const tokenData = await getAccessToken({
code,
userId: decodedState.user,
identifier,
client_url: flowState.metadata.client_url,
redirect_uri: flowState.metadata.redirect_uri,
token_exchange_method: flowState.metadata.token_exchange_method,
/** Encrypted values */
encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
});
const tokenData = await getAccessToken(
{
code,
userId: decodedState.user,
identifier,
client_url: flowState.metadata.client_url,
redirect_uri: flowState.metadata.redirect_uri,
token_exchange_method: flowState.metadata.token_exchange_method,
/** Encrypted values */
encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
},
{
findToken,
updateToken,
createToken,
},
);
await flowManager.completeFlow(identifier, 'oauth', tokenData);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
background-color: rgb(249, 250, 251);
margin: 0;
padding: 2rem;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
background-color: white;
border-radius: 0.5rem;
padding: 2rem;
max-width: 28rem;
width: 100%;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
text-align: center;
}
.heading {
color: rgb(17, 24, 39);
font-size: 1.875rem;
font-weight: 700;
margin: 0 0 1rem;
}
.description {
color: rgb(75, 85, 99);
font-size: 0.875rem;
margin: 0.5rem 0;
}
.countdown {
color: rgb(99, 102, 241);
font-weight: 500;
}
</style>
</head>
<body>
<div class="card">
<h1 class="heading">Authentication Successful</h1>
<p class="description">
Your authentication was successful. This window will close in
<span class="countdown" id="countdown">3</span> seconds.
</p>
</div>
<script>
let secondsLeft = 3;
const countdownElement = document.getElementById('countdown');
const countdown = setInterval(() => {
secondsLeft--;
countdownElement.textContent = secondsLeft;
if (secondsLeft <= 0) {
clearInterval(countdown);
window.close();
}
}, 1000);
</script>
</body>
</html>
`);
/** Redirect to React success page */
const serverName = flowState.metadata?.action_name || `Action ${action_id}`;
const redirectUrl = `/oauth/success?serverName=${encodeURIComponent(serverName)}`;
res.redirect(redirectUrl);
} catch (error) {
logger.error('Error in OAuth callback:', error);
await flowManager.failFlow(identifier, 'oauth', error);
res.status(500).send('Authentication failed. Please try again.');
res.redirect('/oauth/error?error=callback_failed');
}
});

View file

@ -1,10 +1,11 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getProjectByName } = require('~/models/Project');
const { isEnabled } = require('~/server/utils');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
const router = express.Router();
const emailLoginEnabled =
@ -21,6 +22,7 @@ const publicSharedLinksEnabled =
router.get('/', async function (req, res) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
if (cachedStartupConfig) {
res.send(cachedStartupConfig);
@ -96,6 +98,18 @@ router.get('/', async function (req, res) {
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
};
payload.mcpServers = {};
const config = await getCustomConfig();
if (config?.mcpServers != null) {
for (const serverName in config.mcpServers) {
const serverConfig = config.mcpServers[serverName];
payload.mcpServers[serverName] = {
customUserVars: serverConfig?.customUserVars || {},
};
}
}
/** @type {TCustomConfig['webSearch']} */
const webSearchConfig = req.app.locals.webSearch;
if (

View file

@ -27,6 +27,7 @@ const edit = require('./edit');
const keys = require('./keys');
const user = require('./user');
const ask = require('./ask');
const mcp = require('./mcp');
module.exports = {
ask,
@ -58,4 +59,5 @@ module.exports = {
assistants,
categories,
staticRoute,
mcp,
};

205
api/server/routes/mcp.js Normal file
View file

@ -0,0 +1,205 @@
const { Router } = require('express');
const { MCPOAuthHandler } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { requireJwtAuth } = require('~/server/middleware');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const router = Router();
/**
* Initiate OAuth flow
* This endpoint is called when the user clicks the auth link in the UI
*/
router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
try {
const { serverName } = req.params;
const { userId, flowId } = req.query;
const user = req.user;
// Verify the userId matches the authenticated user
if (userId !== user.id) {
return res.status(403).json({ error: 'User mismatch' });
}
logger.debug('[MCP OAuth] Initiate request', { serverName, userId, flowId });
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
/** Flow state to retrieve OAuth config */
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
logger.error('[MCP OAuth] Flow state not found', { flowId });
return res.status(404).json({ error: 'Flow not found' });
}
const { serverUrl, oauth: oauthConfig } = flowState.metadata || {};
if (!serverUrl || !oauthConfig) {
logger.error('[MCP OAuth] Missing server URL or OAuth config in flow state');
return res.status(400).json({ error: 'Invalid flow state' });
}
const { authorizationUrl, flowId: oauthFlowId } = await MCPOAuthHandler.initiateOAuthFlow(
serverName,
serverUrl,
userId,
oauthConfig,
);
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
// Redirect user to the authorization URL
res.redirect(authorizationUrl);
} catch (error) {
logger.error('[MCP OAuth] Failed to initiate OAuth', error);
res.status(500).json({ error: 'Failed to initiate OAuth' });
}
});
/**
* OAuth callback handler
* This handles the OAuth callback after the user has authorized the application
*/
router.get('/:serverName/oauth/callback', async (req, res) => {
try {
const { serverName } = req.params;
const { code, state, error: oauthError } = req.query;
logger.debug('[MCP OAuth] Callback received', {
serverName,
code: code ? 'present' : 'missing',
state,
error: oauthError,
});
if (oauthError) {
logger.error('[MCP OAuth] OAuth error received', { error: oauthError });
return res.redirect(`/oauth/error?error=${encodeURIComponent(String(oauthError))}`);
}
if (!code || typeof code !== 'string') {
logger.error('[MCP OAuth] Missing or invalid code');
return res.redirect('/oauth/error?error=missing_code');
}
if (!state || typeof state !== 'string') {
logger.error('[MCP OAuth] Missing or invalid state');
return res.redirect('/oauth/error?error=missing_state');
}
// Extract flow ID from state
const flowId = state;
logger.debug('[MCP OAuth] Using flow ID from state', { flowId });
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
if (!flowState) {
logger.error('[MCP OAuth] Flow state not found for flowId:', flowId);
return res.redirect('/oauth/error?error=invalid_state');
}
logger.debug('[MCP OAuth] Flow state details', {
serverName: flowState.serverName,
userId: flowState.userId,
hasMetadata: !!flowState.metadata,
hasClientInfo: !!flowState.clientInfo,
hasCodeVerifier: !!flowState.codeVerifier,
});
// Complete the OAuth flow
logger.debug('[MCP OAuth] Completing OAuth flow');
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
// For system-level OAuth, we need to store the tokens and retry the connection
if (flowState.userId === 'system') {
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
}
/** ID of the flow that the tool/connection is waiting for */
const toolFlowId = flowState.metadata?.toolFlowId;
if (toolFlowId) {
logger.debug('[MCP OAuth] Completing tool flow', { toolFlowId });
await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
}
/** Redirect to success page with flowId and serverName */
const redirectUrl = `/oauth/success?serverName=${encodeURIComponent(serverName)}`;
res.redirect(redirectUrl);
} catch (error) {
logger.error('[MCP OAuth] OAuth callback error', error);
res.redirect('/oauth/error?error=callback_failed');
}
});
/**
* Get OAuth tokens for a completed flow
* This is primarily for user-level OAuth flows
*/
router.get('/oauth/tokens/:flowId', requireJwtAuth, async (req, res) => {
try {
const { flowId } = req.params;
const user = req.user;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
// Allow system flows or user-owned flows
if (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) {
return res.status(403).json({ error: 'Access denied' });
}
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
return res.status(404).json({ error: 'Flow not found' });
}
if (flowState.status !== 'COMPLETED') {
return res.status(400).json({ error: 'Flow not completed' });
}
res.json({ tokens: flowState.result });
} catch (error) {
logger.error('[MCP OAuth] Failed to get tokens', error);
res.status(500).json({ error: 'Failed to get tokens' });
}
});
/**
* Check OAuth flow status
* This endpoint can be used to poll the status of an OAuth flow
*/
router.get('/oauth/status/:flowId', async (req, res) => {
try {
const { flowId } = req.params;
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
return res.status(404).json({ error: 'Flow not found' });
}
res.json({
status: flowState.status,
completed: flowState.status === 'COMPLETED',
failed: flowState.status === 'FAILED',
error: flowState.error,
});
} catch (error) {
logger.error('[MCP OAuth] Failed to get flow status', error);
res.status(500).json({ error: 'Failed to get flow status' });
}
});
module.exports = router;

View file

@ -47,7 +47,9 @@ const oauthHandler = async (req, res) => {
router.get('/error', (req, res) => {
// A single error message is pushed by passport when authentication fails.
logger.error('Error in OAuth authentication:', { message: req.session.messages.pop() });
logger.error('Error in OAuth authentication:', {
message: req.session?.messages?.pop() || 'Unknown error',
});
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
res.redirect(`${domains.client}/login?redirect=false`);

View file

@ -1,15 +1,15 @@
const express = require('express');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
getSharedLink,
getSharedMessages,
createSharedLink,
updateSharedLink,
getSharedLinks,
deleteSharedLink,
} = require('~/models/Share');
getSharedLinks,
getSharedLink,
} = require('~/models');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { isEnabled } = require('~/server/utils');
const router = express.Router();
/**
@ -35,6 +35,7 @@ if (allowSharedLinks) {
res.status(404).end();
}
} catch (error) {
logger.error('Error getting shared messages:', error);
res.status(500).json({ message: 'Error getting shared messages' });
}
},
@ -54,9 +55,7 @@ router.get('/', requireJwtAuth, async (req, res) => {
sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
? req.query.sortDirection
: 'desc',
search: req.query.search
? decodeURIComponent(req.query.search.trim())
: undefined,
search: req.query.search ? decodeURIComponent(req.query.search.trim()) : undefined,
};
const result = await getSharedLinks(
@ -75,7 +74,7 @@ router.get('/', requireJwtAuth, async (req, res) => {
hasNextPage: result.hasNextPage,
});
} catch (error) {
console.error('Error getting shared links:', error);
logger.error('Error getting shared links:', error);
res.status(500).json({
message: 'Error getting shared links',
error: error.message,
@ -93,6 +92,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
conversationId: req.params.conversationId,
});
} catch (error) {
logger.error('Error getting shared link:', error);
res.status(500).json({ message: 'Error getting shared link' });
}
});
@ -106,6 +106,7 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => {
res.status(404).end();
}
} catch (error) {
logger.error('Error creating shared link:', error);
res.status(500).json({ message: 'Error creating shared link' });
}
});
@ -119,6 +120,7 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => {
res.status(404).end();
}
} catch (error) {
logger.error('Error updating shared link:', error);
res.status(500).json({ message: 'Error updating shared link' });
}
});
@ -133,7 +135,8 @@ router.delete('/:shareId', requireJwtAuth, async (req, res) => {
return res.status(200).json(result);
} catch (error) {
return res.status(400).json({ message: error.message });
logger.error('Error deleting shared link:', error);
return res.status(400).json({ message: 'Error deleting shared link' });
}
});

View file

@ -3,7 +3,13 @@ const { nanoid } = require('nanoid');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { GraphEvents, sleep } = require('@librechat/agents');
const { sendEvent, logAxiosError } = require('@librechat/api');
const {
sendEvent,
encryptV2,
decryptV2,
logAxiosError,
refreshAccessToken,
} = require('@librechat/api');
const {
Time,
CacheKeys,
@ -14,13 +20,11 @@ const {
isImageVisionTool,
actionDomainSeparator,
} = require('librechat-data-provider');
const { refreshAccessToken } = require('~/server/services/TokenService');
const { encryptV2, decryptV2 } = require('~/server/utils/crypto');
const { findToken, updateToken, createToken } = require('~/models');
const { getActions, deleteActions } = require('~/models/Action');
const { deleteAssistant } = require('~/models/Assistant');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const { findToken } = require('~/models');
const JWT_SECRET = process.env.JWT_SECRET;
const toolNameRegex = /^[a-zA-Z0-9_-]+$/;
@ -258,15 +262,22 @@ async function createActionTool({
try {
const refresh_token = await decryptV2(refreshTokenData.token);
const refreshTokens = async () =>
await refreshAccessToken({
userId,
identifier,
refresh_token,
client_url: metadata.auth.client_url,
encrypted_oauth_client_id: encrypted.oauth_client_id,
token_exchange_method: metadata.auth.token_exchange_method,
encrypted_oauth_client_secret: encrypted.oauth_client_secret,
});
await refreshAccessToken(
{
userId,
identifier,
refresh_token,
client_url: metadata.auth.client_url,
encrypted_oauth_client_id: encrypted.oauth_client_id,
token_exchange_method: metadata.auth.token_exchange_method,
encrypted_oauth_client_secret: encrypted.oauth_client_secret,
},
{
findToken,
updateToken,
createToken,
},
);
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const refreshData = await flowManager.createFlowWithHandler(

View file

@ -1,7 +1,6 @@
const {
FileSources,
loadOCRConfig,
processMCPEnv,
EModelEndpoint,
loadMemoryConfig,
getConfigDefaults,
@ -28,7 +27,7 @@ const { initializeS3 } = require('./Files/S3/initialize');
const { loadAndFormatTools } = require('./ToolService');
const { isEnabled } = require('~/server/utils');
const { initializeRoles } = require('~/models');
const { getMCPManager } = require('~/config');
const { setCachedTools } = require('./Config');
const paths = require('~/config/paths');
/**
@ -76,11 +75,10 @@ const AppService = async (app) => {
directory: paths.structuredTools,
});
if (config.mcpServers != null) {
const mcpManager = getMCPManager();
await mcpManager.initializeMCP(config.mcpServers, processMCPEnv);
await mcpManager.mapAvailableTools(availableTools);
}
await setCachedTools(availableTools, { isGlobal: true });
// Store MCP config for later initialization
const mcpConfig = config.mcpServers || null;
const socialLogins =
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
@ -96,11 +94,11 @@ const AppService = async (app) => {
socialLogins,
filteredTools,
includedTools,
availableTools,
imageOutputType,
interfaceConfig,
turnstileConfig,
balance,
mcpConfig,
};
const agentsDefaults = agentsConfigSetup(config);

View file

@ -32,6 +32,25 @@ jest.mock('~/models', () => ({
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),
}));
jest.mock('./Config', () => ({
setCachedTools: jest.fn(),
getCachedTools: jest.fn().mockResolvedValue({
ExampleTool: {
type: 'function',
function: {
description: 'Example tool function',
name: 'exampleFunction',
parameters: {
type: 'object',
properties: {
param1: { type: 'string', description: 'An example parameter' },
},
required: ['param1'],
},
},
},
}),
}));
jest.mock('./ToolService', () => ({
loadAndFormatTools: jest.fn().mockReturnValue({
ExampleTool: {
@ -121,22 +140,9 @@ describe('AppService', () => {
sidePanel: true,
presets: true,
}),
mcpConfig: null,
turnstileConfig: mockedTurnstileConfig,
modelSpecs: undefined,
availableTools: {
ExampleTool: {
type: 'function',
function: expect.objectContaining({
description: 'Example tool function',
name: 'exampleFunction',
parameters: expect.objectContaining({
type: 'object',
properties: expect.any(Object),
required: expect.arrayContaining(['param1']),
}),
}),
},
},
paths: expect.anything(),
ocr: expect.anything(),
imageOutputType: expect.any(String),
@ -223,14 +229,41 @@ describe('AppService', () => {
it('should load and format tools accurately with defined structure', async () => {
const { loadAndFormatTools } = require('./ToolService');
const { setCachedTools, getCachedTools } = require('./Config');
await AppService(app);
expect(loadAndFormatTools).toHaveBeenCalledWith({
adminFilter: undefined,
adminIncluded: undefined,
directory: expect.anything(),
});
expect(app.locals.availableTools.ExampleTool).toBeDefined();
expect(app.locals.availableTools.ExampleTool).toEqual({
// Verify setCachedTools was called with the tools
expect(setCachedTools).toHaveBeenCalledWith(
{
ExampleTool: {
type: 'function',
function: {
description: 'Example tool function',
name: 'exampleFunction',
parameters: {
type: 'object',
properties: {
param1: { type: 'string', description: 'An example parameter' },
},
required: ['param1'],
},
},
},
},
{ isGlobal: true },
);
// Verify we can retrieve the tools from cache
const cachedTools = await getCachedTools({ includeGlobal: true });
expect(cachedTools.ExampleTool).toBeDefined();
expect(cachedTools.ExampleTool).toEqual({
type: 'function',
function: {
description: 'Example tool function',
@ -535,7 +568,6 @@ describe('AppService updating app.locals and issuing warnings', () => {
expect(app.locals).toBeDefined();
expect(app.locals.paths).toBeDefined();
expect(app.locals.availableTools).toBeDefined();
expect(app.locals.fileStrategy).toEqual(FileSources.local);
expect(app.locals.socialLogins).toEqual(defaultSocialLogins);
expect(app.locals.balance).toEqual(
@ -568,7 +600,6 @@ describe('AppService updating app.locals and issuing warnings', () => {
expect(app.locals).toBeDefined();
expect(app.locals.paths).toBeDefined();
expect(app.locals.availableTools).toBeDefined();
expect(app.locals.fileStrategy).toEqual(customConfig.fileStrategy);
expect(app.locals.socialLogins).toEqual(customConfig.registration.socialLogins);
expect(app.locals.balance).toEqual(customConfig.balance);

View file

@ -1,5 +1,7 @@
const bcrypt = require('bcryptjs');
const { webcrypto } = require('node:crypto');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { SystemRoles, errorsToString } = require('librechat-data-provider');
const {
findUser,
@ -17,11 +19,10 @@ const {
deleteUserById,
generateRefreshToken,
} = require('~/models');
const { isEnabled, checkEmailConfig, sendEmail } = require('~/server/utils');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { checkEmailConfig, sendEmail } = require('~/server/utils');
const { getBalanceConfig } = require('~/server/services/Config');
const { registerSchema } = require('~/strategies/validators');
const { logger } = require('~/config');
const domains = {
client: process.env.DOMAIN_CLIENT,

View file

@ -0,0 +1,258 @@
const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
/**
* Cache key generators for different tool access patterns
* These will support future permission-based caching
*/
const ToolCacheKeys = {
/** Global tools available to all users */
GLOBAL: 'tools:global',
/** Tools available to a specific user */
USER: (userId) => `tools:user:${userId}`,
/** Tools available to a specific role */
ROLE: (roleId) => `tools:role:${roleId}`,
/** Tools available to a specific group */
GROUP: (groupId) => `tools:group:${groupId}`,
/** Combined effective tools for a user (computed from all sources) */
EFFECTIVE: (userId) => `tools:effective:${userId}`,
};
/**
* Retrieves available tools from cache
* @function getCachedTools
* @param {Object} options - Options for retrieving tools
* @param {string} [options.userId] - User ID for user-specific tools
* @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
*/
async function getCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { userId, roleIds = [], groupIds = [], includeGlobal = true } = options;
// For now, return global tools (current behavior)
// This will be expanded to merge tools from different sources
if (!userId && includeGlobal) {
return await cache.get(ToolCacheKeys.GLOBAL);
}
// 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
const effectiveTools = await cache.get(ToolCacheKeys.EFFECTIVE(userId));
if (effectiveTools) {
return effectiveTools;
}
// Otherwise, compute from individual sources
const toolSources = [];
if (includeGlobal) {
const globalTools = await cache.get(ToolCacheKeys.GLOBAL);
if (globalTools) {
toolSources.push(globalTools);
}
}
// User-specific tools
const userTools = await cache.get(ToolCacheKeys.USER(userId));
if (userTools) {
toolSources.push(userTools);
}
// Role-based tools
for (const roleId of roleIds) {
const roleTools = await cache.get(ToolCacheKeys.ROLE(roleId));
if (roleTools) {
toolSources.push(roleTools);
}
}
// Group-based tools
for (const groupId of groupIds) {
const groupTools = await cache.get(ToolCacheKeys.GROUP(groupId));
if (groupTools) {
toolSources.push(groupTools);
}
}
// Merge all tool sources (for now, simple merge - future will handle conflicts)
if (toolSources.length > 0) {
return mergeToolSources(toolSources);
}
}
return null;
}
/**
* Sets available tools in cache
* @function setCachedTools
* @param {Object} tools - The tools object to cache
* @param {Object} options - Options for caching tools
* @param {string} [options.userId] - User ID for user-specific tools
* @param {string} [options.roleId] - Role ID for role-based tools
* @param {string} [options.groupId] - Group ID for group-based tools
* @param {boolean} [options.isGlobal=false] - Whether these are global tools
* @param {number} [options.ttl] - Time to live in milliseconds
* @returns {Promise<boolean>} Whether the operation was successful
*/
async function setCachedTools(tools, options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { userId, roleId, groupId, isGlobal = false, ttl } = options;
let cacheKey;
if (isGlobal || (!userId && !roleId && !groupId)) {
cacheKey = ToolCacheKeys.GLOBAL;
} else if (userId) {
cacheKey = ToolCacheKeys.USER(userId);
} else if (roleId) {
cacheKey = ToolCacheKeys.ROLE(roleId);
} else if (groupId) {
cacheKey = ToolCacheKeys.GROUP(groupId);
}
if (!cacheKey) {
throw new Error('Invalid cache key options provided');
}
return await cache.set(cacheKey, tools, ttl);
}
/**
* Invalidates cached tools
* @function invalidateCachedTools
* @param {Object} options - Options for invalidating tools
* @param {string} [options.userId] - User ID to invalidate
* @param {string} [options.roleId] - Role ID to invalidate
* @param {string} [options.groupId] - Group ID to invalidate
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
* @param {boolean} [options.invalidateEffective=true] - Whether to invalidate effective tools
* @returns {Promise<void>}
*/
async function invalidateCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { userId, roleId, groupId, invalidateGlobal = false, invalidateEffective = true } = options;
const keysToDelete = [];
if (invalidateGlobal) {
keysToDelete.push(ToolCacheKeys.GLOBAL);
}
if (userId) {
keysToDelete.push(ToolCacheKeys.USER(userId));
if (invalidateEffective) {
keysToDelete.push(ToolCacheKeys.EFFECTIVE(userId));
}
}
if (roleId) {
keysToDelete.push(ToolCacheKeys.ROLE(roleId));
// TODO: In future, invalidate all users with this role
}
if (groupId) {
keysToDelete.push(ToolCacheKeys.GROUP(groupId));
// TODO: In future, invalidate all users in this group
}
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
}
/**
* Computes and caches effective tools for a user
* @function computeEffectiveTools
* @param {string} userId - The user ID
* @param {Object} context - Context containing user's roles and groups
* @param {string[]} [context.roleIds=[]] - User's role IDs
* @param {string[]} [context.groupIds=[]] - User's group IDs
* @param {number} [ttl] - Time to live for the computed result
* @returns {Promise<Object>} The computed effective tools
*/
async function computeEffectiveTools(userId, context = {}, ttl) {
const { roleIds = [], groupIds = [] } = context;
// Get all tool sources
const tools = await getCachedTools({
userId,
roleIds,
groupIds,
includeGlobal: true,
});
if (tools) {
// Cache the computed result
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.set(ToolCacheKeys.EFFECTIVE(userId), tools, ttl);
}
return tools;
}
/**
* Merges multiple tool sources into a single tools object
* @function mergeToolSources
* @param {Object[]} sources - Array of tool objects to merge
* @returns {Object} Merged tools object
*/
function mergeToolSources(sources) {
// For now, simple merge that combines all tools
// Future implementation will handle:
// - Permission precedence (deny > allow)
// - Tool property conflicts
// - Metadata merging
const merged = {};
for (const source of sources) {
if (!source || typeof source !== 'object') {
continue;
}
for (const [toolId, toolConfig] of Object.entries(source)) {
// Simple last-write-wins for now
// Future: merge based on permission levels
merged[toolId] = toolConfig;
}
}
return merged;
}
/**
* Middleware-friendly function to get tools for a request
* @function getToolsForRequest
* @param {Object} req - Express request object
* @returns {Promise<Object|null>} Available tools for the request
*/
async function getToolsForRequest(req) {
const userId = req.user?.id;
// For now, return global tools if no user
if (!userId) {
return getCachedTools({ includeGlobal: true });
}
// Future: Extract roles and groups from req.user
const roleIds = req.user?.roles || [];
const groupIds = req.user?.groups || [];
return getCachedTools({
userId,
roleIds,
groupIds,
includeGlobal: true,
});
}
module.exports = {
ToolCacheKeys,
getCachedTools,
setCachedTools,
getToolsForRequest,
invalidateCachedTools,
computeEffectiveTools,
};

View file

@ -1,6 +1,10 @@
const { logger } = require('@librechat/data-schemas');
const { getUserMCPAuthMap } = require('@librechat/api');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
const { normalizeEndpointName, isEnabled } = require('~/server/utils');
const loadCustomConfig = require('./loadCustomConfig');
const { getCachedTools } = require('./getCachedTools');
const { findPluginAuthsByKeys } = require('~/models');
const getLogStores = require('~/cache/getLogStores');
/**
@ -50,4 +54,46 @@ const getCustomEndpointConfig = async (endpoint) => {
);
};
module.exports = { getCustomConfig, getBalanceConfig, getCustomEndpointConfig };
async function createGetMCPAuthMap() {
const customConfig = await getCustomConfig();
const mcpServers = customConfig?.mcpServers;
const hasCustomUserVars = Object.values(mcpServers ?? {}).some((server) => server.customUserVars);
if (!hasCustomUserVars) {
return;
}
/**
* @param {Object} params
* @param {GenericTool[]} [params.tools]
* @param {string} params.userId
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
*/
return async function ({ tools, userId }) {
try {
if (!tools || tools.length === 0) {
return;
}
const appTools = await getCachedTools({
userId,
});
return await getUserMCPAuthMap({
tools,
userId,
appTools,
findPluginAuthsByKeys,
});
} catch (err) {
logger.error(
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
err,
);
}
};
}
module.exports = {
getCustomConfig,
getBalanceConfig,
createGetMCPAuthMap,
getCustomEndpointConfig,
};

View file

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

View file

@ -63,11 +63,17 @@ const initializeAgent = async ({
}
let currentFiles;
if (
isInitialAgent &&
conversationId != null &&
(agent.model_parameters?.resendFiles ?? true) === true
) {
const _modelOptions = structuredClone(
Object.assign(
{ model: agent.model },
agent.model_parameters ?? { model: agent.model },
isInitialAgent === true ? endpointOption?.model_parameters : {},
),
);
const { resendFiles = true, ...modelOptions } = _modelOptions;
if (isInitialAgent && conversationId != null && resendFiles) {
const fileIds = (await getConvoFiles(conversationId)) ?? [];
/** @type {Set<EToolResources>} */
const toolResourceSet = new Set();
@ -117,15 +123,11 @@ const initializeAgent = async ({
getOptions = initCustom;
agent.provider = Providers.OPENAI;
}
const model_parameters = Object.assign(
{},
agent.model_parameters ?? { model: agent.model },
isInitialAgent === true ? endpointOption?.model_parameters : {},
);
const _endpointOption =
isInitialAgent === true
? Object.assign({}, endpointOption, { model_parameters })
: { model_parameters };
? Object.assign({}, endpointOption, { model_parameters: modelOptions })
: { model_parameters: modelOptions };
const options = await getOptions({
req,
@ -136,6 +138,20 @@ const initializeAgent = async ({
endpointOption: _endpointOption,
});
const tokensModel =
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : modelOptions.model;
const maxTokens = optionalChainWithEmptyCheck(
modelOptions.maxOutputTokens,
modelOptions.maxTokens,
0,
);
const maxContextTokens = optionalChainWithEmptyCheck(
modelOptions.maxContextTokens,
modelOptions.max_context_tokens,
getModelMaxTokens(tokensModel, providerEndpointMap[provider]),
4096,
);
if (
agent.endpoint === EModelEndpoint.azureOpenAI &&
options.llmConfig?.azureOpenAIApiInstanceName == null
@ -148,15 +164,11 @@ const initializeAgent = async ({
}
/** @type {import('@librechat/agents').ClientOptions} */
agent.model_parameters = Object.assign(model_parameters, options.llmConfig);
agent.model_parameters = { ...options.llmConfig };
if (options.configOptions) {
agent.model_parameters.configuration = options.configOptions;
}
if (!agent.model_parameters.model) {
agent.model_parameters.model = agent.model;
}
if (agent.instructions && agent.instructions !== '') {
agent.instructions = replaceSpecialVars({
text: agent.instructions,
@ -171,23 +183,11 @@ const initializeAgent = async ({
});
}
const tokensModel =
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : agent.model_parameters.model;
const maxTokens = optionalChainWithEmptyCheck(
agent.model_parameters.maxOutputTokens,
agent.model_parameters.maxTokens,
0,
);
const maxContextTokens = optionalChainWithEmptyCheck(
agent.model_parameters.maxContextTokens,
agent.max_context_tokens,
getModelMaxTokens(tokensModel, providerEndpointMap[provider]),
4096,
);
return {
...agent,
tools,
attachments,
resendFiles,
toolContextMap,
maxContextTokens: (maxContextTokens - maxTokens) * 0.9,
};

View file

@ -130,8 +130,8 @@ const initializeClient = async ({ req, res, endpointOption }) => {
iconURL: endpointOption.iconURL,
attachments: primaryConfig.attachments,
endpointType: endpointOption.endpointType,
resendFiles: primaryConfig.resendFiles ?? true,
maxContextTokens: primaryConfig.maxContextTokens,
resendFiles: primaryConfig.model_parameters?.resendFiles ?? true,
endpoint:
primaryConfig.id === Constants.EPHEMERAL_AGENT_ID
? primaryConfig.endpoint

View file

@ -1,4 +1,4 @@
const { HttpsProxyAgent } = require('https-proxy-agent');
const { ProxyAgent } = require('undici');
const { anthropicSettings, removeNullishValues } = require('librechat-data-provider');
const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = require('./helpers');
@ -67,7 +67,10 @@ function getLLMConfig(apiKey, options = {}) {
}
if (options.proxy) {
requestOptions.clientOptions.httpAgent = new HttpsProxyAgent(options.proxy);
const proxyAgent = new ProxyAgent(options.proxy);
requestOptions.clientOptions.fetchOptions = {
dispatcher: proxyAgent,
};
}
if (options.reverseProxyUrl) {

View file

@ -21,8 +21,12 @@ describe('getLLMConfig', () => {
proxy: 'http://proxy:8080',
});
expect(result.llmConfig.clientOptions).toHaveProperty('httpAgent');
expect(result.llmConfig.clientOptions.httpAgent).toHaveProperty('proxy', 'http://proxy:8080');
expect(result.llmConfig.clientOptions).toHaveProperty('fetchOptions');
expect(result.llmConfig.clientOptions.fetchOptions).toHaveProperty('dispatcher');
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher).toBeDefined();
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher.constructor.name).toBe(
'ProxyAgent',
);
});
it('should include reverse proxy URL when provided', () => {

View file

@ -1,27 +1,111 @@
const { z } = require('zod');
const { tool } = require('@langchain/core/tools');
const { normalizeServerName } = require('@librechat/api');
const { Constants: AgentConstants, Providers } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { Time, CacheKeys, StepTypes } = require('librechat-data-provider');
const { sendEvent, normalizeServerName, MCPOAuthHandler } = require('@librechat/api');
const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents');
const {
Constants,
ContentTypes,
isAssistantsEndpoint,
convertJsonSchemaToZod,
} = require('librechat-data-provider');
const { logger, getMCPManager } = require('~/config');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { findToken, createToken, updateToken } = require('~/models');
const { getCachedTools } = require('./Config');
const { getLogStores } = require('~/cache');
/**
* @param {object} params
* @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 }) {
/**
* 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) {
/** @type {{ id: string; delta: AgentToolCallDelta }} */
const data = {
id: stepId,
delta: {
type: StepTypes.TOOL_CALLS,
tool_calls: [{ ...toolCall, args: '' }],
auth: authURL,
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;
},
signal,
);
};
}
/**
* @param {object} params
* @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 createOAuthEnd({ res, stepId, toolCall }) {
return async function () {
/** @type {{ id: string; delta: AgentToolCallDelta }} */
const data = {
id: stepId,
delta: {
type: StepTypes.TOOL_CALLS,
tool_calls: [{ ...toolCall }],
},
};
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
logger.debug('Sent OAuth login success to client');
};
}
/**
* @param {object} params
* @param {string} params.userId - The ID of the user.
* @param {string} params.serverName - The name of the server.
* @param {string} params.toolName - The name of the tool.
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
*/
function createAbortHandler({ userId, serverName, toolName, flowManager }) {
return function () {
logger.info(`[MCP][User: ${userId}][${serverName}][${toolName}] Tool call aborted`);
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
flowManager.failFlow(flowId, 'mcp_oauth', new Error('Tool call aborted'));
};
}
/**
* Creates a general tool for an entire action set.
*
* @param {Object} params - The parameters for loading action sets.
* @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.
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
*/
async function createMCPTool({ req, toolKey, provider: _provider }) {
const toolDefinition = req.app.locals.availableTools[toolKey]?.function;
async function createMCPTool({ req, res, toolKey, provider: _provider }) {
const availableTools = await getCachedTools({ includeGlobal: true });
const toolDefinition = availableTools?.[toolKey]?.function;
if (!toolDefinition) {
logger.error(`Tool ${toolKey} not found in available tools`);
return null;
@ -51,10 +135,42 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
/** @type {(toolArguments: Object | string, config?: GraphRunnableConfig) => Promise<unknown>} */
const _call = async (toolArguments, config) => {
const userId = config?.configurable?.user?.id || config?.configurable?.user_id;
/** @type {ReturnType<typeof createAbortHandler>} */
let abortHandler = null;
/** @type {AbortSignal} */
let derivedSignal = null;
try {
const derivedSignal = config?.signal ? AbortSignal.any([config.signal]) : undefined;
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
derivedSignal = config?.signal ? AbortSignal.any([config.signal]) : undefined;
const mcpManager = getMCPManager(userId);
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({
res,
stepId,
toolCall,
loginFlowId,
flowManager,
signal: derivedSignal,
});
const oauthEnd = createOAuthEnd({
res,
stepId,
toolCall,
});
if (derivedSignal) {
abortHandler = createAbortHandler({ userId, serverName, toolName, flowManager });
derivedSignal.addEventListener('abort', abortHandler, { once: true });
}
const customUserVars =
config?.configurable?.userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
const result = await mcpManager.callTool({
serverName,
toolName,
@ -62,8 +178,17 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
toolArguments,
options: {
signal: derivedSignal,
user: config?.configurable?.user,
},
user: config?.configurable?.user,
customUserVars,
flowManager,
tokenMethods: {
findToken,
createToken,
updateToken,
},
oauthStart,
oauthEnd,
});
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {
@ -78,9 +203,28 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
`[MCP][User: ${userId}][${serverName}] Error calling "${toolName}" MCP tool:`,
error,
);
/** OAuth error, provide a helpful message */
const isOAuthError =
error.message?.includes('401') ||
error.message?.includes('OAuth') ||
error.message?.includes('authentication') ||
error.message?.includes('Non-200 status code (401)');
if (isOAuthError) {
throw new Error(
`OAuth authentication required for ${serverName}. Please check the server logs for the authentication URL.`,
);
}
throw new Error(
`"${toolKey}" tool call failed${error?.message ? `: ${error?.message}` : '.'}`,
);
} finally {
// Clean up abort handler to prevent memory leaks
if (abortHandler && derivedSignal) {
derivedSignal.removeEventListener('abort', abortHandler);
}
}
};

View file

@ -1,6 +1,6 @@
const { encrypt, decrypt } = require('~/server/utils/crypto');
const { PluginAuth } = require('~/db/models');
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
const { encrypt, decrypt } = require('@librechat/api');
const { findOnePluginAuth, updatePluginAuth, deletePluginAuth } = require('~/models');
/**
* Asynchronously retrieves and decrypts the authentication value for a user's plugin, based on a specified authentication field.
@ -25,7 +25,7 @@ const { logger } = require('~/config');
*/
const getUserPluginAuthValue = async (userId, authField, throwError = true) => {
try {
const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean();
const pluginAuth = await findOnePluginAuth({ userId, authField });
if (!pluginAuth) {
throw new Error(`No plugin auth ${authField} found for user ${userId}`);
}
@ -79,23 +79,12 @@ const getUserPluginAuthValue = async (userId, authField, throwError = true) => {
const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
try {
const encryptedValue = await encrypt(value);
const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean();
if (pluginAuth) {
return await PluginAuth.findOneAndUpdate(
{ userId, authField },
{ $set: { value: encryptedValue } },
{ new: true, upsert: true },
).lean();
} else {
const newPluginAuth = await new PluginAuth({
userId,
authField,
value: encryptedValue,
pluginKey,
});
await newPluginAuth.save();
return newPluginAuth.toObject();
}
return await updatePluginAuth({
userId,
authField,
pluginKey,
value: encryptedValue,
});
} catch (err) {
logger.error('[updateUserPluginAuth]', err);
return err;
@ -105,26 +94,25 @@ const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
/**
* @async
* @param {string} userId
* @param {string} authField
* @param {boolean} [all]
* @param {string | null} authField - The specific authField to delete, or null if `all` is true.
* @param {boolean} [all=false] - Whether to delete all auths for the user (or for a specific pluginKey if provided).
* @param {string} [pluginKey] - Optional. If `all` is true and `pluginKey` is provided, delete all auths for this user and pluginKey.
* @returns {Promise<import('mongoose').DeleteResult>}
* @throws {Error}
*/
const deleteUserPluginAuth = async (userId, authField, all = false) => {
if (all) {
try {
const response = await PluginAuth.deleteMany({ userId });
return response;
} catch (err) {
logger.error('[deleteUserPluginAuth]', err);
return err;
}
}
const deleteUserPluginAuth = async (userId, authField, all = false, pluginKey) => {
try {
return await PluginAuth.deleteOne({ userId, authField });
return await deletePluginAuth({
userId,
authField,
pluginKey,
all,
});
} catch (err) {
logger.error('[deleteUserPluginAuth]', err);
logger.error(
`[deleteUserPluginAuth] Error deleting ${all ? 'all' : 'single'} auth(s) for userId: ${userId}${pluginKey ? ` and pluginKey: ${pluginKey}` : ''}`,
err,
);
return err;
}
};

View file

@ -1,195 +0,0 @@
const axios = require('axios');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { TokenExchangeMethodEnum } = require('librechat-data-provider');
const { handleOAuthToken } = require('~/models/Token');
const { decryptV2 } = require('~/server/utils/crypto');
/**
* Processes the access tokens and stores them in the database.
* @param {object} tokenData
* @param {string} tokenData.access_token
* @param {number} tokenData.expires_in
* @param {string} [tokenData.refresh_token]
* @param {number} [tokenData.refresh_token_expires_in]
* @param {object} metadata
* @param {string} metadata.userId
* @param {string} metadata.identifier
* @returns {Promise<void>}
*/
async function processAccessTokens(tokenData, { userId, identifier }) {
const { access_token, expires_in = 3600, refresh_token, refresh_token_expires_in } = tokenData;
if (!access_token) {
logger.error('Access token not found: ', tokenData);
throw new Error('Access token not found');
}
await handleOAuthToken({
identifier,
token: access_token,
expiresIn: expires_in,
userId,
});
if (refresh_token != null) {
logger.debug('Processing refresh token');
await handleOAuthToken({
token: refresh_token,
type: 'oauth_refresh',
userId,
identifier: `${identifier}:refresh`,
expiresIn: refresh_token_expires_in ?? null,
});
}
logger.debug('Access tokens processed');
}
/**
* Refreshes the access token using the refresh token.
* @param {object} fields
* @param {string} fields.userId - The ID of the user.
* @param {string} fields.client_url - The URL of the OAuth provider.
* @param {string} fields.identifier - The identifier for the token.
* @param {string} fields.refresh_token - The refresh token to use.
* @param {string} fields.token_exchange_method - The token exchange method ('default_post' or 'basic_auth_header').
* @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider.
* @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider.
* @returns {Promise<{
* access_token: string,
* expires_in: number,
* refresh_token?: string,
* refresh_token_expires_in?: number,
* }>}
*/
const refreshAccessToken = async ({
userId,
client_url,
identifier,
refresh_token,
token_exchange_method,
encrypted_oauth_client_id,
encrypted_oauth_client_secret,
}) => {
try {
const oauth_client_id = await decryptV2(encrypted_oauth_client_id);
const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret);
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
};
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token,
});
if (token_exchange_method === TokenExchangeMethodEnum.BasicAuthHeader) {
const basicAuth = Buffer.from(`${oauth_client_id}:${oauth_client_secret}`).toString('base64');
headers['Authorization'] = `Basic ${basicAuth}`;
} else {
params.append('client_id', oauth_client_id);
params.append('client_secret', oauth_client_secret);
}
const response = await axios({
method: 'POST',
url: client_url,
headers,
data: params.toString(),
});
await processAccessTokens(response.data, {
userId,
identifier,
});
logger.debug(`Access token refreshed successfully for ${identifier}`);
return response.data;
} catch (error) {
const message = 'Error refreshing OAuth tokens';
throw new Error(
logAxiosError({
message,
error,
}),
);
}
};
/**
* Handles the OAuth callback and exchanges the authorization code for tokens.
* @param {object} fields
* @param {string} fields.code - The authorization code returned by the provider.
* @param {string} fields.userId - The ID of the user.
* @param {string} fields.identifier - The identifier for the token.
* @param {string} fields.client_url - The URL of the OAuth provider.
* @param {string} fields.redirect_uri - The redirect URI for the OAuth provider.
* @param {string} fields.token_exchange_method - The token exchange method ('default_post' or 'basic_auth_header').
* @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider.
* @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider.
* @returns {Promise<{
* access_token: string,
* expires_in: number,
* refresh_token?: string,
* refresh_token_expires_in?: number,
* }>}
*/
const getAccessToken = async ({
code,
userId,
identifier,
client_url,
redirect_uri,
token_exchange_method,
encrypted_oauth_client_id,
encrypted_oauth_client_secret,
}) => {
const oauth_client_id = await decryptV2(encrypted_oauth_client_id);
const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret);
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
};
const params = new URLSearchParams({
code,
grant_type: 'authorization_code',
redirect_uri,
});
if (token_exchange_method === TokenExchangeMethodEnum.BasicAuthHeader) {
const basicAuth = Buffer.from(`${oauth_client_id}:${oauth_client_secret}`).toString('base64');
headers['Authorization'] = `Basic ${basicAuth}`;
} else {
params.append('client_id', oauth_client_id);
params.append('client_secret', oauth_client_secret);
}
try {
const response = await axios({
method: 'POST',
url: client_url,
headers,
data: params.toString(),
});
await processAccessTokens(response.data, {
userId,
identifier,
});
logger.debug(`Access tokens successfully created for ${identifier}`);
return response.data;
} catch (error) {
const message = 'Error exchanging OAuth code';
throw new Error(
logAxiosError({
message,
error,
}),
);
}
};
module.exports = {
getAccessToken,
refreshAccessToken,
};

View file

@ -1,5 +1,7 @@
const fs = require('fs');
const path = require('path');
const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { zodToJsonSchema } = require('zod-to-json-schema');
const { Calculator } = require('@langchain/community/tools/calculator');
const { tool: toolFn, Tool, DynamicStructuredTool } = require('@langchain/core/tools');
@ -31,14 +33,12 @@ const {
toolkits,
} = require('~/app/clients/tools');
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
const { getEndpointsConfig, getCachedTools } = require('~/server/services/Config');
const { createOnSearchResults } = require('~/server/services/Tools/search');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { getEndpointsConfig } = require('~/server/services/Config');
const { recordUsage } = require('~/server/services/Threads');
const { loadTools } = require('~/app/clients/tools/util');
const { redactMessage } = require('~/config/parsers');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
/**
* @param {string} toolName
@ -226,7 +226,7 @@ async function processRequiredActions(client, requiredActions) {
`[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
requiredActions,
);
const toolDefinitions = client.req.app.locals.availableTools;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
const seenToolkits = new Set();
const tools = requiredActions
.map((action) => {
@ -553,6 +553,7 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
tools: _agentTools,
options: {
req,
res,
openAIApiKey,
tool_resources,
processFileURL,

View file

@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { encrypt, decrypt } = require('@librechat/api');
const { ErrorTypes } = require('librechat-data-provider');
const { encrypt, decrypt } = require('~/server/utils/crypto');
const { updateUser } = require('~/models');
const { Key } = require('~/db/models');
@ -70,6 +70,7 @@ const getUserKeyValues = async ({ userId, name }) => {
try {
userValues = JSON.parse(userValues);
} catch (e) {
logger.error('[getUserKeyValues]', e);
throw new Error(
JSON.stringify({
type: ErrorTypes.INVALID_USER_KEY,

View file

@ -0,0 +1,54 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, processMCPEnv } = require('librechat-data-provider');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getCachedTools, setCachedTools } = require('./Config');
const { getLogStores } = require('~/cache');
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
/**
* Initialize MCP servers
* @param {import('express').Application} app - Express app instance
*/
async function initializeMCP(app) {
const mcpServers = app.locals.mcpConfig;
if (!mcpServers) {
return;
}
logger.info('Initializing MCP servers...');
const mcpManager = getMCPManager();
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
try {
await mcpManager.initializeMCP({
mcpServers,
flowManager,
tokenMethods: {
findToken,
updateToken,
createToken,
deleteTokens,
},
processMCPEnv,
});
delete app.locals.mcpConfig;
const availableTools = await getCachedTools();
if (!availableTools) {
logger.warn('No available tools found in cache during MCP initialization');
return;
}
const toolsCopy = { ...availableTools };
await mcpManager.mapAvailableTools(toolsCopy, flowManager);
await setCachedTools(toolsCopy, { isGlobal: true });
logger.info('MCP servers initialized successfully');
} catch (error) {
logger.error('Failed to initialize MCP servers:', error);
}
}
module.exports = initializeMCP;

View file

@ -1,5 +1,5 @@
const { webcrypto } = require('node:crypto');
const { hashBackupCode, decryptV3, decryptV2 } = require('~/server/utils/crypto');
const { hashBackupCode, decryptV3, decryptV2 } = require('@librechat/api');
const { updateUser } = require('~/models');
// Base32 alphabet for TOTP secret encoding.

View file

@ -3,7 +3,6 @@ const removePorts = require('./removePorts');
const countTokens = require('./countTokens');
const handleText = require('./handleText');
const sendEmail = require('./sendEmail');
const cryptoUtils = require('./crypto');
const queue = require('./queue');
const files = require('./files');
const math = require('./math');
@ -31,7 +30,6 @@ function checkEmailConfig() {
module.exports = {
...streamResponse,
checkEmailConfig,
...cryptoUtils,
...handleText,
countTokens,
removePorts,

View file

@ -21,19 +21,18 @@ jest.mock('~/models', () => ({
createUser: jest.fn(),
updateUser: jest.fn(),
}));
jest.mock('~/server/utils/crypto', () => ({
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));
jest.mock('~/server/utils', () => ({
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(() => false),
}));
jest.mock('~/config', () => ({
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/api'),
logger: {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));
jest.mock('~/cache/getLogStores', () =>
jest.fn(() => ({

View file

@ -1,15 +1,17 @@
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { findUser, createUser, updateUser } = require('~/models');
const { setupSaml, getCertificateContent } = require('./samlStrategy');
// --- Mocks ---
jest.mock('tiktoken');
jest.mock('fs');
jest.mock('path');
jest.mock('node-fetch');
jest.mock('@node-saml/passport-saml');
jest.mock('@librechat/data-schemas', () => ({
logger: {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
createUser: jest.fn(),
@ -29,26 +31,26 @@ jest.mock('~/server/services/Config', () => ({
jest.mock('~/server/services/Config/EndpointService', () => ({
config: {},
}));
jest.mock('~/server/utils', () => ({
isEnabled: jest.fn(() => false),
isUserProvided: jest.fn(() => false),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
})),
}));
jest.mock('~/server/utils/crypto', () => ({
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));
jest.mock('~/config', () => ({
logger: {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
jest.mock('~/config/paths', () => ({
root: '/fake/root/path',
}));
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { setupSaml, getCertificateContent } = require('./samlStrategy');
// Configure fs mock
jest.mocked(fs).existsSync = jest.fn();
jest.mocked(fs).statSync = jest.fn();
jest.mocked(fs).readFileSync = jest.fn();
// To capture the verify callback from the strategy, we grab it from the mock constructor
let verifyCallback;
SamlStrategy.mockImplementation((options, verify) => {

View file

@ -476,11 +476,18 @@
* @memberof typedefs
*/
/**
* @exports ToolCallChunk
* @typedef {import('librechat-data-provider').Agents.ToolCallChunk} ToolCallChunk
* @memberof typedefs
*/
/**
* @exports MessageContentImageUrl
* @typedef {import('librechat-data-provider').Agents.MessageContentImageUrl} MessageContentImageUrl
* @memberof typedefs
*/
/** Web Search */
/**

View file

@ -65,6 +65,7 @@
"export-from-json": "^1.7.2",
"filenamify": "^6.0.0",
"framer-motion": "^11.5.4",
"heic-to": "^1.1.14",
"html-to-image": "^1.11.11",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.3",
@ -74,6 +75,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.394.0",
"match-sorter": "^6.3.4",
"micromark-extension-llm-math": "^3.1.0",
"qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2",
"react": "^18.2.0",

View file

@ -1,7 +1,9 @@
import React, { createContext, useContext, useState } from 'react';
import { Action, MCP, EModelEndpoint } from 'librechat-data-provider';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import type { TPlugin, AgentToolType, Action, MCP } from 'librechat-data-provider';
import type { AgentPanelContextType } from '~/common';
import { useGetActionsQuery } from '~/data-provider';
import { useAvailableToolsQuery, useGetActionsQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { Panel } from '~/common';
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
@ -16,6 +18,7 @@ export function useAgentPanelContext() {
/** Houses relevant state for the Agent Form Panels (formerly 'commonProps') */
export function AgentPanelProvider({ children }: { children: React.ReactNode }) {
const localize = useLocalize();
const [mcp, setMcp] = useState<MCP | undefined>(undefined);
const [mcps, setMcps] = useState<MCP[] | undefined>(undefined);
const [action, setAction] = useState<Action | undefined>(undefined);
@ -26,6 +29,53 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
enabled: !!agent_id,
});
const { data: pluginTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
enabled: !!agent_id,
});
const tools =
pluginTools?.map((tool) => ({
tool_id: tool.pluginKey,
metadata: tool as TPlugin,
agent_id: agent_id || '',
})) || [];
const groupedTools =
tools?.reduce(
(acc, tool) => {
if (tool.tool_id.includes(Constants.mcp_delimiter)) {
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
const groupKey = `${serverName.toLowerCase()}`;
if (!acc[groupKey]) {
acc[groupKey] = {
tool_id: groupKey,
metadata: {
name: `${serverName}`,
pluginKey: groupKey,
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
icon: tool.metadata.icon || '',
} as TPlugin,
agent_id: agent_id || '',
tools: [],
};
}
acc[groupKey].tools?.push({
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
});
} else {
acc[tool.tool_id] = {
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
};
}
return acc;
},
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
) || {};
const value = {
action,
setAction,
@ -37,8 +87,10 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
setActivePanel,
setCurrentAgentId,
agent_id,
/** Query data for actions */
groupedTools,
/** Query data for actions and tools */
actions,
tools,
};
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;

View file

@ -219,6 +219,8 @@ export type AgentPanelContextType = {
mcps?: t.MCP[];
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
groupedTools: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
tools: t.AgentToolType[];
activePanel?: string;
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;

View file

@ -40,7 +40,7 @@ const defaultType = 'unknown';
const defaultIdentifier = 'lc-no-identifier';
export function Artifact({
node,
node: _node,
...props
}: Artifact & {
children: React.ReactNode | { props: { children: React.ReactNode } };
@ -95,7 +95,7 @@ export function Artifact({
setArtifacts((prevArtifacts) => {
if (
prevArtifacts?.[artifactKey] != null &&
prevArtifacts[artifactKey].content === content
prevArtifacts[artifactKey]?.content === content
) {
return prevArtifacts;
}

View file

@ -1,13 +1,31 @@
import React, { memo, useRef, useMemo, useEffect, useCallback } from 'react';
import React, { memo, useRef, useMemo, useEffect, useCallback, useState } from 'react';
import { useRecoilState } from 'recoil';
import { Settings2 } from 'lucide-react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import type { TPlugin, TPluginAuthConfig, TUpdateUserPlugins } from 'librechat-data-provider';
import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog';
import { useAvailableToolsQuery } from '~/data-provider';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import MultiSelect from '~/components/ui/MultiSelect';
import { ephemeralAgentByConvoId } from '~/store';
import { useToastContext } from '~/Providers';
import MCPIcon from '~/components/ui/MCPIcon';
import { useLocalize } from '~/hooks';
interface McpServerInfo {
name: string;
pluginKey: string;
authConfig?: TPluginAuthConfig[];
authenticated?: boolean;
}
// Helper function to extract mcp_serverName from a full pluginKey like action_mcp_serverName
const getBaseMCPPluginKey = (fullPluginKey: string): string => {
const parts = fullPluginKey.split(Constants.mcp_delimiter);
return Constants.mcp_prefix + parts[parts.length - 1];
};
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
@ -24,20 +42,45 @@ const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const key = conversationId ?? Constants.NEW_CONVO;
const hasSetFetched = useRef<string | null>(null);
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<McpServerInfo | null>(null);
const { data: mcpServerSet, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
select: (data) => {
const serverNames = new Set<string>();
const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
select: (data: TPlugin[]) => {
const mcpToolsMap = new Map<string, McpServerInfo>();
data.forEach((tool) => {
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
if (isMCP && tool.chatMenu !== false) {
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
serverNames.add(parts[parts.length - 1]);
const serverName = parts[parts.length - 1];
if (!mcpToolsMap.has(serverName)) {
mcpToolsMap.set(serverName, {
name: serverName,
pluginKey: tool.pluginKey,
authConfig: tool.authConfig,
authenticated: tool.authenticated,
});
}
}
});
return serverNames;
return Array.from(mcpToolsMap.values());
},
});
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
onSuccess: () => {
setIsConfigModalOpen(false);
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
},
onError: (error: unknown) => {
console.error('Error updating MCP auth:', error);
showToast({
message: localize('com_nav_mcp_vars_update_error'),
status: 'error',
});
},
});
@ -76,12 +119,12 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
return;
}
hasSetFetched.current = key;
if ((mcpServerSet?.size ?? 0) > 0) {
setMCPValues(mcpValues.filter((mcp) => mcpServerSet?.has(mcp)));
if ((mcpToolDetails?.length ?? 0) > 0) {
setMCPValues(mcpValues.filter((mcp) => mcpToolDetails?.some((tool) => tool.name === mcp)));
return;
}
setMCPValues([]);
}, [isFetched, setMCPValues, mcpServerSet, key, mcpValues]);
}, [isFetched, setMCPValues, mcpToolDetails, key, mcpValues]);
const renderSelectedValues = useCallback(
(values: string[], placeholder?: string) => {
@ -96,28 +139,140 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
[localize],
);
const mcpServers = useMemo(() => {
return Array.from(mcpServerSet ?? []);
}, [mcpServerSet]);
const mcpServerNames = useMemo(() => {
return (mcpToolDetails ?? []).map((tool) => tool.name);
}, [mcpToolDetails]);
if (!mcpServerSet || mcpServerSet.size === 0) {
const handleConfigSave = useCallback(
(targetName: string, authData: Record<string, string>) => {
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
const payload: TUpdateUserPlugins = {
pluginKey: basePluginKey,
action: 'install',
auth: authData,
};
updateUserPluginsMutation.mutate(payload);
}
},
[selectedToolForConfig, updateUserPluginsMutation],
);
const handleConfigRevoke = useCallback(
(targetName: string) => {
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
const payload: TUpdateUserPlugins = {
pluginKey: basePluginKey,
action: 'uninstall',
auth: {},
};
updateUserPluginsMutation.mutate(payload);
}
},
[selectedToolForConfig, updateUserPluginsMutation],
);
const renderItemContent = useCallback(
(serverName: string, defaultContent: React.ReactNode) => {
const tool = mcpToolDetails?.find((t) => t.name === serverName);
const hasAuthConfig = tool?.authConfig && tool.authConfig.length > 0;
// Common wrapper for the main content (check mark + text)
// Ensures Check & Text are adjacent and the group takes available space.
const mainContentWrapper = (
<div className="flex flex-grow items-center">{defaultContent}</div>
);
if (tool && hasAuthConfig) {
return (
<div className="flex w-full items-center justify-between">
{mainContentWrapper}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setSelectedToolForConfig(tool);
setIsConfigModalOpen(true);
}}
className="ml-2 flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-black/10 dark:hover:bg-white/10"
aria-label={`Configure ${serverName}`}
>
<Settings2 className={`h-4 w-4 ${tool.authenticated ? 'text-green-500' : ''}`} />
</button>
</div>
);
}
// For items without a settings icon, return the consistently wrapped main content.
return mainContentWrapper;
},
[mcpToolDetails, setSelectedToolForConfig, setIsConfigModalOpen],
);
if (!mcpToolDetails || mcpToolDetails.length === 0) {
return null;
}
return (
<MultiSelect
items={mcpServers ?? []}
selectedValues={mcpValues ?? []}
setSelectedValues={setMCPValues}
defaultSelectedValues={mcpValues ?? []}
renderSelectedValues={renderSelectedValues}
placeholder={localize('com_ui_mcp_servers')}
popoverClassName="min-w-fit"
className="badge-icon min-w-fit"
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
/>
<>
<MultiSelect
items={mcpServerNames}
selectedValues={mcpValues ?? []}
setSelectedValues={setMCPValues}
defaultSelectedValues={mcpValues ?? []}
renderSelectedValues={renderSelectedValues}
renderItemContent={renderItemContent}
placeholder={localize('com_ui_mcp_servers')}
popoverClassName="min-w-fit"
className="badge-icon min-w-fit"
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
/>
{selectedToolForConfig && (
<MCPConfigDialog
isOpen={isConfigModalOpen}
onOpenChange={setIsConfigModalOpen}
serverName={selectedToolForConfig.name}
fieldsSchema={(() => {
const schema: Record<string, ConfigFieldDetail> = {};
if (selectedToolForConfig?.authConfig) {
selectedToolForConfig.authConfig.forEach((field) => {
schema[field.authField] = {
title: field.label,
description: field.description,
};
});
}
return schema;
})()}
initialValues={(() => {
const initial: Record<string, string> = {};
// Note: Actual initial values might need to be fetched if they are stored user-specifically
if (selectedToolForConfig?.authConfig) {
selectedToolForConfig.authConfig.forEach((field) => {
initial[field.authField] = ''; // Or fetched value
});
}
return initial;
})()}
onSave={(authData) => {
if (selectedToolForConfig) {
handleConfigSave(selectedToolForConfig.name, authData);
}
}}
onRevoke={() => {
if (selectedToolForConfig) {
handleConfigRevoke(selectedToolForConfig.name);
}
}}
isSubmitting={updateUserPluginsMutation.isLoading}
/>
)}
</>
);
}

View file

@ -46,13 +46,33 @@ const Image = ({
[placeholderDimensions, height, width],
);
const downloadImage = () => {
const link = document.createElement('a');
link.href = imagePath;
link.download = altText;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const downloadImage = async () => {
try {
const response = await fetch(imagePath);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = altText || 'image.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Download failed:', error);
const link = document.createElement('a');
link.href = imagePath;
link.download = altText || 'image.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
return (

View file

@ -204,7 +204,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
remarkGfm,
remarkDirective,
artifactPlugin,
[remarkMath, { singleDollarTextMath: true }],
[remarkMath, { singleDollarTextMath: false }],
unicodeCitation,
];

View file

@ -32,7 +32,7 @@ const MarkdownLite = memo(
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
[remarkMath, { singleDollarTextMath: false }],
]}
/** @ts-ignore */
rehypePlugins={rehypePlugins}

View file

@ -117,9 +117,9 @@ const EditTextPart = ({
messages.map((msg) =>
msg.messageId === messageId
? {
...msg,
content: updatedContent,
}
...msg,
content: updatedContent,
}
: msg,
),
);

View file

@ -25,6 +25,7 @@ type THoverButtons = {
};
type HoverButtonProps = {
id?: string;
onClick: (e?: React.MouseEvent<HTMLButtonElement>) => void;
title: string;
icon: React.ReactNode;
@ -67,6 +68,7 @@ const extractMessageContent = (message: TMessage): string => {
const HoverButton = memo(
({
id,
onClick,
title,
icon,
@ -89,6 +91,7 @@ const HoverButton = memo(
return (
<button
id={id}
className={buttonStyle}
onClick={onClick}
type="button"
@ -213,6 +216,7 @@ const HoverButtons = ({
{/* Edit Button */}
{isEditableEndpoint && (
<HoverButton
id={`edit-${message.messageId}`}
onClick={onEdit}
title={localize('com_ui_edit')}
icon={<EditIcon size="19" />}

View file

@ -14,6 +14,9 @@ function MessageAudio(props: TMessageAudio) {
};
const SelectedTTS = TTSComponents[engineTTS];
if (!SelectedTTS) {
return null;
}
return <SelectedTTS {...props} />;
}

View file

@ -0,0 +1,72 @@
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLocalize } from '~/hooks';
export default function OAuthError() {
const localize = useLocalize();
const [searchParams] = useSearchParams();
const error = searchParams.get('error') || 'unknown_error';
const getErrorMessage = (error: string): string => {
switch (error) {
case 'missing_code':
return (
localize('com_ui_oauth_error_missing_code') ||
'Authorization code is missing. Please try again.'
);
case 'missing_state':
return (
localize('com_ui_oauth_error_missing_state') ||
'State parameter is missing. Please try again.'
);
case 'invalid_state':
return (
localize('com_ui_oauth_error_invalid_state') ||
'Invalid state parameter. Please try again.'
);
case 'callback_failed':
return (
localize('com_ui_oauth_error_callback_failed') ||
'Authentication callback failed. Please try again.'
);
default:
return localize('com_ui_oauth_error_generic') || error.replace(/_/g, ' ');
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
<div className="mb-4 flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<svg
className="h-6 w-6 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
</div>
<h1 className="mb-4 text-3xl font-bold text-gray-900">
{localize('com_ui_oauth_error_title') || 'Authentication Failed'}
</h1>
<p className="mb-6 text-sm text-gray-600">{getErrorMessage(error)}</p>
<button
onClick={() => window.close()}
className="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
aria-label={localize('com_ui_close_window') || 'Close Window'}
>
{localize('com_ui_close_window') || 'Close Window'}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLocalize } from '~/hooks';
export default function OAuthSuccess() {
const localize = useLocalize();
const [searchParams] = useSearchParams();
const [secondsLeft, setSecondsLeft] = useState(3);
const serverName = searchParams.get('serverName');
useEffect(() => {
const countdown = setInterval(() => {
setSecondsLeft((prev) => {
if (prev <= 1) {
clearInterval(countdown);
window.close();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(countdown);
}, []);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
<h1 className="mb-4 text-3xl font-bold text-gray-900">
{localize('com_ui_oauth_success_title') || 'Authentication Successful'}
</h1>
<p className="mb-2 text-sm text-gray-600">
{localize('com_ui_oauth_success_description') ||
'Your authentication was successful. This window will close in'}{' '}
<span className="font-medium text-indigo-500">{secondsLeft}</span>{' '}
{localize('com_ui_seconds') || 'seconds'}.
</p>
{serverName && (
<p className="mt-4 text-xs text-gray-500">
{localize('com_ui_oauth_connected_to') || 'Connected to'}:{' '}
<span className="font-medium">{serverName}</span>
</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,2 @@
export { default as OAuthSuccess } from './OAuthSuccess';
export { default as OAuthError } from './OAuthError';

View file

@ -143,7 +143,7 @@ export default function VariableForm({
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-surface-tertiary p-4 text-text-secondary dark:bg-surface-primary sm:max-w-full md:max-h-96">
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[
/** @ts-ignore */
[rehypeKatex],

View file

@ -55,7 +55,7 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
[remarkMath, { singleDollarTextMath: false }],
]}
rehypePlugins={[
/** @ts-ignore */

View file

@ -130,7 +130,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
[remarkMath, { singleDollarTextMath: false }],
]}
/** @ts-ignore */
rehypePlugins={rehypePlugins}

View file

@ -1,11 +1,9 @@
import { useQueryClient } from '@tanstack/react-query';
import React, { useState, useMemo, useCallback } from 'react';
import { Controller, useWatch, useFormContext } from 'react-hook-form';
import { QueryKeys, EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { TPlugin } from 'librechat-data-provider';
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Providers';
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
import Action from '~/components/SidePanel/Builder/Action';
import { ToolSelectDialog } from '~/components/Tools';
import { icons } from '~/hooks/Endpoint/Icons';
@ -15,7 +13,6 @@ import AgentAvatar from './AgentAvatar';
import FileContext from './FileContext';
import SearchForm from './Search/Form';
import { useLocalize } from '~/hooks';
import MCPSection from './MCPSection';
import FileSearch from './FileSearch';
import Artifacts from './Artifacts';
import AgentTool from './AgentTool';
@ -36,13 +33,10 @@ export default function AgentConfig({
}: Pick<AgentPanelProps, 'agentsConfig' | 'createMutation' | 'endpointsConfig'>) {
const localize = useLocalize();
const fileMap = useFileMapContext();
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const methods = useFormContext<AgentForm>();
const [showToolDialog, setShowToolDialog] = useState(false);
const { actions, setAction, setActivePanel } = useAgentPanelContext();
const allTools = queryClient.getQueryData<TPlugin[]>([QueryKeys.tools]) ?? [];
const { actions, setAction, groupedTools: allTools, setActivePanel } = useAgentPanelContext();
const { control } = methods;
const provider = useWatch({ control, name: 'provider' });
@ -169,6 +163,20 @@ export default function AgentConfig({
Icon = icons[iconKey];
}
// Determine what to show
const selectedToolIds = tools ?? [];
const visibleToolIds = new Set(selectedToolIds);
// Check what group parent tools should be shown if any subtool is present
Object.entries(allTools).forEach(([toolId, toolObj]) => {
if (toolObj.tools?.length) {
// if any subtool of this group is selected, ensure group parent tool rendered
if (toolObj.tools.some((st) => selectedToolIds.includes(st.tool_id))) {
visibleToolIds.add(toolId);
}
}
});
return (
<>
<div className="h-auto bg-white px-4 pt-3 dark:bg-transparent">
@ -287,28 +295,37 @@ export default function AgentConfig({
${toolsEnabled === true && actionsEnabled === true ? ' + ' : ''}
${actionsEnabled === true ? localize('com_assistants_actions') : ''}`}
</label>
<div className="space-y-2">
{tools?.map((func, i) => (
<AgentTool
key={`${func}-${i}-${agent_id}`}
tool={func}
allTools={allTools}
agent_id={agent_id}
/>
))}
{(actions ?? [])
.filter((action) => action.agent_id === agent_id)
.map((action, i) => (
<Action
key={i}
action={action}
onClick={() => {
setAction(action);
setActivePanel(Panel.actions);
}}
/>
))}
<div className="flex space-x-2">
<div>
<div className="mb-1">
{/* // Render all visible IDs (including groups with subtools selected) */}
{[...visibleToolIds].map((toolId, i) => {
const tool = allTools[toolId];
if (!tool) return null;
return (
<AgentTool
key={`${toolId}-${i}-${agent_id}`}
tool={toolId}
allTools={allTools}
agent_id={agent_id}
/>
);
})}
</div>
<div className="flex flex-col gap-1">
{(actions ?? [])
.filter((action) => action.agent_id === agent_id)
.map((action, i) => (
<Action
key={i}
action={action}
onClick={() => {
setAction(action);
setActivePanel(Panel.actions);
}}
/>
))}
</div>
<div className="mt-2 flex space-x-2">
{(toolsEnabled ?? false) && (
<button
type="button"
@ -343,7 +360,6 @@ export default function AgentConfig({
<ToolSelectDialog
isOpen={showToolDialog}
setIsOpen={setShowToolDialog}
toolsFormKey="tools"
endpoint={EModelEndpoint.agents}
/>
</>

View file

@ -1,41 +1,69 @@
import React, { useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import type { TPlugin } from 'librechat-data-provider';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import type { AgentToolType } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion';
import { OGDialog, OGDialogTrigger, Label, Checkbox } from '~/components/ui';
import { TrashIcon, CircleHelpIcon } from '~/components/svg';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useToastContext } from '~/Providers';
import { TrashIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function AgentTool({
tool,
allTools,
agent_id = '',
}: {
tool: string;
allTools: TPlugin[];
allTools: Record<string, AgentToolType & { tools?: AgentToolType[] }>;
agent_id?: string;
}) {
const [isHovering, setIsHovering] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [hoveredToolId, setHoveredToolId] = useState<string | null>(null);
const [accordionValue, setAccordionValue] = useState<string>('');
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext();
const currentTool = allTools.find((t) => t.pluginKey === tool);
const { getValues, setValue } = useFormContext<AgentForm>();
const currentTool = allTools[tool];
const getSelectedTools = () => {
if (!currentTool?.tools) return [];
const formTools = getValues('tools') || [];
return currentTool.tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
};
const updateFormTools = (newSelectedTools: string[]) => {
const currentTools = getValues('tools') || [];
const otherTools = currentTools.filter(
(t: string) => !currentTool?.tools?.some((st) => st.tool_id === t),
);
setValue('tools', [...otherTools, ...newSelectedTools]);
};
const removeTool = (toolId: string) => {
if (toolId) {
const toolIdsToRemove =
isGroup && currentTool.tools
? [toolId, ...currentTool.tools.map((t) => t.tool_id)]
: [toolId];
const removeTool = (tool: string) => {
if (tool) {
updateUserPlugins.mutate(
{ pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
{
onError: (error: unknown) => {
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
},
onSuccess: () => {
const tools = getValues('tools').filter((fn: string) => fn !== tool);
setValue('tools', tools);
const remainingToolIds = getValues('tools')?.filter(
(toolId: string) => !toolIdsToRemove.includes(toolId),
);
setValue('tools', remainingToolIds);
showToast({ message: 'Tool deleted successfully', status: 'success' });
},
},
@ -47,41 +75,309 @@ export default function AgentTool({
return null;
}
return (
<OGDialog>
<div
className={cn('flex w-full items-center rounded-lg text-sm', !agent_id ? 'opacity-40' : '')}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div className="flex grow items-center">
{currentTool.icon && (
<div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{ backgroundImage: `url(${currentTool.icon})`, backgroundSize: 'cover' }}
/>
</div>
)}
<div
className="h-9 grow px-3 py-2"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentTool.name}
</div>
</div>
const isGroup = currentTool.tools && currentTool.tools.length > 0;
const selectedTools = getSelectedTools();
const isExpanded = accordionValue === currentTool.tool_id;
if (!isGroup) {
return (
<OGDialog>
<div
className="group relative flex w-full items-center gap-1 rounded-lg p-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800/50"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
// Check if focus is moving to a child element
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsFocused(false);
}
}}
>
<div className="flex grow items-center">
{currentTool.metadata.icon && (
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{
backgroundImage: `url(${currentTool.metadata.icon})`,
backgroundSize: 'cover',
}}
/>
</div>
)}
<div
className="grow px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentTool.metadata.name}
</div>
</div>
{isHovering && (
<OGDialogTrigger asChild>
<button
type="button"
className="transition-color flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-all duration-200',
'hover:bg-gray-200 dark:hover:bg-gray-700',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
'focus:opacity-100',
isHovering || isFocused ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
aria-label={`Delete ${currentTool.metadata.name}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon />
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
)}
</div>
</div>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
mainClassName="px-0"
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_tool_confirm')}
</Label>
}
selection={{
selectHandler: () => removeTool(currentTool.tool_id),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
);
}
// Group tool with accordion
return (
<OGDialog>
<Accordion type="single" value={accordionValue} onValueChange={setAccordionValue} collapsible>
<AccordionItem value={currentTool.tool_id} className="group relative w-full border-none">
<div
className="relative flex w-full items-center gap-1 rounded-lg p-1 hover:bg-gray-50 dark:hover:bg-gray-800/50"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
// Check if focus is moving to a child element
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsFocused(false);
}
}}
>
<AccordionPrimitive.Header asChild>
<AccordionPrimitive.Trigger asChild>
<button
type="button"
className={cn(
'flex grow items-center gap-1 rounded bg-transparent p-0 text-left transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
)}
>
{currentTool.metadata.icon && (
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{
backgroundImage: `url(${currentTool.metadata.icon})`,
backgroundSize: 'cover',
}}
/>
</div>
)}
<div
className="grow px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentTool.metadata.name}
</div>
<div className="flex items-center">
{/* Container for grouped checkbox and chevron */}
<div className="relative flex items-center">
{/* Grouped checkbox and chevron that slide together */}
<div
className={cn(
'flex items-center gap-2 transition-all duration-300',
isHovering || isFocused ? '-translate-x-8' : 'translate-x-0',
)}
>
<div
data-checkbox-container
onClick={(e) => e.stopPropagation()}
className="mt-1"
>
<Checkbox
id={`select-all-${currentTool.tool_id}`}
checked={selectedTools.length === currentTool.tools?.length}
onCheckedChange={(checked) => {
if (currentTool.tools) {
const newSelectedTools = checked
? currentTool.tools.map((t) => t.tool_id)
: [];
updateFormTools(newSelectedTools);
}
}}
className={cn(
'h-4 w-4 rounded border border-gray-300 transition-all duration-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500',
isExpanded ? 'opacity-100' : 'opacity-0',
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
const checkbox = e.currentTarget as HTMLButtonElement;
checkbox.click();
}
}}
tabIndex={isExpanded ? 0 : -1}
/>
</div>
<div
className={cn(
'pointer-events-none flex h-4 w-4 items-center justify-center transition-transform duration-300',
isExpanded ? 'rotate-180' : '',
)}
aria-hidden="true"
>
<ChevronDown className="h-4 w-4" />
</div>
</div>
{/* Delete button slides in from behind */}
<div
className={cn(
'absolute right-0 transition-all duration-300',
isHovering || isFocused
? 'translate-x-0 opacity-100'
: 'translate-x-8 opacity-0',
)}
>
<OGDialogTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
'hover:bg-gray-200 dark:hover:bg-gray-700',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
'focus:translate-x-0 focus:opacity-100',
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Delete ${currentTool.metadata.name}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
</div>
</div>
</div>
</button>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
</div>
<AccordionContent className="relative ml-1 pt-1 before:absolute before:bottom-2 before:left-0 before:top-0 before:w-0.5 before:bg-border-medium">
<div className="space-y-1">
{currentTool.tools?.map((subTool) => (
<label
key={subTool.tool_id}
htmlFor={subTool.tool_id}
className={cn(
'border-token-border-light hover:bg-token-surface-secondary flex cursor-pointer items-center rounded-lg border p-2',
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
}}
onMouseEnter={() => setHoveredToolId(subTool.tool_id)}
onMouseLeave={() => setHoveredToolId(null)}
>
<Checkbox
id={subTool.tool_id}
checked={selectedTools.includes(subTool.tool_id)}
onCheckedChange={(_checked) => {
const newSelectedTools = selectedTools.includes(subTool.tool_id)
? selectedTools.filter((t) => t !== subTool.tool_id)
: [...selectedTools, subTool.tool_id];
updateFormTools(newSelectedTools);
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const checkbox = e.currentTarget as HTMLButtonElement;
checkbox.click();
}
}}
onClick={(e) => e.stopPropagation()}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-gray-300 transition-[border-color] duration-200 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background dark:border-gray-600 dark:hover:border-gray-500"
/>
<span className="text-token-text-primary">{subTool.metadata.name}</span>
{subTool.metadata.description && (
<Ariakit.HovercardProvider placement="left-start">
<div className="ml-auto flex h-6 w-6 items-center justify-center">
<Ariakit.HovercardAnchor
render={
<Ariakit.Button
className={cn(
'flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary transition-opacity duration-200',
hoveredToolId === subTool.tool_id ? 'opacity-100' : 'opacity-0',
)}
aria-label={localize('com_ui_tool_info')}
>
<CircleHelpIcon className="h-4 w-4" />
<Ariakit.VisuallyHidden>
{localize('com_ui_tool_info')}
</Ariakit.VisuallyHidden>
</Ariakit.Button>
}
/>
<Ariakit.HovercardDisclosure
className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_tool_more_info')}
aria-expanded={hoveredToolId === subTool.tool_id}
aria-controls={`tool-description-${subTool.tool_id}`}
>
<Ariakit.VisuallyHidden>
{localize('com_ui_tool_more_info')}
</Ariakit.VisuallyHidden>
<ChevronDown className="h-4 w-4" />
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
id={`tool-description-${subTool.tool_id}`}
gutter={14}
shift={40}
flip={false}
className="z-[999] w-80 scale-95 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary opacity-0 shadow-md transition-all duration-200 data-[enter]:scale-100 data-[leave]:scale-95 data-[enter]:opacity-100 data-[leave]:opacity-0"
portal={true}
unmountOnHide={true}
role="tooltip"
aria-label={subTool.metadata.description}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{subTool.metadata.description}
</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
)}
</label>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
@ -93,7 +389,7 @@ export default function AgentTool({
</Label>
}
selection={{
selectHandler: () => removeTool(currentTool.pluginKey),
selectHandler: () => removeTool(currentTool.tool_id),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),

View file

@ -0,0 +1,253 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import { useForm, Controller } from 'react-hook-form';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins } from 'librechat-data-provider';
import { Button, Input, Label } from '~/components/ui';
import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
interface ServerConfigWithVars {
serverName: string;
config: {
customUserVars: Record<string, { title: string; description: string }>;
};
}
export default function MCPPanel() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
null,
);
const mcpServerDefinitions = useMemo(() => {
if (!startupConfig?.mcpServers) {
return [];
}
return Object.entries(startupConfig.mcpServers)
.filter(
([, serverConfig]) =>
serverConfig.customUserVars && Object.keys(serverConfig.customUserVars).length > 0,
)
.map(([serverName, config]) => ({
serverName,
iconPath: null,
config: {
...config,
customUserVars: config.customUserVars ?? {},
},
}));
}, [startupConfig?.mcpServers]);
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
onSuccess: () => {
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
},
onError: (error) => {
console.error('Error updating MCP custom user variables:', error);
showToast({
message: localize('com_nav_mcp_vars_update_error'),
status: 'error',
});
},
});
const handleSaveServerVars = useCallback(
(serverName: string, updatedValues: Record<string, string>) => {
const payload: TUpdateUserPlugins = {
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'install', // 'install' action is used to set/update credentials/variables
auth: updatedValues,
};
updateUserPluginsMutation.mutate(payload);
},
[updateUserPluginsMutation],
);
const handleRevokeServerVars = useCallback(
(serverName: string) => {
const payload: TUpdateUserPlugins = {
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall', // 'uninstall' action clears the variables
auth: {}, // Empty auth for uninstall
};
updateUserPluginsMutation.mutate(payload);
},
[updateUserPluginsMutation],
);
const handleServerClickToEdit = (serverName: string) => {
setSelectedServerNameForEditing(serverName);
};
const handleGoBackToList = () => {
setSelectedServerNameForEditing(null);
};
if (startupConfigLoading) {
return <MCPPanelSkeleton />;
}
if (mcpServerDefinitions.length === 0) {
return (
<div className="p-4 text-center text-sm text-gray-500">
{localize('com_sidepanel_mcp_no_servers_with_vars')}
</div>
);
}
if (selectedServerNameForEditing) {
// Editing View
const serverBeingEdited = mcpServerDefinitions.find(
(s) => s.serverName === selectedServerNameForEditing,
);
if (!serverBeingEdited) {
// Fallback to list view if server not found
setSelectedServerNameForEditing(null);
return (
<div className="p-4 text-center text-sm text-gray-500">
{localize('com_ui_error')}: {localize('com_ui_mcp_server_not_found')}
</div>
);
}
return (
<div className="h-auto max-w-full overflow-x-hidden p-3">
<Button
variant="outline"
onClick={handleGoBackToList}
className="mb-3 flex items-center px-3 py-2 text-sm"
>
<ChevronLeft className="mr-1 h-4 w-4" />
{localize('com_ui_back')}
</Button>
<h3 className="mb-3 text-lg font-medium">
{localize('com_sidepanel_mcp_variables_for', { '0': serverBeingEdited.serverName })}
</h3>
<MCPVariableEditor
server={serverBeingEdited}
onSave={handleSaveServerVars}
onRevoke={handleRevokeServerVars}
isSubmitting={updateUserPluginsMutation.isLoading}
/>
</div>
);
} else {
// Server List View
return (
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="space-y-2">
{mcpServerDefinitions.map((server) => (
<Button
key={server.serverName}
variant="outline"
className="w-full justify-start dark:hover:bg-gray-700"
onClick={() => handleServerClickToEdit(server.serverName)}
>
{server.serverName}
</Button>
))}
</div>
</div>
);
}
}
// Inner component for the form - remains the same
interface MCPVariableEditorProps {
server: ServerConfigWithVars;
onSave: (serverName: string, updatedValues: Record<string, string>) => void;
onRevoke: (serverName: string) => void;
isSubmitting: boolean;
}
function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariableEditorProps) {
const localize = useLocalize();
const {
control,
handleSubmit,
reset,
formState: { errors, isDirty },
} = useForm<Record<string, string>>({
defaultValues: {}, // Initialize empty, will be reset by useEffect
});
useEffect(() => {
// Always initialize with empty strings based on the schema
const initialFormValues = Object.keys(server.config.customUserVars).reduce(
(acc, key) => {
acc[key] = '';
return acc;
},
{} as Record<string, string>,
);
reset(initialFormValues);
}, [reset, server.config.customUserVars]);
const onFormSubmit = (data: Record<string, string>) => {
onSave(server.serverName, data);
};
const handleRevokeClick = () => {
onRevoke(server.serverName);
};
return (
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-4 mt-2 space-y-4">
{Object.entries(server.config.customUserVars).map(([key, details]) => (
<div key={key} className="space-y-2">
<Label htmlFor={`${server.serverName}-${key}`} className="text-sm font-medium">
{details.title}
</Label>
<Controller
name={key}
control={control}
defaultValue={''}
render={({ field }) => (
<Input
id={`${server.serverName}-${key}`}
type="text"
{...field}
placeholder={localize('com_sidepanel_mcp_enter_value', { '0': details.title })}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
/>
)}
/>
{details.description && (
<p
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
dangerouslySetInnerHTML={{ __html: details.description }}
/>
)}
{errors[key] && <p className="text-xs text-red-500">{errors[key]?.message}</p>}
</div>
))}
<div className="flex justify-end gap-2 pt-2">
{Object.keys(server.config.customUserVars).length > 0 && (
<Button
type="button"
onClick={handleRevokeClick}
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
disabled={isSubmitting}
>
{localize('com_ui_revoke')}
</Button>
)}
<Button
type="submit"
className="bg-green-500 text-white hover:bg-green-600"
disabled={isSubmitting || !isDirty}
>
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,21 @@
import React from 'react';
import { Skeleton } from '~/components/ui';
export default function MCPPanelSkeleton() {
return (
<div className="space-y-6 p-2">
{[1, 2].map((serverIdx) => (
<div key={serverIdx} className="space-y-4">
<Skeleton className="h-6 w-1/3 rounded-lg" /> {/* Server Name */}
{[1, 2].map((varIdx) => (
<div key={varIdx} className="space-y-2">
<Skeleton className="h-5 w-1/4 rounded-lg" /> {/* Variable Title */}
<Skeleton className="h-8 w-full rounded-lg" /> {/* Input Field */}
<Skeleton className="h-4 w-2/3 rounded-lg" /> {/* Description */}
</div>
))}
</div>
))}
</div>
);
}

View file

@ -1,9 +1,9 @@
import { TPlugin } from 'librechat-data-provider';
import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react';
import { AgentToolType } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
type ToolItemProps = {
tool: TPlugin;
tool: AgentToolType;
onAddTool: () => void;
onRemoveTool: () => void;
isInstalled?: boolean;
@ -19,15 +19,19 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
}
};
const name = tool.metadata?.name || tool.tool_id;
const description = tool.metadata?.description || '';
const icon = tool.metadata?.icon;
return (
<div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6">
<div className="flex gap-4">
<div className="h-[70px] w-[70px] shrink-0">
<div className="relative h-full w-full">
{tool.icon != null && tool.icon ? (
{icon ? (
<img
src={tool.icon}
alt={localize('com_ui_logo', { 0: tool.name })}
src={icon}
alt={localize('com_ui_logo', { 0: name })}
className="h-full w-full rounded-[5px] bg-white"
/>
) : (
@ -40,12 +44,12 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
</div>
<div className="flex min-w-0 flex-col items-start justify-between">
<div className="mb-2 line-clamp-1 max-w-full text-lg leading-5 text-text-primary">
{tool.name}
{name}
</div>
{!isInstalled ? (
<button
className="btn btn-primary relative"
aria-label={`${localize('com_ui_add')} ${tool.name}`}
aria-label={`${localize('com_ui_add')} ${name}`}
onClick={handleClick}
>
<div className="flex w-full items-center justify-center gap-2">
@ -57,7 +61,7 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
<button
className="btn relative bg-gray-300 hover:bg-gray-400 dark:bg-gray-50 dark:hover:bg-gray-200"
onClick={handleClick}
aria-label={`${localize('com_nav_tool_remove')} ${tool.name}`}
aria-label={`${localize('com_nav_tool_remove')} ${name}`}
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_nav_tool_remove')}
@ -67,7 +71,7 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
)}
</div>
</div>
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{tool.description}</div>
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{description}</div>
</div>
);
}

View file

@ -1,17 +1,19 @@
import { useEffect } from 'react';
import { Search, X } from 'lucide-react';
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
import { useFormContext } from 'react-hook-form';
import { isAgentsEndpoint } from 'librechat-data-provider';
import { Constants, isAgentsEndpoint } from 'librechat-data-provider';
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type {
AssistantsEndpoint,
EModelEndpoint,
TPluginAction,
AgentToolType,
TError,
} from 'librechat-data-provider';
import type { TPluginStoreDialogProps } from '~/common/types';
import type { AgentForm, TPluginStoreDialogProps } from '~/common';
import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { useLocalize, usePluginDialogHelpers } from '~/hooks';
import { useAvailableToolsQuery } from '~/data-provider';
import ToolItem from './ToolItem';
@ -20,14 +22,13 @@ function ToolSelectDialog({
isOpen,
endpoint,
setIsOpen,
toolsFormKey,
}: TPluginStoreDialogProps & {
toolsFormKey: string;
endpoint: AssistantsEndpoint | EModelEndpoint.agents;
}) {
const localize = useLocalize();
const { getValues, setValue } = useFormContext();
const { getValues, setValue } = useFormContext<AgentForm>();
const { data: tools } = useAvailableToolsQuery(endpoint);
const { groupedTools } = useAgentPanelContext();
const isAgentTools = isAgentsEndpoint(endpoint);
const {
@ -66,11 +67,23 @@ function ToolSelectDialog({
}, 5000);
};
const toolsFormKey = 'tools';
const handleInstall = (pluginAction: TPluginAction) => {
const addFunction = () => {
const fns = getValues(toolsFormKey).slice();
fns.push(pluginAction.pluginKey);
setValue(toolsFormKey, fns);
const installedToolIds: string[] = getValues(toolsFormKey) || [];
// Add the parent
installedToolIds.push(pluginAction.pluginKey);
// If this tool is a group, add subtools too
const groupObj = groupedTools[pluginAction.pluginKey];
if (groupObj?.tools && groupObj.tools.length > 0) {
for (const sub of groupObj.tools) {
if (!installedToolIds.includes(sub.tool_id)) {
installedToolIds.push(sub.tool_id);
}
}
}
setValue(toolsFormKey, Array.from(new Set(installedToolIds))); // no duplicates just in case
};
if (!pluginAction.auth) {
@ -87,17 +100,21 @@ function ToolSelectDialog({
setShowPluginAuthForm(false);
};
const onRemoveTool = (tool: string) => {
setShowPluginAuthForm(false);
const onRemoveTool = (toolId: string) => {
const groupObj = groupedTools[toolId];
const toolIdsToRemove = [toolId];
if (groupObj?.tools && groupObj.tools.length > 0) {
toolIdsToRemove.push(...groupObj.tools.map((sub) => sub.tool_id));
}
// Remove these from the formTools
updateUserPlugins.mutate(
{ pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
{
onError: (error: unknown) => {
handleInstallError(error as TError);
},
onError: (error: unknown) => handleInstallError(error as TError),
onSuccess: () => {
const fns = getValues(toolsFormKey).filter((fn: string) => fn !== tool);
setValue(toolsFormKey, fns);
const remainingToolIds =
getValues(toolsFormKey)?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || [];
setValue(toolsFormKey, remainingToolIds);
},
},
);
@ -108,22 +125,45 @@ function ToolSelectDialog({
const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey);
setSelectedPlugin(getAvailablePluginFromKey);
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
const isMCPTool = pluginKey.includes(Constants.mcp_delimiter);
if (authConfig && authConfig.length > 0 && !authenticated) {
setShowPluginAuthForm(true);
if (isMCPTool) {
// MCP tools have their variables configured elsewhere (e.g., MCPPanel or MCPSelect),
// so we directly proceed to install without showing the auth form.
handleInstall({ pluginKey, action: 'install', auth: {} });
} else {
handleInstall({ pluginKey, action: 'install', auth: null });
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
if (authConfig && authConfig.length > 0 && !authenticated) {
setShowPluginAuthForm(true);
} else {
handleInstall({
pluginKey,
action: 'install',
auth: {},
});
}
}
};
const filteredTools = tools?.filter((tool) =>
tool.name.toLowerCase().includes(searchValue.toLowerCase()),
const filteredTools = Object.values(groupedTools || {}).filter(
(tool: AgentToolType & { tools?: AgentToolType[] }) => {
// Check if the parent tool matches
if (tool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) {
return true;
}
// Check if any child tools match
if (tool.tools) {
return tool.tools.some((childTool) =>
childTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()),
);
}
return false;
},
);
useEffect(() => {
if (filteredTools) {
setMaxPage(Math.ceil(filteredTools.length / itemsPerPage));
setMaxPage(Math.ceil(Object.keys(filteredTools || {}).length / itemsPerPage));
if (searchChanged) {
setCurrentPage(1);
setSearchChanged(false);
@ -155,7 +195,7 @@ function ToolSelectDialog({
{/* Full-screen container to center the panel */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel
className="relative w-full transform overflow-hidden overflow-y-auto rounded-lg bg-surface-secondary text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
className="relative max-h-[90vh] w-full transform overflow-hidden overflow-y-auto rounded-lg bg-surface-secondary text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
style={{ minHeight: '610px' }}
>
<div className="flex items-center justify-between border-b-[1px] border-border-medium px-4 pb-4 pt-5 sm:p-6">
@ -228,9 +268,9 @@ function ToolSelectDialog({
<ToolItem
key={index}
tool={tool}
isInstalled={getValues(toolsFormKey).includes(tool.pluginKey)}
onAddTool={() => onAddTool(tool.pluginKey)}
onRemoveTool={() => onRemoveTool(tool.pluginKey)}
isInstalled={getValues(toolsFormKey)?.includes(tool.tool_id) || false}
onAddTool={() => onAddTool(tool.tool_id)}
onRemoveTool={() => onRemoveTool(tool.tool_id)}
/>
))}
</div>

View file

@ -0,0 +1,122 @@
import React, { useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Input, Label, OGDialog, Button } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize } from '~/hooks';
export interface ConfigFieldDetail {
title: string;
description: string;
}
interface MCPConfigDialogProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
fieldsSchema: Record<string, ConfigFieldDetail>;
initialValues: Record<string, string>;
onSave: (updatedValues: Record<string, string>) => void;
isSubmitting?: boolean;
onRevoke?: () => void;
serverName: string;
}
export default function MCPConfigDialog({
isOpen,
onOpenChange,
fieldsSchema,
initialValues,
onSave,
isSubmitting = false,
onRevoke,
serverName,
}: MCPConfigDialogProps) {
const localize = useLocalize();
const {
control,
handleSubmit,
reset,
formState: { errors, _ },
} = useForm<Record<string, string>>({
defaultValues: initialValues,
});
useEffect(() => {
if (isOpen) {
reset(initialValues);
}
}, [isOpen, initialValues, reset]);
const onFormSubmit = (data: Record<string, string>) => {
onSave(data);
};
const handleRevoke = () => {
if (onRevoke) {
onRevoke();
}
};
const dialogTitle = localize('com_ui_configure_mcp_variables_for', { 0: serverName });
const dialogDescription = localize('com_ui_mcp_dialog_desc');
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogTemplate
className="sm:max-w-lg"
title={dialogTitle}
description={dialogDescription}
headerClassName="px-6 pt-6 pb-4"
main={
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4 px-6 pb-2">
{Object.entries(fieldsSchema).map(([key, details]) => (
<div key={key} className="space-y-2">
<Label htmlFor={key} className="text-sm font-medium">
{details.title}
</Label>
<Controller
name={key}
control={control}
defaultValue={initialValues[key] || ''}
render={({ field }) => (
<Input
id={key}
type="text"
{...field}
placeholder={localize('com_ui_mcp_enter_var', { 0: details.title })}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
/>
)}
/>
{details.description && (
<p
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
dangerouslySetInnerHTML={{ __html: details.description }}
/>
)}
{errors[key] && <p className="text-xs text-red-500">{errors[key]?.message}</p>}
</div>
))}
</form>
}
selection={{
selectHandler: handleSubmit(onFormSubmit),
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
selectText: isSubmitting ? localize('com_ui_saving') : localize('com_ui_save'),
}}
buttons={
onRevoke && (
<Button
onClick={handleRevoke}
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
disabled={isSubmitting}
>
{localize('com_ui_revoke')}
</Button>
)
}
footerClassName="flex justify-end gap-2 px-6 pb-6 pt-2"
showCancelButton={true}
/>
</OGDialog>
);
}

View file

@ -26,6 +26,11 @@ interface MultiSelectProps<T extends string> {
selectItemsClassName?: string;
selectedValues: T[];
setSelectedValues: (values: T[]) => void;
renderItemContent?: (
value: T,
defaultContent: React.ReactNode,
isSelected: boolean,
) => React.ReactNode;
}
function defaultRender<T extends string>(values: T[], placeholder?: string) {
@ -54,9 +59,9 @@ export default function MultiSelect<T extends string>({
selectItemsClassName,
selectedValues = [],
setSelectedValues,
renderItemContent,
}: MultiSelectProps<T>) {
const selectRef = useRef<HTMLButtonElement>(null);
// const [selectedValues, setSelectedValues] = React.useState<T[]>(defaultSelectedValues);
const handleValueChange = (values: T[]) => {
setSelectedValues(values);
@ -105,23 +110,33 @@ export default function MultiSelect<T extends string>({
popoverClassName,
)}
>
{items.map((value) => (
<SelectItem
key={value}
value={value}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
'scroll-m-1 outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
'w-full min-w-0 text-sm',
itemClassName,
)}
>
<SelectItemCheck className="text-primary" />
<span className="truncate">{value}</span>
</SelectItem>
))}
{items.map((value) => {
const defaultContent = (
<>
<SelectItemCheck className="text-primary" />
<span className="truncate">{value}</span>
</>
);
const isCurrentItemSelected = selectedValues.includes(value);
return (
<SelectItem
key={value}
value={value}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
'scroll-m-1 outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
'w-full min-w-0 text-sm',
itemClassName,
)}
>
{renderItemContent
? renderItemContent(value, defaultContent, isCurrentItemSelected)
: defaultContent}
</SelectItem>
);
})}
</SelectPopover>
</SelectProvider>
</div>

View file

@ -1,4 +1,5 @@
import { v4 } from 'uuid';
import { cloneDeep } from 'lodash';
import { useQueryClient } from '@tanstack/react-query';
import {
Constants,
@ -51,10 +52,10 @@ export default function useChatFunctions({
getMessages,
setMessages,
isSubmitting,
conversation,
latestMessage,
setSubmission,
setLatestMessage,
conversation: immutableConversation,
}: {
index?: number;
isSubmitting: boolean;
@ -77,8 +78,8 @@ export default function useChatFunctions({
const isTemporary = useRecoilValue(store.isTemporary);
const codeArtifacts = useRecoilValue(store.codeArtifacts);
const includeShadcnui = useRecoilValue(store.includeShadcnui);
const { getExpiry } = useUserKey(conversation?.endpoint ?? '');
const customPromptMode = useRecoilValue(store.customPromptMode);
const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? '');
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
@ -108,6 +109,8 @@ export default function useChatFunctions({
return;
}
const conversation = cloneDeep(immutableConversation);
const endpoint = conversation?.endpoint;
if (endpoint === null) {
console.error('No endpoint available');

View file

@ -19,6 +19,7 @@ import { useChatContext } from '~/Providers/ChatContext';
import { useToastContext } from '~/Providers/ToastContext';
import { logger, validateFiles } from '~/utils';
import useClientSideResize from './useClientSideResize';
import { processFileForUpload } from '~/utils/heicConverter';
import { useDelayedUploadToast } from './useDelayedUploadToast';
import useUpdateFiles from './useUpdateFiles';
@ -264,13 +265,61 @@ const useFileHandling = (params?: UseFileHandling) => {
for (const originalFile of fileList) {
const file_id = v4();
try {
let processedFile = originalFile;
// Create initial preview with original file
const initialPreview = URL.createObjectURL(originalFile);
// Create initial ExtendedFile to show immediately
const initialExtendedFile: ExtendedFile = {
file_id,
file: originalFile,
type: originalFile.type,
preview: initialPreview,
progress: 0.1, // Show as processing
size: originalFile.size,
};
if (_toolResource != null && _toolResource !== '') {
initialExtendedFile.tool_resource = _toolResource;
}
// Add file immediately to show in UI
addFile(initialExtendedFile);
// Check if HEIC conversion is needed and show toast
const isHEIC =
originalFile.type === 'image/heic' ||
originalFile.type === 'image/heif' ||
originalFile.name.toLowerCase().match(/\.(heic|heif)$/);
if (isHEIC) {
showToast({
message: localize('com_info_heic_converting'),
status: 'info',
duration: 3000,
});
}
// Process file for HEIC conversion if needed
const heicProcessedFile = await processFileForUpload(
originalFile,
0.9,
(conversionProgress) => {
// Update progress during HEIC conversion (0.1 to 0.5 range for conversion)
const adjustedProgress = 0.1 + conversionProgress * 0.4;
replaceFile({
...initialExtendedFile,
progress: adjustedProgress,
});
},
);
let finalProcessedFile = heicProcessedFile;
// Apply client-side resizing if available and appropriate
if (originalFile.type.startsWith('image/')) {
if (heicProcessedFile.type.startsWith('image/')) {
try {
const resizeResult = await resizeImageIfNeeded(originalFile);
processedFile = resizeResult.file;
const resizeResult = await resizeImageIfNeeded(heicProcessedFile);
finalProcessedFile = resizeResult.file;
// Show toast notification if image was resized
if (resizeResult.resized && resizeResult.result) {
@ -287,45 +336,66 @@ const useFileHandling = (params?: UseFileHandling) => {
}
} catch (resizeError) {
console.warn('Image resize failed, using original:', resizeError);
// Continue with original file if resizing fails
// Continue with HEIC processed file if resizing fails
}
}
const preview = URL.createObjectURL(processedFile);
const extendedFile: ExtendedFile = {
file_id,
file: processedFile,
type: processedFile.type,
preview,
progress: 0.2,
size: processedFile.size,
};
// If file was processed (HEIC converted or resized), update with new file and preview
if (finalProcessedFile !== originalFile) {
URL.revokeObjectURL(initialPreview); // Clean up original preview
const newPreview = URL.createObjectURL(finalProcessedFile);
if (_toolResource != null && _toolResource !== '') {
extendedFile.tool_resource = _toolResource;
const updatedExtendedFile: ExtendedFile = {
...initialExtendedFile,
file: finalProcessedFile,
type: finalProcessedFile.type,
preview: newPreview,
progress: 0.5, // Processing complete, ready for upload
size: finalProcessedFile.size,
};
replaceFile(updatedExtendedFile);
const isImage = finalProcessedFile.type.split('/')[0] === 'image';
if (isImage) {
loadImage(updatedExtendedFile, newPreview);
continue;
}
await startUpload(updatedExtendedFile);
} else {
// File wasn't processed, proceed with original
const isImage = originalFile.type.split('/')[0] === 'image';
const tool_resource =
initialExtendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource;
if (isAgentsEndpoint(endpoint) && !isImage && tool_resource == null) {
/** Note: this needs to be removed when we can support files to providers */
setError('com_error_files_unsupported_capability');
continue;
}
// Update progress to show ready for upload
const readyExtendedFile = {
...initialExtendedFile,
progress: 0.2,
};
replaceFile(readyExtendedFile);
if (isImage) {
loadImage(readyExtendedFile, initialPreview);
continue;
}
await startUpload(readyExtendedFile);
}
const isImage = processedFile.type.split('/')[0] === 'image';
const tool_resource =
extendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource;
if (isAgentsEndpoint(endpoint) && !isImage && tool_resource == null) {
/** Note: this needs to be removed when we can support files to providers */
setError('com_error_files_unsupported_capability');
continue;
}
addFile(extendedFile);
if (isImage) {
loadImage(extendedFile, preview);
continue;
}
await startUpload(extendedFile);
} catch (error) {
deleteFileById(file_id);
console.log('file handling error', error);
setError('com_error_files_process');
if (error instanceof Error && error.message.includes('HEIC')) {
setError('com_error_heic_conversion');
} else {
setError('com_error_files_process');
}
}
}
};

View file

@ -17,7 +17,10 @@ import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch';
import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
import Parameters from '~/components/SidePanel/Parameters/Panel';
import FilesPanel from '~/components/SidePanel/Files/Panel';
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
import { Blocks, AttachmentIcon } from '~/components/svg';
import { useGetStartupConfig } from '~/data-provider';
import MCPIcon from '~/components/ui/MCPIcon';
import { useHasAccess } from '~/hooks';
export default function useSideNavLinks({
@ -59,6 +62,7 @@ export default function useSideNavLinks({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.CREATE,
});
const { data: startupConfig } = useGetStartupConfig();
const Links = useMemo(() => {
const links: NavLink[] = [];
@ -149,6 +153,21 @@ export default function useSideNavLinks({
});
}
if (
startupConfig?.mcpServers &&
Object.values(startupConfig.mcpServers).some(
(server) => server.customUserVars && Object.keys(server.customUserVars).length > 0,
)
) {
links.push({
title: 'com_nav_setting_mcp',
label: '',
icon: MCPIcon,
id: 'mcp-settings',
Component: MCPPanel,
});
}
links.push({
title: 'com_sidepanel_hide_panel',
label: '',
@ -171,6 +190,7 @@ export default function useSideNavLinks({
hasAccessToBookmarks,
hasAccessToCreateAgents,
hidePanel,
startupConfig,
]);
return Links;

View file

@ -17,6 +17,12 @@
"com_agents_file_search_disabled": "Agent must be created before uploading files for File Search.",
"com_agents_file_search_info": "When enabled, the agent will be informed of the exact filenames listed below, allowing it to retrieve relevant context from these files.",
"com_agents_instructions_placeholder": "The system instructions that the agent uses",
"com_agents_mcp_description_placeholder": "Explain what it does in a few words",
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px",
"com_agents_mcp_info": "Add MCP servers to your agent to enable it to perform tasks and interact with external services",
"com_agents_mcp_name_placeholder": "Custom Tool",
"com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat",
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",
"com_agents_missing_provider_model": "Please select a provider and model before creating an agent.",
"com_agents_name_placeholder": "Optional: The name of the agent",
"com_agents_no_access": "You don't have access to edit this agent.",
@ -276,6 +282,7 @@
"com_error_files_upload": "An error occurred while uploading the file.",
"com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.",
"com_error_files_validation": "An error occurred while validating the file.",
"com_error_heic_conversion": "Failed to convert HEIC image to JPEG. Please try converting the image manually or use a different format.",
"com_error_input_length": "The latest message token count is too long, exceeding the token limit, or your token limit parameters are misconfigured, adversely affecting the context window. More info: {{0}}. Please shorten your message, adjust the max context size from the conversation parameters, or fork the conversation to continue.",
"com_error_invalid_agent_provider": "The \"{{0}}\" provider is not available for use with Agents. Please go to your agent's settings and select a currently available provider.",
"com_error_invalid_user_key": "Invalid key provided. Please provide a valid key and try again.",
@ -288,6 +295,7 @@
"com_files_table": "something needs to go here. was empty",
"com_generated_files": "Generated files:",
"com_hide_examples": "Hide Examples",
"com_info_heic_converting": "Converting HEIC image to JPEG...",
"com_nav_2fa": "Two-Factor Authentication (2FA)",
"com_nav_account_settings": "Account Settings",
"com_nav_always_make_prod": "Always make new versions production",
@ -421,6 +429,8 @@
"com_nav_log_out": "Log out",
"com_nav_long_audio_warning": "Longer texts will take longer to process.",
"com_nav_maximize_chat_space": "Maximize chat space",
"com_nav_mcp_vars_update_error": "Error updating MCP custom user variables: {{0}}",
"com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.",
"com_nav_modular_chat": "Enable switching Endpoints mid-conversation",
"com_nav_my_files": "My Files",
"com_nav_not_supported": "Not Supported",
@ -445,6 +455,7 @@
"com_nav_setting_chat": "Chat",
"com_nav_setting_data": "Data controls",
"com_nav_setting_general": "General",
"com_nav_setting_mcp": "MCP Settings",
"com_nav_setting_personalization": "Personalization",
"com_nav_setting_speech": "Speech",
"com_nav_settings": "Settings",
@ -478,6 +489,9 @@
"com_sidepanel_conversation_tags": "Bookmarks",
"com_sidepanel_hide_panel": "Hide Panel",
"com_sidepanel_manage_files": "Manage Files",
"com_sidepanel_mcp_enter_value": "Enter value for {{0}}",
"com_sidepanel_mcp_no_servers_with_vars": "No MCP servers with configurable variables.",
"com_sidepanel_mcp_variables_for": "MCP Variables for {{0}}",
"com_sidepanel_parameters": "Parameters",
"com_sources_image_alt": "Search result image",
"com_sources_more_sources": "+{{count}} sources",
@ -498,6 +512,8 @@
"com_ui_accept": "I accept",
"com_ui_action_button": "Action Button",
"com_ui_add": "Add",
"com_ui_add_mcp": "Add MCP",
"com_ui_add_mcp_server": "Add MCP Server",
"com_ui_add_model_preset": "Add a model or preset for an additional response",
"com_ui_add_multi_conversation": "Add multi-conversation",
"com_ui_adding_details": "Adding details",
@ -566,8 +582,10 @@
"com_ui_auth_url": "Authorization URL",
"com_ui_authentication": "Authentication",
"com_ui_authentication_type": "Authentication Type",
"com_ui_available_tools": "Available Tools",
"com_ui_avatar": "Avatar",
"com_ui_azure": "Azure",
"com_ui_back": "Back",
"com_ui_back_to_chat": "Back to Chat",
"com_ui_back_to_prompts": "Back to Prompts",
"com_ui_backup_codes": "Backup Codes",
@ -607,11 +625,13 @@
"com_ui_client_secret": "Client Secret",
"com_ui_close": "Close",
"com_ui_close_menu": "Close Menu",
"com_ui_close_window": "Close Window",
"com_ui_code": "Code",
"com_ui_collapse_chat": "Collapse Chat",
"com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used",
"com_ui_command_usage_placeholder": "Select a Prompt by command or name",
"com_ui_complete_setup": "Complete Setup",
"com_ui_configure_mcp_variables_for": "Configure Variables for {{0}}",
"com_ui_confirm_action": "Confirm Action",
"com_ui_confirm_admin_use_change": "Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?",
"com_ui_confirm_change": "Confirm Change",
@ -662,6 +682,10 @@
"com_ui_delete_confirm": "This will delete",
"com_ui_delete_confirm_prompt_version_var": "This will delete the selected version for \"{{0}}.\" If no other versions exist, the prompt will be deleted.",
"com_ui_delete_conversation": "Delete chat?",
"com_ui_delete_mcp": "Delete MCP",
"com_ui_delete_mcp_confirm": "Are you sure you want to delete this MCP server?",
"com_ui_delete_mcp_error": "Failed to delete MCP server",
"com_ui_delete_mcp_success": "MCP server deleted successfully",
"com_ui_delete_memory": "Delete Memory",
"com_ui_delete_prompt": "Delete Prompt?",
"com_ui_delete_shared_link": "Delete shared link?",
@ -671,6 +695,7 @@
"com_ui_descending": "Desc",
"com_ui_description": "Description",
"com_ui_description_placeholder": "Optional: Enter a description to display for the prompt",
"com_ui_deselect_all": "Deselect All",
"com_ui_disabling": "Disabling...",
"com_ui_download": "Download",
"com_ui_download_artifact": "Download Artifact",
@ -686,6 +711,7 @@
"com_ui_duplication_success": "Successfully duplicated conversation",
"com_ui_edit": "Edit",
"com_ui_edit_editing_image": "Editing image",
"com_ui_edit_mcp_server": "Edit MCP Server",
"com_ui_edit_memory": "Edit Memory",
"com_ui_empty_category": "-",
"com_ui_endpoint": "Endpoint",
@ -765,6 +791,7 @@
"com_ui_hide_image_details": "Hide Image Details",
"com_ui_hide_qr": "Hide QR Code",
"com_ui_host": "Host",
"com_ui_icon": "Icon",
"com_ui_idea": "Ideas",
"com_ui_image_created": "Image created",
"com_ui_image_details": "Image Details",
@ -792,7 +819,11 @@
"com_ui_logo": "{{0}} Logo",
"com_ui_manage": "Manage",
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
"com_ui_mcp_dialog_desc": "Please enter the necessary information below.",
"com_ui_mcp_enter_var": "Enter value for {{0}}",
"com_ui_mcp_server_not_found": "Server not found.",
"com_ui_mcp_servers": "MCP Servers",
"com_ui_mcp_url": "MCP Server URL",
"com_ui_memories": "Memories",
"com_ui_memories_allow_create": "Allow creating Memories",
"com_ui_memories_allow_opt_out": "Allow users to opt out of Memories",
@ -833,10 +864,20 @@
"com_ui_not_used": "Not Used",
"com_ui_nothing_found": "Nothing found",
"com_ui_oauth": "OAuth",
"com_ui_oauth_connected_to": "Connected to",
"com_ui_oauth_error_callback_failed": "Authentication callback failed. Please try again.",
"com_ui_oauth_error_generic": "Authentication failed. Please try again.",
"com_ui_oauth_error_invalid_state": "Invalid state parameter. Please try again.",
"com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.",
"com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.",
"com_ui_oauth_error_title": "Authentication Failed",
"com_ui_oauth_success_description": "Your authentication was successful. This window will close in",
"com_ui_oauth_success_title": "Authentication Successful",
"com_ui_of": "of",
"com_ui_off": "Off",
"com_ui_on": "On",
"com_ui_openai": "OpenAI",
"com_ui_optional": "(optional)",
"com_ui_page": "Page",
"com_ui_preferences_updated": "Preferences updated successfully",
"com_ui_prev": "Prev",
@ -887,11 +928,14 @@
"com_ui_save_badge_changes": "Save badge changes?",
"com_ui_save_submit": "Save & Submit",
"com_ui_saved": "Saved!",
"com_ui_saving": "Saving...",
"com_ui_schema": "Schema",
"com_ui_scope": "Scope",
"com_ui_search": "Search",
"com_ui_seconds": "seconds",
"com_ui_secret_key": "Secret Key",
"com_ui_select": "Select",
"com_ui_select_all": "Select All",
"com_ui_select_file": "Select a file",
"com_ui_select_model": "Select a model",
"com_ui_select_provider": "Select a provider",
@ -943,13 +987,19 @@
"com_ui_token_exchange_method": "Token Exchange Method",
"com_ui_token_url": "Token URL",
"com_ui_tokens": "tokens",
"com_ui_tool_collection_prefix": "A collection of tools from",
"com_ui_tool_info": "Tool Information",
"com_ui_tool_more_info": "More information about this tool",
"com_ui_tools": "Tools",
"com_ui_travel": "Travel",
"com_ui_trust_app": "I trust this application",
"com_ui_unarchive": "Unarchive",
"com_ui_unarchive_error": "Failed to unarchive conversation",
"com_ui_unknown": "Unknown",
"com_ui_untitled": "Untitled",
"com_ui_update": "Update",
"com_ui_update_mcp_error": "There was an error creating or updating the MCP.",
"com_ui_update_mcp_success": "Successfully created or updated MCP",
"com_ui_upload": "Upload",
"com_ui_upload_code_files": "Upload for Code Interpreter",
"com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.",
@ -1005,27 +1055,5 @@
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_user_message": "You",
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.",
"com_ui_add_mcp": "Add MCP",
"com_ui_add_mcp_server": "Add MCP Server",
"com_ui_edit_mcp_server": "Edit MCP Server",
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",
"com_ui_delete_mcp": "Delete MCP",
"com_ui_delete_mcp_confirm": "Are you sure you want to delete this MCP server?",
"com_ui_delete_mcp_success": "MCP server deleted successfully",
"com_ui_delete_mcp_error": "Failed to delete MCP server",
"com_agents_mcp_info": "Add MCP servers to your agent to enable it to perform tasks and interact with external services",
"com_ui_update_mcp_error": "There was an error creating or updating the MCP.",
"com_ui_update_mcp_success": "Successfully created or updated MCP",
"com_ui_available_tools": "Available Tools",
"com_ui_select_all": "Select All",
"com_ui_deselect_all": "Deselect All",
"com_agents_mcp_name_placeholder": "Custom Tool",
"com_ui_optional": "(optional)",
"com_agents_mcp_description_placeholder": "Explain what it does in a few words",
"com_ui_mcp_url": "MCP Server URL",
"com_ui_trust_app": "I trust this application",
"com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat",
"com_ui_icon": "Icon",
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px"
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
}

View file

@ -1,13 +1,14 @@
import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom';
import {
Login,
Registration,
RequestPasswordReset,
ResetPassword,
VerifyEmail,
Registration,
ResetPassword,
ApiErrorWatcher,
TwoFactorScreen,
RequestPasswordReset,
} from '~/components/Auth';
import { OAuthSuccess, OAuthError } from '~/components/OAuth';
import { AuthContextProvider } from '~/hooks/AuthContext';
import RouteErrorBoundary from './RouteErrorBoundary';
import StartupLayout from './Layouts/Startup';
@ -31,6 +32,20 @@ export const router = createBrowserRouter([
element: <ShareRoute />,
errorElement: <RouteErrorBoundary />,
},
{
path: 'oauth',
errorElement: <RouteErrorBoundary />,
children: [
{
path: 'success',
element: <OAuthSuccess />,
},
{
path: 'error',
element: <OAuthError />,
},
],
},
{
path: '/',
element: <StartupLayout />,

View file

@ -0,0 +1,79 @@
import { heicTo, isHeic } from 'heic-to';
/**
* Check if a file is in HEIC format
* @param file - The file to check
* @returns Promise<boolean> - True if the file is HEIC
*/
export const isHEICFile = async (file: File): Promise<boolean> => {
try {
return await isHeic(file);
} catch (error) {
console.warn('Error checking if file is HEIC:', error);
// Fallback to mime type check
return file.type === 'image/heic' || file.type === 'image/heif';
}
};
/**
* Convert HEIC file to JPEG
* @param file - The HEIC file to convert
* @param quality - JPEG quality (0-1), default is 0.9
* @param onProgress - Optional callback to track conversion progress
* @returns Promise<File> - The converted JPEG file
*/
export const convertHEICToJPEG = async (
file: File,
quality: number = 0.9,
onProgress?: (progress: number) => void,
): Promise<File> => {
try {
// Report conversion start
onProgress?.(0.3);
const convertedBlob = await heicTo({
blob: file,
type: 'image/jpeg',
quality,
});
// Report conversion completion
onProgress?.(0.8);
// Create a new File object with the converted blob
const convertedFile = new File([convertedBlob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), {
type: 'image/jpeg',
lastModified: file.lastModified,
});
// Report file creation completion
onProgress?.(1.0);
return convertedFile;
} catch (error) {
console.error('Error converting HEIC to JPEG:', error);
throw new Error('Failed to convert HEIC image to JPEG');
}
};
/**
* Process a file, converting it from HEIC to JPEG if necessary
* @param file - The file to process
* @param quality - JPEG quality for conversion (0-1), default is 0.9
* @param onProgress - Optional callback to track conversion progress
* @returns Promise<File> - The processed file (converted if it was HEIC, original otherwise)
*/
export const processFileForUpload = async (
file: File,
quality: number = 0.9,
onProgress?: (progress: number) => void,
): Promise<File> => {
const isHEIC = await isHEICFile(file);
if (isHEIC) {
console.log('HEIC file detected, converting to JPEG...');
return convertHEICToJPEG(file, quality, onProgress);
}
return file;
};

View file

@ -1,163 +1,92 @@
import { processLaTeX, preprocessLaTeX } from './latex';
describe('processLaTeX', () => {
test('returns the same string if no LaTeX patterns are found', () => {
const content = 'This is a test string without LaTeX';
expect(processLaTeX(content)).toBe(content);
});
test('converts inline LaTeX expressions correctly', () => {
const content = 'This is an inline LaTeX expression: \\(x^2 + y^2 = z^2\\)';
const expected = 'This is an inline LaTeX expression: $x^2 + y^2 = z^2$';
expect(processLaTeX(content)).toBe(expected);
});
test('converts block LaTeX expressions correctly', () => {
const content = 'This is a block LaTeX expression: \\[E = mc^2\\]';
const expected = 'This is a block LaTeX expression: $$E = mc^2$$';
expect(processLaTeX(content)).toBe(expected);
});
test('converts mixed LaTeX expressions correctly', () => {
const content = 'Inline \\(a + b = c\\) and block \\[x^2 + y^2 = z^2\\]';
const expected = 'Inline $a + b = c$ and block $$x^2 + y^2 = z^2$$';
expect(processLaTeX(content)).toBe(expected);
});
test('escapes dollar signs followed by a digit or space and digit', () => {
const content = 'Price is $50 and $ 100';
const expected = 'Price is \\$50 and \\$ 100';
expect(processLaTeX(content)).toBe(expected);
});
test('handles strings with no content', () => {
const content = '';
expect(processLaTeX(content)).toBe('');
});
test('does not alter already valid inline Markdown LaTeX', () => {
const content = 'This is a valid inline LaTeX: $x^2 + y^2 = z^2$';
expect(processLaTeX(content)).toBe(content);
});
test('does not alter already valid block Markdown LaTeX', () => {
const content = 'This is a valid block LaTeX: $$E = mc^2$$';
expect(processLaTeX(content)).toBe(content);
});
test('correctly processes a mix of valid Markdown LaTeX and LaTeX patterns', () => {
const content = 'Valid $a + b = c$ and LaTeX to convert \\(x^2 + y^2 = z^2\\)';
const expected = 'Valid $a + b = c$ and LaTeX to convert $x^2 + y^2 = z^2$';
expect(processLaTeX(content)).toBe(expected);
});
test('correctly handles strings with LaTeX and non-LaTeX dollar signs', () => {
const content = 'Price $100 and LaTeX \\(x^2 + y^2 = z^2\\)';
const expected = 'Price \\$100 and LaTeX $x^2 + y^2 = z^2$';
expect(processLaTeX(content)).toBe(expected);
});
test('ignores non-LaTeX content enclosed in dollar signs', () => {
const content = 'This is not LaTeX: $This is just text$';
expect(processLaTeX(content)).toBe(content);
});
test('correctly processes complex block LaTeX with line breaks', () => {
const complexBlockLatex = `Certainly! Here's an example of a mathematical formula written in LaTeX:
\\[
\\sum_{i=1}^{n} \\left( \\frac{x_i}{y_i} \\right)^2
\\]
This formula represents the sum of the squares of the ratios of \\(x\\) to \\(y\\) for \\(n\\) terms, where \\(x_i\\) and \\(y_i\\) represent the values of \\(x\\) and \\(y\\) for each term.
LaTeX is a typesetting system commonly used for mathematical and scientific documents. It provides a wide range of formatting options and symbols for expressing mathematical expressions.`;
const expectedOutput = `Certainly! Here's an example of a mathematical formula written in LaTeX:
$$
\\sum_{i=1}^{n} \\left( \\frac{x_i}{y_i} \\right)^2
$$
This formula represents the sum of the squares of the ratios of $x$ to $y$ for $n$ terms, where $x_i$ and $y_i$ represent the values of $x$ and $y$ for each term.
LaTeX is a typesetting system commonly used for mathematical and scientific documents. It provides a wide range of formatting options and symbols for expressing mathematical expressions.`;
expect(processLaTeX(complexBlockLatex)).toBe(expectedOutput);
});
describe('processLaTeX with code block exception', () => {
test('ignores dollar signs inside inline code', () => {
const content = 'This is inline code: `$100`';
expect(processLaTeX(content)).toBe(content);
});
test('ignores dollar signs inside multi-line code blocks', () => {
const content = '```\n$100\n# $1000\n```';
expect(processLaTeX(content)).toBe(content);
});
test('processes LaTeX outside of code blocks', () => {
const content =
'Outside \\(x^2 + y^2 = z^2\\) and inside code block: ```\n$100\n# $1000\n```';
const expected = 'Outside $x^2 + y^2 = z^2$ and inside code block: ```\n$100\n# $1000\n```';
expect(processLaTeX(content)).toBe(expected);
});
});
});
import { preprocessLaTeX } from './latex';
describe('preprocessLaTeX', () => {
test('returns the same string if no LaTeX patterns are found', () => {
const content = 'This is a test string without LaTeX';
const content = 'This is a test string without LaTeX or dollar signs';
expect(preprocessLaTeX(content)).toBe(content);
});
test('escapes dollar signs followed by digits', () => {
test('returns the same string if no dollar signs are present', () => {
const content = 'This has LaTeX \\(x^2\\) and \\[y^2\\] but no dollars';
expect(preprocessLaTeX(content)).toBe(content);
});
test('preserves valid inline LaTeX delimiters \\(...\\)', () => {
const content = 'This is inline LaTeX: \\(x^2 + y^2 = z^2\\)';
expect(preprocessLaTeX(content)).toBe(content);
});
test('preserves valid block LaTeX delimiters \\[...\\]', () => {
const content = 'This is block LaTeX: \\[E = mc^2\\]';
expect(preprocessLaTeX(content)).toBe(content);
});
test('preserves valid double dollar delimiters', () => {
const content = 'This is valid: $$x^2 + y^2 = z^2$$';
expect(preprocessLaTeX(content)).toBe(content);
});
test('converts single dollar delimiters to double dollars', () => {
const content = 'Inline math: $x^2 + y^2 = z^2$';
const expected = 'Inline math: $$x^2 + y^2 = z^2$$';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('converts multiple single dollar expressions', () => {
const content = 'First $a + b = c$ and second $x^2 + y^2 = z^2$';
const expected = 'First $$a + b = c$$ and second $$x^2 + y^2 = z^2$$';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('escapes currency dollar signs', () => {
const content = 'Price is $50 and $100';
const expected = 'Price is \\$50 and \\$100';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('does not escape dollar signs not followed by digits', () => {
const content = 'This $variable is not escaped';
expect(preprocessLaTeX(content)).toBe(content);
test('escapes currency with spaces', () => {
const content = '$50 is $20 + $30';
const expected = '\\$50 is \\$20 + \\$30';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('preserves existing LaTeX expressions', () => {
const content = 'Inline $x^2 + y^2 = z^2$ and block $$E = mc^2$$';
expect(preprocessLaTeX(content)).toBe(content);
test('escapes currency with commas', () => {
const content = 'The price is $1,000,000 for this item.';
const expected = 'The price is \\$1,000,000 for this item.';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles mixed LaTeX and currency', () => {
test('escapes currency with decimals', () => {
const content = 'Total: $29.50 plus tax';
const expected = 'Total: \\$29.50 plus tax';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('converts LaTeX expressions while escaping currency', () => {
const content = 'LaTeX $x^2$ and price $50';
const expected = 'LaTeX $x^2$ and price \\$50';
const expected = 'LaTeX $$x^2$$ and price \\$50';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('converts LaTeX delimiters', () => {
const content = 'Brackets \\[x^2\\] and parentheses \\(y^2\\)';
const expected = 'Brackets $$x^2$$ and parentheses $y^2$';
test('handles Goldbach Conjecture example', () => {
const content = '- **Goldbach Conjecture**: $2n = p + q$ (every even integer > 2)';
const expected = '- **Goldbach Conjecture**: $$2n = p + q$$ (every even integer > 2)';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('does not escape already escaped dollar signs', () => {
const content = 'Already escaped \\$50 and \\$100';
expect(preprocessLaTeX(content)).toBe(content);
});
test('does not convert already escaped single dollars', () => {
const content = 'Escaped \\$x^2\\$ should not change';
expect(preprocessLaTeX(content)).toBe(content);
});
test('escapes mhchem commands', () => {
const content = '$\\ce{H2O}$ and $\\pu{123 J}$';
const expected = '$\\\\ce{H2O}$ and $\\\\pu{123 J}$';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles complex mixed content', () => {
const content = `
LaTeX inline $x^2$ and block $$y^2$$
Currency $100 and $200
Chemical $\\ce{H2O}$
Brackets \\[z^2\\]
`;
const expected = `
LaTeX inline $x^2$ and block $$y^2$$
Currency \\$100 and \\$200
Chemical $\\\\ce{H2O}$
Brackets $$z^2$$
`;
const expected = '$$\\\\ce{H2O}$$ and $$\\\\pu{123 J}$$';
expect(preprocessLaTeX(content)).toBe(expected);
});
@ -165,31 +94,117 @@ describe('preprocessLaTeX', () => {
expect(preprocessLaTeX('')).toBe('');
});
test('preserves code blocks', () => {
const content = '```\n$100\n```\nOutside $200';
const expected = '```\n$100\n```\nOutside \\$200';
test('handles complex mixed content', () => {
const content = `Valid double $$y^2$$
Currency $100 and $200
Single dollar math $x^2 + y^2$
Chemical $\\ce{H2O}$
Valid brackets \\[z^2\\]`;
const expected = `Valid double $$y^2$$
Currency \\$100 and \\$200
Single dollar math $$x^2 + y^2$$
Chemical $$\\\\ce{H2O}$$
Valid brackets \\[z^2\\]`;
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles multiple currency values in a sentence', () => {
const content = 'I have $50 in my wallet and $100 in the bank.';
const expected = 'I have \\$50 in my wallet and \\$100 in the bank.';
test('handles multiple equations with currency', () => {
const content = `- **Euler's Totient Function**: $\\phi(n) = n \\prod_{p|n} \\left(1 - \\frac{1}{p}\\right)$
- **Total Savings**: $500 + $200 + $150 = $850`;
const expected = `- **Euler's Totient Function**: $$\\phi(n) = n \\prod_{p|n} \\left(1 - \\frac{1}{p}\\right)$$
- **Total Savings**: \\$500 + \\$200 + \\$150 = \\$850`;
expect(preprocessLaTeX(content)).toBe(expected);
});
test('preserves LaTeX expressions with numbers', () => {
const content = 'The equation is $f(x) = 2x + 3$ where x is a variable.';
expect(preprocessLaTeX(content)).toBe(content);
test('handles inline code blocks', () => {
const content = 'Outside $x^2$ and inside code: `$100`';
const expected = 'Outside $$x^2$$ and inside code: `$100`';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles currency values with commas', () => {
const content = 'The price is $1,000,000 for this item.';
const expected = 'The price is \\$1,000,000 for this item.';
test('handles multiline code blocks', () => {
const content = '```\n$100\n$variable\n```\nOutside $x^2$';
const expected = '```\n$100\n$variable\n```\nOutside $$x^2$$';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('preserves LaTeX expressions with special characters', () => {
const content = 'The set is defined as $\\{x | x > 0\\}$.';
expect(preprocessLaTeX(content)).toBe(content);
const expected = 'The set is defined as $$\\{x | x > 0\\}$$.';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles complex physics equations', () => {
const content = `- **Schrödinger Equation**: $i\\hbar\\frac{\\partial}{\\partial t}|\\psi\\rangle = \\hat{H}|\\psi\\rangle$
- **Einstein Field Equations**: $G_{\\mu\\nu} = \\frac{8\\pi G}{c^4} T_{\\mu\\nu}$`;
const expected = `- **Schrödinger Equation**: $$i\\hbar\\frac{\\partial}{\\partial t}|\\psi\\rangle = \\hat{H}|\\psi\\rangle$$
- **Einstein Field Equations**: $$G_{\\mu\\nu} = \\frac{8\\pi G}{c^4} T_{\\mu\\nu}$$`;
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles financial calculations with currency', () => {
const content = `- **Simple Interest**: $A = P + Prt = $1,000 + ($1,000)(0.05)(2) = $1,100$
- **ROI**: $\\text{ROI} = \\frac{$1,200 - $1,000}{$1,000} \\times 100\\% = 20\\%$`;
const expected = `- **Simple Interest**: $$A = P + Prt = \\$1,000 + (\\$1,000)(0.05)(2) = \\$1,100$$
- **ROI**: $$\\text{ROI} = \\frac{\\$1,200 - \\$1,000}{\\$1,000} \\times 100\\% = 20\\%$$`;
expect(preprocessLaTeX(content)).toBe(expected);
});
test('does not convert partial or malformed expressions', () => {
const content = 'A single $ sign should not be converted';
const expected = 'A single $ sign should not be converted';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles nested parentheses in LaTeX', () => {
const content =
'Matrix determinant: $\\det(A) = \\sum_{\\sigma \\in S_n} \\text{sgn}(\\sigma) \\prod_{i=1}^n a_{i,\\sigma(i)}$';
const expected =
'Matrix determinant: $$\\det(A) = \\sum_{\\sigma \\in S_n} \\text{sgn}(\\sigma) \\prod_{i=1}^n a_{i,\\sigma(i)}$$';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('preserves spacing in equations', () => {
const content = 'Equation: $f(x) = 2x + 3$ where x is a variable.';
const expected = 'Equation: $$f(x) = 2x + 3$$ where x is a variable.';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles LaTeX with newlines inside should not be converted', () => {
const content = `This has $x
y$ which spans lines`;
const expected = `This has $x
y$ which spans lines`;
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles multiple dollar signs in text', () => {
const content = 'Price $100 then equation $x + y = z$ then another price $50';
const expected = 'Price \\$100 then equation $$x + y = z$$ then another price \\$50';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles complex LaTeX with currency in same expression', () => {
const content = 'Calculate $\\text{Total} = \\$500 + \\$200$';
const expected = 'Calculate $$\\text{Total} = \\$500 + \\$200$$';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('preserves already escaped dollars in LaTeX', () => {
const content = 'The formula $f(x) = \\$2x$ represents cost';
const expected = 'The formula $$f(x) = \\$2x$$ represents cost';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles adjacent LaTeX and currency', () => {
const content = 'Formula $x^2$ costs $25';
const expected = 'Formula $$x^2$$ costs \\$25';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles LaTeX with special characters and currency', () => {
const content = 'Set $\\{x | x > \\$0\\}$ for positive prices';
const expected = 'Set $$\\{x | x > \\$0\\}$$ for positive prices';
expect(preprocessLaTeX(content)).toBe(expected);
});
});

View file

@ -1,105 +1,152 @@
// Regex to check if the processed content contains any potential LaTeX patterns
const containsLatexRegex =
/\\\(.*?\\\)|\\\[.*?\\\]|\$.*?\$|\\begin\{equation\}.*?\\end\{equation\}/;
// Regex for inline and block LaTeX expressions
const inlineLatex = new RegExp(/\\\((.+?)\\\)/, 'g');
const blockLatex = new RegExp(/\\\[(.*?[^\\])\\\]/, 'gs');
// Function to restore code blocks
const restoreCodeBlocks = (content: string, codeBlocks: string[]) => {
return content.replace(/<<CODE_BLOCK_(\d+)>>/g, (match, index) => codeBlocks[index]);
};
// Regex to identify code blocks and inline code
const codeBlockRegex = /(```[\s\S]*?```|`.*?`)/g;
export const processLaTeX = (_content: string) => {
let content = _content;
// Temporarily replace code blocks and inline code with placeholders
const codeBlocks: string[] = [];
let index = 0;
content = content.replace(codeBlockRegex, (match) => {
codeBlocks[index] = match;
return `<<CODE_BLOCK_${index++}>>`;
});
// Escape dollar signs followed by a digit or space and digit
let processedContent = content.replace(/(\$)(?=\s?\d)/g, '\\$');
// If no LaTeX patterns are found, restore code blocks and return the processed content
if (!containsLatexRegex.test(processedContent)) {
return restoreCodeBlocks(processedContent, codeBlocks);
}
// Convert LaTeX expressions to a markdown compatible format
processedContent = processedContent
.replace(inlineLatex, (match: string, equation: string) => `$${equation}$`) // Convert inline LaTeX
.replace(blockLatex, (match: string, equation: string) => `$$${equation}$$`); // Convert block LaTeX
// Restore code blocks
return restoreCodeBlocks(processedContent, codeBlocks);
};
// Pre-compile all regular expressions for better performance
const MHCHEM_CE_REGEX = /\$\\ce\{/g;
const MHCHEM_PU_REGEX = /\$\\pu\{/g;
const MHCHEM_CE_ESCAPED_REGEX = /\$\\\\ce\{[^}]*\}\$/g;
const MHCHEM_PU_ESCAPED_REGEX = /\$\\\\pu\{[^}]*\}\$/g;
const CURRENCY_REGEX =
/(?<![\\$])\$(?!\$)(?=\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?(?:\s|$|[^a-zA-Z\d]))/g;
const SINGLE_DOLLAR_REGEX = /(?<!\\)\$(?!\$)((?:[^$\n]|\\[$])+?)(?<!\\)\$(?!\$)/g;
/**
* Preprocesses LaTeX content by replacing delimiters and escaping certain characters.
* Escapes mhchem package notation in LaTeX by converting single dollar delimiters to double dollars
* and escaping backslashes in mhchem commands.
*
* @param text - The input text containing potential mhchem notation
* @returns The processed text with properly escaped mhchem notation
*/
function escapeMhchem(text: string): string {
// First escape the backslashes in mhchem commands
let result = text.replace(MHCHEM_CE_REGEX, '$\\\\ce{');
result = result.replace(MHCHEM_PU_REGEX, '$\\\\pu{');
// Then convert single dollar mhchem to double dollar
result = result.replace(MHCHEM_CE_ESCAPED_REGEX, (match) => `$${match}$`);
result = result.replace(MHCHEM_PU_ESCAPED_REGEX, (match) => `$${match}$`);
return result;
}
/**
* Efficiently finds all code block regions in the content
* @param content The content to analyze
* @returns Array of code block regions [start, end]
*/
function findCodeBlockRegions(content: string): Array<[number, number]> {
const regions: Array<[number, number]> = [];
let inlineStart = -1;
let multilineStart = -1;
for (let i = 0; i < content.length; i++) {
const char = content[i];
// Check for multiline code blocks
if (
char === '`' &&
i + 2 < content.length &&
content[i + 1] === '`' &&
content[i + 2] === '`'
) {
if (multilineStart === -1) {
multilineStart = i;
i += 2; // Skip the next two backticks
} else {
regions.push([multilineStart, i + 2]);
multilineStart = -1;
i += 2;
}
}
// Check for inline code blocks (only if not in multiline)
else if (char === '`' && multilineStart === -1) {
if (inlineStart === -1) {
inlineStart = i;
} else {
regions.push([inlineStart, i]);
inlineStart = -1;
}
}
}
return regions;
}
/**
* Checks if a position is inside any code block region using binary search
* @param position The position to check
* @param codeRegions Array of code block regions
* @returns True if position is inside a code block
*/
function isInCodeBlock(position: number, codeRegions: Array<[number, number]>): boolean {
let left = 0;
let right = codeRegions.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const [start, end] = codeRegions[mid];
if (position >= start && position <= end) {
return true;
} else if (position < start) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return false;
}
/**
* Preprocesses LaTeX content by escaping currency indicators and converting single dollar math delimiters.
* Optimized for high-frequency execution.
* @param content The input string containing LaTeX expressions.
* @returns The processed string with replaced delimiters and escaped characters.
* @returns The processed string with escaped currency indicators and converted math delimiters.
*/
export function preprocessLaTeX(content: string): string {
// Step 1: Protect code blocks
const codeBlocks: string[] = [];
content = content.replace(/(```[\s\S]*?```|`[^`\n]+`)/g, (match, code) => {
codeBlocks.push(code);
return `<<CODE_BLOCK_${codeBlocks.length - 1}>>`;
});
// Early return for most common case
if (!content.includes('$')) return content;
// Step 2: Protect existing LaTeX expressions
const latexExpressions: string[] = [];
content = content.replace(/(\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\(.*?\\\))/g, (match) => {
latexExpressions.push(match);
return `<<LATEX_${latexExpressions.length - 1}>>`;
});
// Process mhchem first (usually rare, so check if needed)
let processed = content;
if (content.includes('\\ce{') || content.includes('\\pu{')) {
processed = escapeMhchem(content);
}
// Step 3: Escape dollar signs that are likely currency indicators
content = content.replace(/\$(?=\d)/g, '\\$');
// Find all code block regions once
const codeRegions = findCodeBlockRegions(processed);
// Step 4: Restore LaTeX expressions
content = content.replace(/<<LATEX_(\d+)>>/g, (_, index) => latexExpressions[parseInt(index)]);
// First pass: escape currency dollar signs
const parts: string[] = [];
let lastIndex = 0;
// Step 5: Restore code blocks
content = content.replace(/<<CODE_BLOCK_(\d+)>>/g, (_, index) => codeBlocks[parseInt(index)]);
// Reset regex for reuse
CURRENCY_REGEX.lastIndex = 0;
// Step 6: Apply additional escaping functions
content = escapeBrackets(content);
content = escapeMhchem(content);
let match: RegExpExecArray | null;
while ((match = CURRENCY_REGEX.exec(processed)) !== null) {
if (!isInCodeBlock(match.index, codeRegions)) {
parts.push(processed.substring(lastIndex, match.index));
parts.push('\\$');
lastIndex = match.index + 1;
}
}
parts.push(processed.substring(lastIndex));
processed = parts.join('');
return content;
}
export function escapeBrackets(text: string): string {
const pattern = /(```[\S\s]*?```|`.*?`)|\\\[([\S\s]*?[^\\])\\]|\\\((.*?)\\\)/g;
return text.replace(
pattern,
(
match: string,
codeBlock: string | undefined,
squareBracket: string | undefined,
roundBracket: string | undefined,
): string => {
if (codeBlock != null) {
return codeBlock;
} else if (squareBracket != null) {
return `$$${squareBracket}$$`;
} else if (roundBracket != null) {
return `$${roundBracket}$`;
}
return match;
},
);
}
export function escapeMhchem(text: string) {
return text.replaceAll('$\\ce{', '$\\\\ce{').replaceAll('$\\pu{', '$\\\\pu{');
// Second pass: convert single dollar delimiters to double dollars
const result: string[] = [];
lastIndex = 0;
// Reset regex for reuse
SINGLE_DOLLAR_REGEX.lastIndex = 0;
while ((match = SINGLE_DOLLAR_REGEX.exec(processed)) !== null) {
if (!isInCodeBlock(match.index, codeRegions)) {
result.push(processed.substring(lastIndex, match.index));
result.push(`$$${match[1]}$$`);
lastIndex = match.index + match[0].length;
}
}
result.push(processed.substring(lastIndex));
return result.join('');
}

View file

@ -1,10 +1,10 @@
import path from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'path';
import type { Plugin } from 'vite';
import { defineConfig } from 'vite';
import { compression } from 'vite-plugin-compression2';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import type { Plugin } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
// https://vitejs.dev/config/
export default defineConfig(({ command }) => ({
@ -46,7 +46,7 @@ export default defineConfig(({ command }) => ({
'assets/maskable-icon.png',
'manifest.webmanifest',
],
globIgnores: ['images/**/*', '**/*.map'],
globIgnores: ['images/**/*', '**/*.map', 'index.html'],
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
navigateFallbackDenylist: [/^\/oauth/, /^\/api/],
},
@ -169,6 +169,9 @@ export default defineConfig(({ command }) => ({
if (id.includes('react-select') || id.includes('downshift')) {
return 'advanced-inputs';
}
if (id.includes('heic-to')) {
return 'heic-converter';
}
// Existing chunks
if (id.includes('@radix-ui')) {
@ -229,6 +232,7 @@ export default defineConfig(({ command }) => ({
alias: {
'~': path.join(__dirname, 'src/'),
$fonts: path.resolve(__dirname, 'public/fonts'),
'micromark-extension-math': 'micromark-extension-llm-math',
},
},
}));

View file

@ -1,50 +1,116 @@
#!/usr/bin/env node
const path = require('path');
const mongoose = require(path.resolve(__dirname, '..', 'api', 'node_modules', 'mongoose'));
const { User } = require('@librechat/data-schemas').createModels(mongoose);
const {
User,
Agent,
Assistant,
Balance,
Transaction,
ConversationTag,
Conversation,
Message,
File,
Key,
MemoryEntry,
PluginAuth,
Prompt,
PromptGroup,
Preset,
Session,
SharedLink,
ToolCall,
Token,
} = require('@librechat/data-schemas').createModels(mongoose);
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const { askQuestion, silentExit } = require('./helpers');
const connect = require('./connect');
async function gracefulExit(code = 0) {
try {
await mongoose.disconnect();
} catch (err) {
console.error('Error disconnecting from MongoDB:', err);
}
silentExit(code);
}
(async () => {
await connect();
/**
* Show the welcome / help menu
*/
console.purple('---------------');
console.purple('Deleting a user');
console.purple('Deleting a user and all related data');
console.purple('---------------');
let email = '';
if (process.argv.length >= 3) {
email = process.argv[2];
} else {
email = await askQuestion('Email:');
}
let user = await User.findOne({ email: email });
if (user !== null) {
if ((await askQuestion(`Delete user ${user}?`)) === 'y') {
user = await User.findOneAndDelete({ _id: user._id });
if (user !== null) {
console.yellow(`Deleted user ${user}`);
} else {
console.yellow(`Couldn't delete user with email ${email}`);
}
}
} else {
console.yellow(`Didn't find user with email ${email}`);
// 1) Get email
let email = process.argv[2]?.trim();
if (!email) {
email = (await askQuestion('Email:')).trim();
}
silentExit(0);
})();
// 2) Find user
const user = await User.findOne({ email: email.toLowerCase() });
if (!user) {
console.yellow(`No user found with email "${email}"`);
return gracefulExit(0);
}
process.on('uncaughtException', (err) => {
// 3) Confirm full deletion
const confirmAll = await askQuestion(
`Really delete user ${user.email} (${user._id}) and ALL their data? (y/N)`,
);
if (confirmAll.toLowerCase() !== 'y') {
console.yellow('Aborted.');
return gracefulExit(0);
}
// 4) Ask specifically about transactions
const confirmTx = await askQuestion('Also delete all transaction history for this user? (y/N)');
const deleteTx = confirmTx.toLowerCase() === 'y';
const uid = user._id.toString();
// 5) Build and run deletion tasks
const tasks = [
Agent.deleteMany({ author: uid }),
Assistant.deleteMany({ user: uid }),
Balance.deleteMany({ user: uid }),
ConversationTag.deleteMany({ user: uid }),
Conversation.deleteMany({ user: uid }),
Message.deleteMany({ user: uid }),
File.deleteMany({ user: uid }),
Key.deleteMany({ userId: uid }),
MemoryEntry.deleteMany({ userId: uid }),
PluginAuth.deleteMany({ userId: uid }),
Prompt.deleteMany({ author: uid }),
PromptGroup.deleteMany({ author: uid }),
Preset.deleteMany({ user: uid }),
Session.deleteMany({ user: uid }),
SharedLink.deleteMany({ user: uid }),
ToolCall.deleteMany({ user: uid }),
Token.deleteMany({ userId: uid }),
];
if (deleteTx) {
tasks.push(Transaction.deleteMany({ user: uid }));
}
await Promise.all(tasks);
// 6) Finally delete the user document itself
await User.deleteOne({ _id: uid });
console.green(`✔ Successfully deleted user ${email} and all associated data.`);
if (!deleteTx) {
console.yellow('⚠️ Transaction history was retained.');
}
return gracefulExit(0);
})().catch(async (err) => {
if (!err.message.includes('fetch failed')) {
console.error('There was an uncaught error:');
console.error(err);
}
if (!err.message.includes('fetch failed')) {
await mongoose.disconnect();
process.exit(1);
}
});

1004
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -64,6 +64,7 @@
"b:data": "cd packages/data-provider && bun run b:build",
"b:mcp": "cd packages/api && bun run b:build",
"b:data-schemas": "cd packages/data-schemas && bun run b:build",
"b:build:api": "cd packages/api && bun run b:build",
"b:client": "bun --bun run b:data && bun --bun run b:mcp && bun --bun run b:data-schemas && cd client && bun --bun run b:build",
"b:client:dev": "cd client && bun run b:dev",
"b:test:client": "cd client && bun run b:test",

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/api",
"version": "1.2.3",
"version": "1.2.4",
"type": "commonjs",
"description": "MCP services for LibreChat",
"main": "dist/index.js",
@ -69,9 +69,9 @@
"registry": "https://registry.npmjs.org/"
},
"peerDependencies": {
"@librechat/agents": "^2.4.37",
"@librechat/agents": "^2.4.41",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.11.2",
"@modelcontextprotocol/sdk": "^1.12.3",
"axios": "^1.8.2",
"diff": "^7.0.0",
"eventsource": "^3.0.2",
@ -80,6 +80,7 @@
"librechat-data-provider": "*",
"node-fetch": "2.7.0",
"tiktoken": "^1.0.15",
"undici": "^7.10.0",
"zod": "^3.22.4"
}
}

View file

@ -36,10 +36,11 @@ const plugins = [
const cjsBuild = {
input: 'src/index.ts',
output: {
file: pkg.main,
dir: 'dist',
format: 'cjs',
sourcemap: true,
exports: 'named',
entryFileNames: '[name].js',
},
external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})],
preserveSymlinks: true,

View file

@ -0,0 +1,93 @@
import { logger } from '@librechat/data-schemas';
import type { IPluginAuth, PluginAuthMethods } from '@librechat/data-schemas';
import { decrypt } from '../crypto/encryption';
export interface GetPluginAuthMapParams {
userId: string;
pluginKeys: string[];
throwError?: boolean;
findPluginAuthsByKeys: PluginAuthMethods['findPluginAuthsByKeys'];
}
export type PluginAuthMap = Record<string, Record<string, string>>;
/**
* Retrieves and decrypts authentication values for multiple plugins
* @returns A map where keys are pluginKeys and values are objects of authField:decryptedValue pairs
*/
export async function getPluginAuthMap({
userId,
pluginKeys,
throwError = true,
findPluginAuthsByKeys,
}: GetPluginAuthMapParams): Promise<PluginAuthMap> {
try {
/** Early return for empty plugin keys */
if (!pluginKeys?.length) {
return {};
}
/** All plugin auths for current user query */
const pluginAuths = await findPluginAuthsByKeys({ userId, pluginKeys });
/** Group auth records by pluginKey for efficient lookup */
const authsByPlugin = new Map<string, IPluginAuth[]>();
for (const auth of pluginAuths) {
if (!auth.pluginKey) {
logger.warn(`[getPluginAuthMap] Missing pluginKey for userId ${userId}`);
continue;
}
const existing = authsByPlugin.get(auth.pluginKey) || [];
existing.push(auth);
authsByPlugin.set(auth.pluginKey, existing);
}
const authMap: PluginAuthMap = {};
const decryptionPromises: Promise<void>[] = [];
/** Single loop through requested pluginKeys */
for (const pluginKey of pluginKeys) {
authMap[pluginKey] = {};
const auths = authsByPlugin.get(pluginKey) || [];
for (const auth of auths) {
decryptionPromises.push(
(async () => {
try {
const decryptedValue = await decrypt(auth.value);
authMap[pluginKey][auth.authField] = decryptedValue;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error(
`[getPluginAuthMap] Decryption failed for userId ${userId}, plugin ${pluginKey}, field ${auth.authField}: ${message}`,
);
if (throwError) {
throw new Error(
`Decryption failed for plugin ${pluginKey}, field ${auth.authField}: ${message}`,
);
}
}
})(),
);
}
}
await Promise.all(decryptionPromises);
return authMap;
} catch (error) {
if (!throwError) {
/** Empty objects for each plugin key on error */
return pluginKeys.reduce((acc, key) => {
acc[key] = {};
return acc;
}, {} as PluginAuthMap);
}
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error(
`[getPluginAuthMap] Failed to fetch auth values for userId ${userId}, plugins: ${pluginKeys.join(', ')}: ${message}`,
);
throw error;
}
}

View file

@ -1,6 +1,12 @@
import { Run, Providers } from '@librechat/agents';
import { providerEndpointMap, KnownEndpoints } from 'librechat-data-provider';
import type { StandardGraphConfig, EventHandler, GraphEvents, IState } from '@librechat/agents';
import type {
StandardGraphConfig,
EventHandler,
GenericTool,
GraphEvents,
IState,
} from '@librechat/agents';
import type { Agent } from 'librechat-data-provider';
import type * as t from '~/types';
@ -32,7 +38,7 @@ export async function createRun({
streaming = true,
streamUsage = true,
}: {
agent: Agent;
agent: Omit<Agent, 'tools'> & { tools?: GenericTool[] };
signal: AbortSignal;
runId?: string;
streaming?: boolean;

View file

@ -1,15 +1,15 @@
require('dotenv').config();
const crypto = require('node:crypto');
import 'dotenv/config';
import crypto from 'node:crypto';
const { webcrypto } = crypto;
// Use hex decoding for both key and IV for legacy methods.
const key = Buffer.from(process.env.CREDS_KEY, 'hex');
const iv = Buffer.from(process.env.CREDS_IV, 'hex');
const key = Buffer.from(process.env.CREDS_KEY ?? '', 'hex');
const iv = Buffer.from(process.env.CREDS_IV ?? '', 'hex');
const algorithm = 'AES-CBC';
// --- Legacy v1/v2 Setup: AES-CBC with fixed key and IV ---
async function encrypt(value) {
export async function encrypt(value: string) {
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
'encrypt',
]);
@ -23,7 +23,7 @@ async function encrypt(value) {
return Buffer.from(encryptedBuffer).toString('hex');
}
async function decrypt(encryptedValue) {
export async function decrypt(encryptedValue: string) {
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
'decrypt',
]);
@ -39,7 +39,7 @@ async function decrypt(encryptedValue) {
// --- v2: AES-CBC with a random IV per encryption ---
async function encryptV2(value) {
export async function encryptV2(value: string) {
const gen_iv = webcrypto.getRandomValues(new Uint8Array(16));
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
'encrypt',
@ -54,12 +54,12 @@ async function encryptV2(value) {
return Buffer.from(gen_iv).toString('hex') + ':' + Buffer.from(encryptedBuffer).toString('hex');
}
async function decryptV2(encryptedValue) {
export async function decryptV2(encryptedValue: string) {
const parts = encryptedValue.split(':');
if (parts.length === 1) {
return parts[0];
}
const gen_iv = Buffer.from(parts.shift(), 'hex');
const gen_iv = Buffer.from(parts.shift() ?? '', 'hex');
const encrypted = parts.join(':');
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
'decrypt',
@ -81,10 +81,10 @@ const algorithm_v3 = 'aes-256-ctr';
* Encrypts a value using AES-256-CTR.
* Note: AES-256 requires a 32-byte key. Ensure that process.env.CREDS_KEY is a 64-character hex string.
*
* @param {string} value - The plaintext to encrypt.
* @returns {string} The encrypted string with a "v3:" prefix.
* @param value - The plaintext to encrypt.
* @returns The encrypted string with a "v3:" prefix.
*/
function encryptV3(value) {
export function encryptV3(value: string) {
if (key.length !== 32) {
throw new Error(`Invalid key length: expected 32 bytes, got ${key.length} bytes`);
}
@ -94,7 +94,7 @@ function encryptV3(value) {
return `v3:${iv_v3.toString('hex')}:${encrypted.toString('hex')}`;
}
function decryptV3(encryptedValue) {
export function decryptV3(encryptedValue: string) {
const parts = encryptedValue.split(':');
if (parts[0] !== 'v3') {
throw new Error('Not a v3 encrypted value');
@ -106,7 +106,7 @@ function decryptV3(encryptedValue) {
return decrypted.toString('utf8');
}
async function getRandomValues(length) {
export async function getRandomValues(length: number) {
if (!Number.isInteger(length) || length <= 0) {
throw new Error('Length must be a positive integer');
}
@ -117,24 +117,13 @@ async function getRandomValues(length) {
/**
* Computes SHA-256 hash for the given input.
* @param {string} input
* @returns {Promise<string>}
* @param input - The input to hash.
* @returns The SHA-256 hash of the input.
*/
async function hashBackupCode(input) {
export async function hashBackupCode(input: string) {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await webcrypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
module.exports = {
encrypt,
decrypt,
encryptV2,
decryptV2,
encryptV3,
decryptV3,
hashBackupCode,
getRandomValues,
};

View file

@ -0,0 +1 @@
export * from './encryption';

View file

@ -1,4 +1,4 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
import { ProxyAgent } from 'undici';
import { KnownEndpoints } from 'librechat-data-provider';
import type * as t from '~/types';
import { sanitizeModelName, constructAzureURL } from '~/utils/azure';
@ -102,8 +102,10 @@ export function getOpenAIConfig(
}
if (proxy) {
const proxyAgent = new HttpsProxyAgent(proxy);
configOptions.httpAgent = proxyAgent;
const proxyAgent = new ProxyAgent(proxy);
configOptions.fetchOptions = {
dispatcher: proxyAgent,
};
}
if (azure) {

View file

@ -1,8 +1,8 @@
import { FlowStateManager } from './manager';
import { Keyv } from 'keyv';
import { FlowStateManager } from './manager';
import type { FlowState } from './types';
// Create a mock class without extending Keyv
/** Mock class without extending Keyv */
class MockKeyv {
private store: Map<string, FlowState<string>>;

View file

@ -1,28 +1,18 @@
import { Keyv } from 'keyv';
import { logger } from '@librechat/data-schemas';
import type { StoredDataNoRaw } from 'keyv';
import type { Logger } from 'winston';
import type { FlowState, FlowMetadata, FlowManagerOptions } from './types';
export class FlowStateManager<T = unknown> {
private keyv: Keyv;
private ttl: number;
private logger: Logger;
private intervals: Set<NodeJS.Timeout>;
private static getDefaultLogger(): Logger {
return {
error: console.error,
warn: console.warn,
info: console.info,
debug: console.debug,
} as Logger;
}
constructor(store: Keyv, options?: FlowManagerOptions) {
if (!options) {
options = { ttl: 60000 * 3 };
}
const { ci = false, ttl, logger } = options;
const { ci = false, ttl } = options;
if (!ci && !(store instanceof Keyv)) {
throw new Error('Invalid store provided to FlowStateManager');
@ -30,14 +20,13 @@ export class FlowStateManager<T = unknown> {
this.ttl = ttl;
this.keyv = store;
this.logger = logger || FlowStateManager.getDefaultLogger();
this.intervals = new Set();
this.setupCleanupHandlers();
}
private setupCleanupHandlers() {
const cleanup = () => {
this.logger.info('Cleaning up FlowStateManager intervals...');
logger.info('Cleaning up FlowStateManager intervals...');
this.intervals.forEach((interval) => clearInterval(interval));
this.intervals.clear();
process.exit(0);
@ -66,7 +55,7 @@ export class FlowStateManager<T = unknown> {
let existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
if (existingState) {
this.logger.debug(`[${flowKey}] Flow already exists`);
logger.debug(`[${flowKey}] Flow already exists`);
return this.monitorFlow(flowKey, type, signal);
}
@ -74,7 +63,7 @@ export class FlowStateManager<T = unknown> {
existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
if (existingState) {
this.logger.debug(`[${flowKey}] Flow exists on 2nd check`);
logger.debug(`[${flowKey}] Flow exists on 2nd check`);
return this.monitorFlow(flowKey, type, signal);
}
@ -85,7 +74,7 @@ export class FlowStateManager<T = unknown> {
createdAt: Date.now(),
};
this.logger.debug('Creating initial flow state:', flowKey);
logger.debug('Creating initial flow state:', flowKey);
await this.keyv.set(flowKey, initialState, this.ttl);
return this.monitorFlow(flowKey, type, signal);
}
@ -102,7 +91,7 @@ export class FlowStateManager<T = unknown> {
if (!flowState) {
clearInterval(intervalId);
this.intervals.delete(intervalId);
this.logger.error(`[${flowKey}] Flow state not found`);
logger.error(`[${flowKey}] Flow state not found`);
reject(new Error(`${type} Flow state not found`));
return;
}
@ -110,7 +99,7 @@ export class FlowStateManager<T = unknown> {
if (signal?.aborted) {
clearInterval(intervalId);
this.intervals.delete(intervalId);
this.logger.warn(`[${flowKey}] Flow aborted`);
logger.warn(`[${flowKey}] Flow aborted`);
const message = `${type} flow aborted`;
await this.keyv.delete(flowKey);
reject(new Error(message));
@ -120,7 +109,7 @@ export class FlowStateManager<T = unknown> {
if (flowState.status !== 'PENDING') {
clearInterval(intervalId);
this.intervals.delete(intervalId);
this.logger.debug(`[${flowKey}] Flow completed`);
logger.debug(`[${flowKey}] Flow completed`);
if (flowState.status === 'COMPLETED' && flowState.result !== undefined) {
resolve(flowState.result);
@ -135,17 +124,15 @@ export class FlowStateManager<T = unknown> {
if (elapsedTime >= this.ttl) {
clearInterval(intervalId);
this.intervals.delete(intervalId);
this.logger.error(
logger.error(
`[${flowKey}] Flow timed out | Elapsed time: ${elapsedTime} | TTL: ${this.ttl}`,
);
await this.keyv.delete(flowKey);
reject(new Error(`${type} flow timed out`));
}
this.logger.debug(
`[${flowKey}] Flow state elapsed time: ${elapsedTime}, checking again...`,
);
logger.debug(`[${flowKey}] Flow state elapsed time: ${elapsedTime}, checking again...`);
} catch (error) {
this.logger.error(`[${flowKey}] Error checking flow state:`, error);
logger.error(`[${flowKey}] Error checking flow state:`, error);
clearInterval(intervalId);
this.intervals.delete(intervalId);
reject(error);
@ -224,7 +211,7 @@ export class FlowStateManager<T = unknown> {
const flowKey = this.getFlowKey(flowId, type);
let existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
if (existingState) {
this.logger.debug(`[${flowKey}] Flow already exists`);
logger.debug(`[${flowKey}] Flow already exists`);
return this.monitorFlow(flowKey, type, signal);
}
@ -232,7 +219,7 @@ export class FlowStateManager<T = unknown> {
existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
if (existingState) {
this.logger.debug(`[${flowKey}] Flow exists on 2nd check`);
logger.debug(`[${flowKey}] Flow exists on 2nd check`);
return this.monitorFlow(flowKey, type, signal);
}
@ -242,7 +229,7 @@ export class FlowStateManager<T = unknown> {
metadata: {},
createdAt: Date.now(),
};
this.logger.debug(`[${flowKey}] Creating initial flow state`);
logger.debug(`[${flowKey}] Creating initial flow state`);
await this.keyv.set(flowKey, initialState, this.ttl);
try {

View file

@ -1,8 +1,14 @@
/* MCP */
export * from './mcp/manager';
export * from './mcp/oauth';
export * from './mcp/auth';
/* Utilities */
export * from './mcp/utils';
export * from './utils';
/* OAuth */
export * from './oauth';
/* Crypto */
export * from './crypto';
/* Flow */
export * from './flow/manager';
/* Agents */

Some files were not shown because too many files have changed in this diff Show more