mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-03 00:58:50 +01:00
Merge branch 'main' into feature/entra-id-azure-integration
This commit is contained in:
commit
af661b1df2
293 changed files with 20207 additions and 13884 deletions
|
|
@ -29,8 +29,59 @@ const clientRegistry = FinalizationRegistry
|
|||
})
|
||||
: null;
|
||||
|
||||
const graphPropsToClean = [
|
||||
'handlerRegistry',
|
||||
'runId',
|
||||
'tools',
|
||||
'signal',
|
||||
'config',
|
||||
'agentContexts',
|
||||
'messages',
|
||||
'contentData',
|
||||
'stepKeyIds',
|
||||
'contentIndexMap',
|
||||
'toolCallStepIds',
|
||||
'messageIdsByStepKey',
|
||||
'messageStepHasToolCalls',
|
||||
'prelimMessageIdsByStepKey',
|
||||
'startIndex',
|
||||
'defaultAgentId',
|
||||
'dispatchReasoningDelta',
|
||||
'compileOptions',
|
||||
'invokedToolIds',
|
||||
'overrideModel',
|
||||
];
|
||||
|
||||
const graphRunnablePropsToClean = [
|
||||
'lc_serializable',
|
||||
'lc_kwargs',
|
||||
'lc_runnable',
|
||||
'name',
|
||||
'lc_namespace',
|
||||
'lg_is_pregel',
|
||||
'nodes',
|
||||
'channels',
|
||||
'inputChannels',
|
||||
'outputChannels',
|
||||
'autoValidate',
|
||||
'streamMode',
|
||||
'streamChannels',
|
||||
'interruptAfter',
|
||||
'interruptBefore',
|
||||
'stepTimeout',
|
||||
'debug',
|
||||
'checkpointer',
|
||||
'retryPolicy',
|
||||
'config',
|
||||
'store',
|
||||
'triggerToNodes',
|
||||
'cache',
|
||||
'description',
|
||||
'metaRegistry',
|
||||
];
|
||||
|
||||
/**
|
||||
* Cleans up the client object by removing references to its properties.
|
||||
* Cleans up the client object by removing potential circular references to its properties.
|
||||
* This is useful for preventing memory leaks and ensuring that the client
|
||||
* and its properties can be garbage collected when it is no longer needed.
|
||||
*/
|
||||
|
|
@ -223,68 +274,54 @@ function disposeClient(client) {
|
|||
if (client.processMemory) {
|
||||
client.processMemory = null;
|
||||
}
|
||||
|
||||
if (client.run) {
|
||||
// Break circular references in run
|
||||
if (client.run.Graph) {
|
||||
client.run.Graph.resetValues();
|
||||
client.run.Graph.handlerRegistry = null;
|
||||
client.run.Graph.runId = null;
|
||||
client.run.Graph.tools = null;
|
||||
client.run.Graph.signal = null;
|
||||
client.run.Graph.config = null;
|
||||
client.run.Graph.toolEnd = null;
|
||||
client.run.Graph.toolMap = null;
|
||||
client.run.Graph.provider = null;
|
||||
client.run.Graph.streamBuffer = null;
|
||||
client.run.Graph.clientOptions = null;
|
||||
client.run.Graph.graphState = null;
|
||||
if (client.run.Graph.boundModel?.client) {
|
||||
client.run.Graph.boundModel.client = null;
|
||||
}
|
||||
client.run.Graph.boundModel = null;
|
||||
client.run.Graph.systemMessage = null;
|
||||
client.run.Graph.reasoningKey = null;
|
||||
client.run.Graph.messages = null;
|
||||
client.run.Graph.contentData = null;
|
||||
client.run.Graph.stepKeyIds = null;
|
||||
client.run.Graph.contentIndexMap = null;
|
||||
client.run.Graph.toolCallStepIds = null;
|
||||
client.run.Graph.messageIdsByStepKey = null;
|
||||
client.run.Graph.messageStepHasToolCalls = null;
|
||||
client.run.Graph.prelimMessageIdsByStepKey = null;
|
||||
client.run.Graph.currentTokenType = null;
|
||||
client.run.Graph.lastToken = null;
|
||||
client.run.Graph.tokenTypeSwitch = null;
|
||||
client.run.Graph.indexTokenCountMap = null;
|
||||
client.run.Graph.currentUsage = null;
|
||||
client.run.Graph.tokenCounter = null;
|
||||
client.run.Graph.maxContextTokens = null;
|
||||
client.run.Graph.pruneMessages = null;
|
||||
client.run.Graph.lastStreamCall = null;
|
||||
client.run.Graph.startIndex = null;
|
||||
|
||||
graphPropsToClean.forEach((prop) => {
|
||||
if (client.run.Graph[prop] !== undefined) {
|
||||
client.run.Graph[prop] = null;
|
||||
}
|
||||
});
|
||||
|
||||
client.run.Graph = null;
|
||||
}
|
||||
if (client.run.handlerRegistry) {
|
||||
client.run.handlerRegistry = null;
|
||||
}
|
||||
|
||||
if (client.run.graphRunnable) {
|
||||
if (client.run.graphRunnable.channels) {
|
||||
client.run.graphRunnable.channels = null;
|
||||
}
|
||||
if (client.run.graphRunnable.nodes) {
|
||||
client.run.graphRunnable.nodes = null;
|
||||
}
|
||||
if (client.run.graphRunnable.lc_kwargs) {
|
||||
client.run.graphRunnable.lc_kwargs = null;
|
||||
}
|
||||
if (client.run.graphRunnable.builder?.nodes) {
|
||||
client.run.graphRunnable.builder.nodes = null;
|
||||
graphRunnablePropsToClean.forEach((prop) => {
|
||||
if (client.run.graphRunnable[prop] !== undefined) {
|
||||
client.run.graphRunnable[prop] = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (client.run.graphRunnable.builder) {
|
||||
if (client.run.graphRunnable.builder.nodes !== undefined) {
|
||||
client.run.graphRunnable.builder.nodes = null;
|
||||
}
|
||||
client.run.graphRunnable.builder = null;
|
||||
}
|
||||
|
||||
client.run.graphRunnable = null;
|
||||
}
|
||||
|
||||
const runPropsToClean = [
|
||||
'handlerRegistry',
|
||||
'id',
|
||||
'indexTokenCountMap',
|
||||
'returnContent',
|
||||
'tokenCounter',
|
||||
];
|
||||
|
||||
runPropsToClean.forEach((prop) => {
|
||||
if (client.run[prop] !== undefined) {
|
||||
client.run[prop] = null;
|
||||
}
|
||||
});
|
||||
|
||||
client.run = null;
|
||||
}
|
||||
|
||||
if (client.sendMessage) {
|
||||
client.sendMessage = null;
|
||||
}
|
||||
|
|
@ -339,6 +376,8 @@ function disposeClient(client) {
|
|||
client.options = null;
|
||||
} catch {
|
||||
// Ignore errors during disposal
|
||||
} finally {
|
||||
logger.debug('[disposeClient] Client disposed');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const { getMCPManager, getFlowStateManager } = require('~/config');
|
|||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
|
|
@ -198,7 +199,7 @@ const updateUserPluginsController = async (req, res) => {
|
|||
// 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);
|
||||
const mcpManager = getMCPManager();
|
||||
if (mcpManager) {
|
||||
// Extract server name from pluginKey (format: "mcp_<serverName>")
|
||||
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
||||
|
|
@ -295,10 +296,11 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
|||
}
|
||||
|
||||
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
||||
const mcpManager = getMCPManager(userId);
|
||||
const serverConfig = mcpManager.getRawConfig(serverName) ?? appConfig?.mcpServers?.[serverName];
|
||||
|
||||
if (!mcpManager.getOAuthServers().has(serverName)) {
|
||||
const serverConfig =
|
||||
(await mcpServersRegistry.getServerConfig(serverName, userId)) ??
|
||||
appConfig?.mcpServers?.[serverName];
|
||||
const oauthServers = await mcpServersRegistry.getOAuthServers();
|
||||
if (!oauthServers.has(serverName)) {
|
||||
// this server does not use OAuth, so nothing to do here as well
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const { nanoid } = require('nanoid');
|
||||
const { sendEvent } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Tools, StepTypes, FileContext } = require('librechat-data-provider');
|
||||
const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider');
|
||||
const {
|
||||
EnvVar,
|
||||
Providers,
|
||||
|
|
@ -27,6 +27,13 @@ class ModelEndHandler {
|
|||
this.collectedUsage = collectedUsage;
|
||||
}
|
||||
|
||||
finalize(errorMessage) {
|
||||
if (!errorMessage) {
|
||||
return;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} event
|
||||
* @param {ModelEndData | undefined} data
|
||||
|
|
@ -40,28 +47,56 @@ class ModelEndHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
/** @type {string | undefined} */
|
||||
let errorMessage;
|
||||
try {
|
||||
if (metadata.provider === Providers.GOOGLE || graph.clientOptions?.disableStreaming) {
|
||||
handleToolCalls(data?.output?.tool_calls, metadata, graph);
|
||||
const agentContext = graph.getAgentContext(metadata);
|
||||
const isGoogle = agentContext.provider === Providers.GOOGLE;
|
||||
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
|
||||
if (data?.output?.additional_kwargs?.stop_reason === 'refusal') {
|
||||
const info = { ...data.output.additional_kwargs };
|
||||
errorMessage = JSON.stringify({
|
||||
type: ErrorTypes.REFUSAL,
|
||||
info,
|
||||
});
|
||||
logger.debug(`[ModelEndHandler] Model refused to respond`, {
|
||||
...info,
|
||||
userId: metadata.user_id,
|
||||
messageId: metadata.run_id,
|
||||
conversationId: metadata.thread_id,
|
||||
});
|
||||
}
|
||||
|
||||
const toolCalls = data?.output?.tool_calls;
|
||||
let hasUnprocessedToolCalls = false;
|
||||
if (Array.isArray(toolCalls) && toolCalls.length > 0 && graph?.toolCallStepIds?.has) {
|
||||
try {
|
||||
hasUnprocessedToolCalls = toolCalls.some(
|
||||
(tc) => tc?.id && !graph.toolCallStepIds.has(tc.id),
|
||||
);
|
||||
} catch {
|
||||
hasUnprocessedToolCalls = false;
|
||||
}
|
||||
}
|
||||
if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
|
||||
handleToolCalls(toolCalls, metadata, graph);
|
||||
}
|
||||
|
||||
const usage = data?.output?.usage_metadata;
|
||||
if (!usage) {
|
||||
return;
|
||||
return this.finalize(errorMessage);
|
||||
}
|
||||
if (metadata?.model) {
|
||||
usage.model = metadata.model;
|
||||
const modelName = metadata?.ls_model_name || agentContext.clientOptions?.model;
|
||||
if (modelName) {
|
||||
usage.model = modelName;
|
||||
}
|
||||
|
||||
this.collectedUsage.push(usage);
|
||||
const streamingDisabled = !!(
|
||||
graph.clientOptions?.disableStreaming || graph?.boundModel?.disableStreaming
|
||||
);
|
||||
if (!streamingDisabled) {
|
||||
return;
|
||||
return this.finalize(errorMessage);
|
||||
}
|
||||
if (!data.output.content) {
|
||||
return;
|
||||
return this.finalize(errorMessage);
|
||||
}
|
||||
const stepKey = graph.getStepKey(metadata);
|
||||
const message_id = getMessageId(stepKey, graph) ?? '';
|
||||
|
|
@ -91,10 +126,24 @@ class ModelEndHandler {
|
|||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling model end event:', error);
|
||||
return this.finalize(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Agent Chain helper
|
||||
* @param {string | undefined} [last_agent_id]
|
||||
* @param {string | undefined} [langgraph_node]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function checkIfLastAgent(last_agent_id, langgraph_node) {
|
||||
if (!last_agent_id || !langgraph_node) {
|
||||
return false;
|
||||
}
|
||||
return langgraph_node?.endsWith(last_agent_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default handlers for stream events.
|
||||
* @param {Object} options - The options object.
|
||||
|
|
@ -125,7 +174,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
|||
handle: (event, data, metadata) => {
|
||||
if (data?.stepDetails.type === StepTypes.TOOL_CALLS) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
|
|
@ -154,7 +203,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
|||
handle: (event, data, metadata) => {
|
||||
if (data?.delta.type === StepTypes.TOOL_CALLS) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
|
|
@ -172,7 +221,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
|||
handle: (event, data, metadata) => {
|
||||
if (data?.result != null) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
|
|
@ -188,7 +237,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
|||
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
|
||||
*/
|
||||
handle: (event, data, metadata) => {
|
||||
if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
|
|
@ -204,7 +253,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
|||
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
|
||||
*/
|
||||
handle: (event, data, metadata) => {
|
||||
if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ const { logger } = require('@librechat/data-schemas');
|
|||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
||||
const {
|
||||
sendEvent,
|
||||
createRun,
|
||||
Tokenizer,
|
||||
checkAccess,
|
||||
|
|
@ -12,14 +11,13 @@ const {
|
|||
resolveHeaders,
|
||||
getBalanceConfig,
|
||||
memoryInstructions,
|
||||
formatContentStrings,
|
||||
getTransactionsConfig,
|
||||
createMemoryProcessor,
|
||||
filterMalformedContentParts,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Callback,
|
||||
Providers,
|
||||
GraphEvents,
|
||||
TitleMethod,
|
||||
formatMessage,
|
||||
formatAgentMessages,
|
||||
|
|
@ -38,12 +36,12 @@ const {
|
|||
bedrockInputSchema,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
|
||||
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||
const { createContextHandlers } = require('~/app/clients/prompts');
|
||||
const { checkCapability } = require('~/server/services/Config');
|
||||
const BaseClient = require('~/app/clients/BaseClient');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
|
|
@ -80,8 +78,6 @@ const payloadParser = ({ req, agent, endpoint }) => {
|
|||
return req.body.endpointOption.model_parameters;
|
||||
};
|
||||
|
||||
const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
||||
|
||||
function createTokenCounter(encoding) {
|
||||
return function (message) {
|
||||
const countTokens = (text) => Tokenizer.getTokenCount(text, encoding);
|
||||
|
|
@ -215,7 +211,10 @@ class AgentClient extends BaseClient {
|
|||
const { files, image_urls } = await encodeAndFormat(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.agent.provider,
|
||||
{
|
||||
provider: this.options.agent.provider,
|
||||
endpoint: this.options.endpoint,
|
||||
},
|
||||
VisionModes.agents,
|
||||
);
|
||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
||||
|
|
@ -346,7 +345,7 @@ class AgentClient extends BaseClient {
|
|||
|
||||
if (mcpServers.length > 0) {
|
||||
try {
|
||||
const mcpInstructions = getMCPManager().formatInstructionsForContext(mcpServers);
|
||||
const mcpInstructions = await getMCPManager().formatInstructionsForContext(mcpServers);
|
||||
if (mcpInstructions) {
|
||||
systemContent = [systemContent, mcpInstructions].filter(Boolean).join('\n\n');
|
||||
logger.debug('[AgentClient] Injected MCP instructions for servers:', mcpServers);
|
||||
|
|
@ -613,7 +612,7 @@ class AgentClient extends BaseClient {
|
|||
userMCPAuthMap: opts.userMCPAuthMap,
|
||||
abortController: opts.abortController,
|
||||
});
|
||||
return this.contentParts;
|
||||
return filterMalformedContentParts(this.contentParts);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -766,12 +765,14 @@ class AgentClient extends BaseClient {
|
|||
let run;
|
||||
/** @type {Promise<(TAttachment | null)[] | undefined>} */
|
||||
let memoryPromise;
|
||||
const appConfig = this.options.req.config;
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const transactionsConfig = getTransactionsConfig(appConfig);
|
||||
try {
|
||||
if (!abortController) {
|
||||
abortController = new AbortController();
|
||||
}
|
||||
|
||||
const appConfig = this.options.req.config;
|
||||
/** @type {AppConfig['endpoints']['agents']} */
|
||||
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
|
||||
|
||||
|
|
@ -803,137 +804,81 @@ class AgentClient extends BaseClient {
|
|||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Agent} agent
|
||||
* @param {BaseMessage[]} messages
|
||||
* @param {number} [i]
|
||||
* @param {TMessageContentParts[]} [contentData]
|
||||
* @param {Record<string, number>} [currentIndexCountMap]
|
||||
*/
|
||||
const runAgent = async (agent, _messages, i = 0, contentData = [], _currentIndexCountMap) => {
|
||||
config.configurable.model = agent.model_parameters.model;
|
||||
const currentIndexCountMap = _currentIndexCountMap ?? indexTokenCountMap;
|
||||
if (i > 0) {
|
||||
this.model = agent.model_parameters.model;
|
||||
const runAgents = async (messages) => {
|
||||
const agents = [this.options.agent];
|
||||
if (
|
||||
this.agentConfigs &&
|
||||
this.agentConfigs.size > 0 &&
|
||||
((this.options.agent.edges?.length ?? 0) > 0 ||
|
||||
(await checkCapability(this.options.req, AgentCapabilities.chain)))
|
||||
) {
|
||||
agents.push(...this.agentConfigs.values());
|
||||
}
|
||||
if (i > 0 && config.signal == null) {
|
||||
config.signal = abortController.signal;
|
||||
}
|
||||
if (agent.recursion_limit && typeof agent.recursion_limit === 'number') {
|
||||
config.recursionLimit = agent.recursion_limit;
|
||||
|
||||
if (agents[0].recursion_limit && typeof agents[0].recursion_limit === 'number') {
|
||||
config.recursionLimit = agents[0].recursion_limit;
|
||||
}
|
||||
|
||||
if (
|
||||
agentsEConfig?.maxRecursionLimit &&
|
||||
config.recursionLimit > agentsEConfig?.maxRecursionLimit
|
||||
) {
|
||||
config.recursionLimit = agentsEConfig?.maxRecursionLimit;
|
||||
}
|
||||
config.configurable.agent_id = agent.id;
|
||||
config.configurable.name = agent.name;
|
||||
config.configurable.agent_index = i;
|
||||
const noSystemMessages = noSystemModelRegex.some((regex) =>
|
||||
agent.model_parameters.model.match(regex),
|
||||
);
|
||||
|
||||
const systemMessage = Object.values(agent.toolContextMap ?? {})
|
||||
.join('\n')
|
||||
.trim();
|
||||
// TODO: needs to be added as part of AgentContext initialization
|
||||
// const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
||||
// const noSystemMessages = noSystemModelRegex.some((regex) =>
|
||||
// agent.model_parameters.model.match(regex),
|
||||
// );
|
||||
// if (noSystemMessages === true && systemContent?.length) {
|
||||
// const latestMessageContent = _messages.pop().content;
|
||||
// if (typeof latestMessageContent !== 'string') {
|
||||
// latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
|
||||
// _messages.push(new HumanMessage({ content: latestMessageContent }));
|
||||
// } else {
|
||||
// const text = [systemContent, latestMessageContent].join('\n');
|
||||
// _messages.push(new HumanMessage(text));
|
||||
// }
|
||||
// }
|
||||
// let messages = _messages;
|
||||
// if (agent.useLegacyContent === true) {
|
||||
// messages = formatContentStrings(messages);
|
||||
// }
|
||||
// if (
|
||||
// agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes(
|
||||
// 'prompt-caching',
|
||||
// )
|
||||
// ) {
|
||||
// messages = addCacheControl(messages);
|
||||
// }
|
||||
|
||||
let systemContent = [
|
||||
systemMessage,
|
||||
agent.instructions ?? '',
|
||||
i !== 0 ? (agent.additional_instructions ?? '') : '',
|
||||
]
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
if (noSystemMessages === true) {
|
||||
agent.instructions = undefined;
|
||||
agent.additional_instructions = undefined;
|
||||
} else {
|
||||
agent.instructions = systemContent;
|
||||
agent.additional_instructions = undefined;
|
||||
}
|
||||
|
||||
if (noSystemMessages === true && systemContent?.length) {
|
||||
const latestMessageContent = _messages.pop().content;
|
||||
if (typeof latestMessageContent !== 'string') {
|
||||
latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
|
||||
_messages.push(new HumanMessage({ content: latestMessageContent }));
|
||||
} else {
|
||||
const text = [systemContent, latestMessageContent].join('\n');
|
||||
_messages.push(new HumanMessage(text));
|
||||
}
|
||||
}
|
||||
|
||||
let messages = _messages;
|
||||
if (agent.useLegacyContent === true) {
|
||||
messages = formatContentStrings(messages);
|
||||
}
|
||||
const defaultHeaders =
|
||||
agent.model_parameters?.clientOptions?.defaultHeaders ??
|
||||
agent.model_parameters?.configuration?.defaultHeaders;
|
||||
if (defaultHeaders?.['anthropic-beta']?.includes('prompt-caching')) {
|
||||
messages = addCacheControl(messages);
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
memoryPromise = this.runMemory(messages);
|
||||
}
|
||||
|
||||
/** Resolve request-based headers for Custom Endpoints. Note: if this is added to
|
||||
* non-custom endpoints, needs consideration of varying provider header configs.
|
||||
*/
|
||||
if (agent.model_parameters?.configuration?.defaultHeaders != null) {
|
||||
agent.model_parameters.configuration.defaultHeaders = resolveHeaders({
|
||||
headers: agent.model_parameters.configuration.defaultHeaders,
|
||||
body: config.configurable.requestBody,
|
||||
});
|
||||
}
|
||||
memoryPromise = this.runMemory(messages);
|
||||
|
||||
run = await createRun({
|
||||
agent,
|
||||
req: this.options.req,
|
||||
agents,
|
||||
indexTokenCountMap,
|
||||
runId: this.responseMessageId,
|
||||
signal: abortController.signal,
|
||||
customHandlers: this.options.eventHandlers,
|
||||
requestBody: config.configurable.requestBody,
|
||||
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
throw new Error('Failed to create run');
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
this.run = run;
|
||||
}
|
||||
|
||||
if (contentData.length) {
|
||||
const agentUpdate = {
|
||||
type: ContentTypes.AGENT_UPDATE,
|
||||
[ContentTypes.AGENT_UPDATE]: {
|
||||
index: contentData.length,
|
||||
runId: this.responseMessageId,
|
||||
agentId: agent.id,
|
||||
},
|
||||
};
|
||||
const streamData = {
|
||||
event: GraphEvents.ON_AGENT_UPDATE,
|
||||
data: agentUpdate,
|
||||
};
|
||||
this.options.aggregateContent(streamData);
|
||||
sendEvent(this.options.res, streamData);
|
||||
contentData.push(agentUpdate);
|
||||
run.Graph.contentData = contentData;
|
||||
}
|
||||
|
||||
this.run = run;
|
||||
if (userMCPAuthMap != null) {
|
||||
config.configurable.userMCPAuthMap = userMCPAuthMap;
|
||||
}
|
||||
|
||||
/** @deprecated Agent Chain */
|
||||
config.configurable.last_agent_id = agents[agents.length - 1].id;
|
||||
await run.processStream({ messages }, config, {
|
||||
keepContent: i !== 0,
|
||||
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||
indexTokenCountMap: currentIndexCountMap,
|
||||
maxContextTokens: agent.maxContextTokens,
|
||||
callbacks: {
|
||||
[Callback.TOOL_ERROR]: logToolError,
|
||||
},
|
||||
|
|
@ -942,133 +887,22 @@ class AgentClient extends BaseClient {
|
|||
config.signal = null;
|
||||
};
|
||||
|
||||
await runAgent(this.options.agent, initialMessages);
|
||||
let finalContentStart = 0;
|
||||
if (
|
||||
this.agentConfigs &&
|
||||
this.agentConfigs.size > 0 &&
|
||||
(await checkCapability(this.options.req, AgentCapabilities.chain))
|
||||
) {
|
||||
const windowSize = 5;
|
||||
let latestMessage = initialMessages.pop().content;
|
||||
if (typeof latestMessage !== 'string') {
|
||||
latestMessage = latestMessage[0].text;
|
||||
}
|
||||
let i = 1;
|
||||
let runMessages = [];
|
||||
|
||||
const windowIndexCountMap = {};
|
||||
const windowMessages = initialMessages.slice(-windowSize);
|
||||
let currentIndex = 4;
|
||||
for (let i = initialMessages.length - 1; i >= 0; i--) {
|
||||
windowIndexCountMap[currentIndex] = indexTokenCountMap[i];
|
||||
currentIndex--;
|
||||
if (currentIndex < 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const encoding = this.getEncoding();
|
||||
const tokenCounter = createTokenCounter(encoding);
|
||||
for (const [agentId, agent] of this.agentConfigs) {
|
||||
if (abortController.signal.aborted === true) {
|
||||
break;
|
||||
}
|
||||
const currentRun = await run;
|
||||
|
||||
if (
|
||||
i === this.agentConfigs.size &&
|
||||
config.configurable.hide_sequential_outputs === true
|
||||
) {
|
||||
const content = this.contentParts.filter(
|
||||
(part) => part.type === ContentTypes.TOOL_CALL,
|
||||
);
|
||||
|
||||
this.options.res.write(
|
||||
`event: message\ndata: ${JSON.stringify({
|
||||
event: 'on_content_update',
|
||||
data: {
|
||||
runId: this.responseMessageId,
|
||||
content,
|
||||
},
|
||||
})}\n\n`,
|
||||
);
|
||||
}
|
||||
const _runMessages = currentRun.Graph.getRunMessages();
|
||||
finalContentStart = this.contentParts.length;
|
||||
runMessages = runMessages.concat(_runMessages);
|
||||
const contentData = currentRun.Graph.contentData.slice();
|
||||
const bufferString = getBufferString([new HumanMessage(latestMessage), ...runMessages]);
|
||||
if (i === this.agentConfigs.size) {
|
||||
logger.debug(`SEQUENTIAL AGENTS: Last buffer string:\n${bufferString}`);
|
||||
}
|
||||
try {
|
||||
const contextMessages = [];
|
||||
const runIndexCountMap = {};
|
||||
for (let i = 0; i < windowMessages.length; i++) {
|
||||
const message = windowMessages[i];
|
||||
const messageType = message._getType();
|
||||
if (
|
||||
(!agent.tools || agent.tools.length === 0) &&
|
||||
(messageType === 'tool' || (message.tool_calls?.length ?? 0) > 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
runIndexCountMap[contextMessages.length] = windowIndexCountMap[i];
|
||||
contextMessages.push(message);
|
||||
}
|
||||
const bufferMessage = new HumanMessage(bufferString);
|
||||
runIndexCountMap[contextMessages.length] = tokenCounter(bufferMessage);
|
||||
const currentMessages = [...contextMessages, bufferMessage];
|
||||
await runAgent(agent, currentMessages, i, contentData, runIndexCountMap);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[api/server/controllers/agents/client.js #chatCompletion] Error running agent ${agentId} (${i})`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Note: not implemented */
|
||||
if (config.configurable.hide_sequential_outputs !== true) {
|
||||
finalContentStart = 0;
|
||||
}
|
||||
|
||||
this.contentParts = this.contentParts.filter((part, index) => {
|
||||
// Include parts that are either:
|
||||
// 1. At or after the finalContentStart index
|
||||
// 2. Of type tool_call
|
||||
// 3. Have tool_call_ids property
|
||||
return (
|
||||
index >= finalContentStart || part.type === ContentTypes.TOOL_CALL || part.tool_call_ids
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const transactionsConfig = getTransactionsConfig(appConfig);
|
||||
await this.recordCollectedUsage({
|
||||
context: 'message',
|
||||
balance: balanceConfig,
|
||||
transactions: transactionsConfig,
|
||||
await runAgents(initialMessages);
|
||||
/** @deprecated Agent Chain */
|
||||
if (config.configurable.hide_sequential_outputs) {
|
||||
this.contentParts = this.contentParts.filter((part, index) => {
|
||||
// Include parts that are either:
|
||||
// 1. At or after the finalContentStart index
|
||||
// 2. Of type tool_call
|
||||
// 3. Have tool_call_ids property
|
||||
return (
|
||||
index >= this.contentParts.length - 1 ||
|
||||
part.type === ContentTypes.TOOL_CALL ||
|
||||
part.tool_call_ids
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage',
|
||||
err,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
||||
err,
|
||||
|
|
@ -1083,6 +917,24 @@ class AgentClient extends BaseClient {
|
|||
[ContentTypes.ERROR]: `An error occurred while processing the request${err?.message ? `: ${err.message}` : ''}`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
|
||||
await this.recordCollectedUsage({
|
||||
context: 'message',
|
||||
balance: balanceConfig,
|
||||
transactions: transactionsConfig,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #chatCompletion] Error in cleanup phase',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ jest.mock('@librechat/api', () => ({
|
|||
...jest.requireActual('@librechat/api'),
|
||||
}));
|
||||
|
||||
// Mock getMCPManager
|
||||
const mockFormatInstructions = jest.fn();
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPManager: jest.fn(() => ({
|
||||
formatInstructionsForContext: mockFormatInstructions,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('AgentClient - titleConvo', () => {
|
||||
let client;
|
||||
let mockRun;
|
||||
|
|
@ -1168,6 +1176,200 @@ describe('AgentClient - titleConvo', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('buildMessages with MCP server instructions', () => {
|
||||
let client;
|
||||
let mockReq;
|
||||
let mockRes;
|
||||
let mockAgent;
|
||||
let mockOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset the mock to default behavior
|
||||
mockFormatInstructions.mockResolvedValue(
|
||||
'# MCP Server Instructions\n\nTest MCP instructions here',
|
||||
);
|
||||
|
||||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||
|
||||
// Create mock MCP tools with the delimiter pattern
|
||||
const mockMCPTool1 = new DynamicStructuredTool({
|
||||
name: `tool1${Constants.mcp_delimiter}server1`,
|
||||
description: 'Test MCP tool 1',
|
||||
schema: {},
|
||||
func: async () => 'result',
|
||||
});
|
||||
|
||||
const mockMCPTool2 = new DynamicStructuredTool({
|
||||
name: `tool2${Constants.mcp_delimiter}server2`,
|
||||
description: 'Test MCP tool 2',
|
||||
schema: {},
|
||||
func: async () => 'result',
|
||||
});
|
||||
|
||||
mockAgent = {
|
||||
id: 'agent-123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
provider: EModelEndpoint.openAI,
|
||||
instructions: 'Base agent instructions',
|
||||
model_parameters: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
tools: [mockMCPTool1, mockMCPTool2],
|
||||
};
|
||||
|
||||
mockReq = {
|
||||
user: {
|
||||
id: 'user-123',
|
||||
},
|
||||
body: {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
},
|
||||
config: {},
|
||||
};
|
||||
|
||||
mockRes = {};
|
||||
|
||||
mockOptions = {
|
||||
req: mockReq,
|
||||
res: mockRes,
|
||||
agent: mockAgent,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
};
|
||||
|
||||
client = new AgentClient(mockOptions);
|
||||
client.conversationId = 'convo-123';
|
||||
client.responseMessageId = 'response-123';
|
||||
client.shouldSummarize = false;
|
||||
client.maxContextTokens = 4096;
|
||||
});
|
||||
|
||||
it('should await MCP instructions and not include [object Promise] in agent instructions', async () => {
|
||||
// Set specific return value for this test
|
||||
mockFormatInstructions.mockResolvedValue(
|
||||
'# MCP Server Instructions\n\nUse these tools carefully',
|
||||
);
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Hello',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Base instructions',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Verify formatInstructionsForContext was called with correct server names
|
||||
expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2']);
|
||||
|
||||
// Verify the instructions do NOT contain [object Promise]
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
|
||||
// Verify the instructions DO contain the MCP instructions
|
||||
expect(client.options.agent.instructions).toContain('# MCP Server Instructions');
|
||||
expect(client.options.agent.instructions).toContain('Use these tools carefully');
|
||||
|
||||
// Verify the base instructions are also included
|
||||
expect(client.options.agent.instructions).toContain('Base instructions');
|
||||
});
|
||||
|
||||
it('should handle MCP instructions with ephemeral agent', async () => {
|
||||
// Set specific return value for this test
|
||||
mockFormatInstructions.mockResolvedValue(
|
||||
'# Ephemeral MCP Instructions\n\nSpecial ephemeral instructions',
|
||||
);
|
||||
|
||||
// Set up ephemeral agent with MCP servers
|
||||
mockReq.body.ephemeralAgent = {
|
||||
mcp: ['ephemeral-server1', 'ephemeral-server2'],
|
||||
};
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Test ephemeral',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Ephemeral instructions',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Verify formatInstructionsForContext was called with ephemeral server names
|
||||
expect(mockFormatInstructions).toHaveBeenCalledWith([
|
||||
'ephemeral-server1',
|
||||
'ephemeral-server2',
|
||||
]);
|
||||
|
||||
// Verify no [object Promise] in instructions
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
|
||||
// Verify ephemeral MCP instructions are included
|
||||
expect(client.options.agent.instructions).toContain('# Ephemeral MCP Instructions');
|
||||
expect(client.options.agent.instructions).toContain('Special ephemeral instructions');
|
||||
});
|
||||
|
||||
it('should handle empty MCP instructions gracefully', async () => {
|
||||
// Set empty return value for this test
|
||||
mockFormatInstructions.mockResolvedValue('');
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Hello',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Base instructions only',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Verify the instructions still work without MCP content
|
||||
expect(client.options.agent.instructions).toBe('Base instructions only');
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
});
|
||||
|
||||
it('should handle MCP instructions error gracefully', async () => {
|
||||
// Set error return for this test
|
||||
mockFormatInstructions.mockRejectedValue(new Error('MCP error'));
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Hello',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Base instructions',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Should still have base instructions without MCP content
|
||||
expect(client.options.agent.instructions).toContain('Base instructions');
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMemory method', () => {
|
||||
let client;
|
||||
let mockReq;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const {
|
|||
getAppConfig,
|
||||
} = require('~/server/services/Config');
|
||||
const { getMCPManager } = require('~/config');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
/**
|
||||
* Get all MCP tools available to the user
|
||||
|
|
@ -32,7 +33,7 @@ const getMCPTools = async (req, res) => {
|
|||
const mcpServers = {};
|
||||
|
||||
const cachePromises = configuredServers.map((serverName) =>
|
||||
getMCPServerTools(serverName).then((tools) => ({ serverName, tools })),
|
||||
getMCPServerTools(userId, serverName).then((tools) => ({ serverName, tools })),
|
||||
);
|
||||
const cacheResults = await Promise.all(cachePromises);
|
||||
|
||||
|
|
@ -52,7 +53,7 @@ const getMCPTools = async (req, res) => {
|
|||
|
||||
if (Object.keys(serverTools).length > 0) {
|
||||
// Cache asynchronously without blocking
|
||||
cacheMCPServerTools({ serverName, serverTools }).catch((err) =>
|
||||
cacheMCPServerTools({ userId, serverName, serverTools }).catch((err) =>
|
||||
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
|
||||
);
|
||||
}
|
||||
|
|
@ -65,7 +66,7 @@ const getMCPTools = async (req, res) => {
|
|||
|
||||
// Get server config once
|
||||
const serverConfig = appConfig.mcpConfig[serverName];
|
||||
const rawServerConfig = mcpManager.getRawConfig(serverName);
|
||||
const rawServerConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
|
||||
|
||||
// Initialize server object with all server-level data
|
||||
const server = {
|
||||
|
|
|
|||
|
|
@ -185,8 +185,8 @@ process.on('uncaughtException', (err) => {
|
|||
logger.error('There was an uncaught error:', err);
|
||||
}
|
||||
|
||||
if (err.message.includes('abort')) {
|
||||
logger.warn('There was an uncatchable AbortController error.');
|
||||
if (err.message && err.message?.toLowerCase()?.includes('abort')) {
|
||||
logger.warn('There was an uncatchable abort error.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
502
api/server/routes/__tests__/convos.spec.js
Normal file
502
api/server/routes/__tests__/convos.spec.js
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
sleep: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
isEnabled: jest.fn(),
|
||||
createAxiosInstance: jest.fn(() => ({
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
})),
|
||||
logAxiosError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
createModels: jest.fn(() => ({
|
||||
User: {},
|
||||
Conversation: {},
|
||||
Message: {},
|
||||
SharedLink: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/Conversation', () => ({
|
||||
getConvosByCursor: jest.fn(),
|
||||
getConvo: jest.fn(),
|
||||
deleteConvos: jest.fn(),
|
||||
saveConvo: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/ToolCall', () => ({
|
||||
deleteToolCalls: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
deleteAllSharedLinks: jest.fn(),
|
||||
deleteConvoSharedLink: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
|
||||
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
createImportLimiters: jest.fn(() => ({
|
||||
importIpLimiter: (req, res, next) => next(),
|
||||
importUserLimiter: (req, res, next) => next(),
|
||||
})),
|
||||
createForkLimiters: jest.fn(() => ({
|
||||
forkIpLimiter: (req, res, next) => next(),
|
||||
forkUserLimiter: (req, res, next) => next(),
|
||||
})),
|
||||
configMiddleware: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils/import/fork', () => ({
|
||||
forkConversation: jest.fn(),
|
||||
duplicateConversation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils/import', () => ({
|
||||
importConversations: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache/getLogStores', () => jest.fn());
|
||||
|
||||
jest.mock('~/server/routes/files/multer', () => ({
|
||||
storage: {},
|
||||
importFileFilter: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('multer', () => {
|
||||
return jest.fn(() => ({
|
||||
single: jest.fn(() => (req, res, next) => {
|
||||
req.file = { path: '/tmp/test-file.json' };
|
||||
next();
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
CacheKeys: {
|
||||
GEN_TITLE: 'GEN_TITLE',
|
||||
},
|
||||
EModelEndpoint: {
|
||||
azureAssistants: 'azureAssistants',
|
||||
assistants: 'assistants',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Endpoints/azureAssistants', () => ({
|
||||
initializeClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Endpoints/assistants', () => ({
|
||||
initializeClient: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Convos Routes', () => {
|
||||
let app;
|
||||
let convosRouter;
|
||||
const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
|
||||
const { deleteConvos } = require('~/models/Conversation');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
|
||||
beforeAll(() => {
|
||||
convosRouter = require('../convos');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
|
||||
/** Mock authenticated user */
|
||||
app.use((req, res, next) => {
|
||||
req.user = { id: 'test-user-123' };
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/api/convos', convosRouter);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('DELETE /all', () => {
|
||||
it('should delete all conversations, tool calls, and shared links for a user', async () => {
|
||||
const mockDbResponse = {
|
||||
deletedCount: 5,
|
||||
message: 'All conversations deleted successfully',
|
||||
};
|
||||
|
||||
deleteConvos.mockResolvedValue(mockDbResponse);
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
|
||||
deleteAllSharedLinks.mockResolvedValue({
|
||||
message: 'All shared links deleted successfully',
|
||||
deletedCount: 3,
|
||||
});
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockDbResponse);
|
||||
|
||||
/** Verify deleteConvos was called with correct userId */
|
||||
expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {});
|
||||
expect(deleteConvos).toHaveBeenCalledTimes(1);
|
||||
|
||||
/** Verify deleteToolCalls was called with correct userId */
|
||||
expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123');
|
||||
expect(deleteToolCalls).toHaveBeenCalledTimes(1);
|
||||
|
||||
/** Verify deleteAllSharedLinks was called with correct userId */
|
||||
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
|
||||
expect(deleteAllSharedLinks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call deleteAllSharedLinks even when no conversations exist', async () => {
|
||||
const mockDbResponse = {
|
||||
deletedCount: 0,
|
||||
message: 'No conversations to delete',
|
||||
};
|
||||
|
||||
deleteConvos.mockResolvedValue(mockDbResponse);
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
|
||||
deleteAllSharedLinks.mockResolvedValue({
|
||||
message: 'All shared links deleted successfully',
|
||||
deletedCount: 0,
|
||||
});
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
|
||||
});
|
||||
|
||||
it('should return 500 if deleteConvos fails', async () => {
|
||||
const errorMessage = 'Database connection error';
|
||||
deleteConvos.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.text).toBe('Error clearing conversations');
|
||||
|
||||
/** Verify error was logged */
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
expect(logger.error).toHaveBeenCalledWith('Error clearing conversations', expect.any(Error));
|
||||
});
|
||||
|
||||
it('should return 500 if deleteToolCalls fails', async () => {
|
||||
deleteConvos.mockResolvedValue({ deletedCount: 5 });
|
||||
deleteToolCalls.mockRejectedValue(new Error('Tool calls deletion failed'));
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.text).toBe('Error clearing conversations');
|
||||
});
|
||||
|
||||
it('should return 500 if deleteAllSharedLinks fails', async () => {
|
||||
deleteConvos.mockResolvedValue({ deletedCount: 5 });
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
|
||||
deleteAllSharedLinks.mockRejectedValue(new Error('Shared links deletion failed'));
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.text).toBe('Error clearing conversations');
|
||||
});
|
||||
|
||||
it('should handle multiple users independently', async () => {
|
||||
/** First user */
|
||||
deleteConvos.mockResolvedValue({ deletedCount: 3 });
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 5 });
|
||||
deleteAllSharedLinks.mockResolvedValue({ deletedCount: 2 });
|
||||
|
||||
let response = await request(app).delete('/api/convos/all');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
/** Second user (simulate different user by modifying middleware) */
|
||||
const app2 = express();
|
||||
app2.use(express.json());
|
||||
app2.use((req, res, next) => {
|
||||
req.user = { id: 'test-user-456' };
|
||||
next();
|
||||
});
|
||||
app2.use('/api/convos', require('../convos'));
|
||||
|
||||
deleteConvos.mockResolvedValue({ deletedCount: 7 });
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 12 });
|
||||
deleteAllSharedLinks.mockResolvedValue({ deletedCount: 4 });
|
||||
|
||||
response = await request(app2).delete('/api/convos/all');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-456');
|
||||
});
|
||||
|
||||
it('should execute deletions in correct sequence', async () => {
|
||||
const executionOrder = [];
|
||||
|
||||
deleteConvos.mockImplementation(() => {
|
||||
executionOrder.push('deleteConvos');
|
||||
return Promise.resolve({ deletedCount: 5 });
|
||||
});
|
||||
|
||||
deleteToolCalls.mockImplementation(() => {
|
||||
executionOrder.push('deleteToolCalls');
|
||||
return Promise.resolve({ deletedCount: 10 });
|
||||
});
|
||||
|
||||
deleteAllSharedLinks.mockImplementation(() => {
|
||||
executionOrder.push('deleteAllSharedLinks');
|
||||
return Promise.resolve({ deletedCount: 3 });
|
||||
});
|
||||
|
||||
await request(app).delete('/api/convos/all');
|
||||
|
||||
/** Verify all three functions were called */
|
||||
expect(executionOrder).toEqual(['deleteConvos', 'deleteToolCalls', 'deleteAllSharedLinks']);
|
||||
});
|
||||
|
||||
it('should maintain data integrity by cleaning up shared links when conversations are deleted', async () => {
|
||||
/** This test ensures that orphaned shared links are prevented */
|
||||
const mockConvosDeleted = { deletedCount: 10 };
|
||||
const mockToolCallsDeleted = { deletedCount: 15 };
|
||||
const mockSharedLinksDeleted = {
|
||||
message: 'All shared links deleted successfully',
|
||||
deletedCount: 8,
|
||||
};
|
||||
|
||||
deleteConvos.mockResolvedValue(mockConvosDeleted);
|
||||
deleteToolCalls.mockResolvedValue(mockToolCallsDeleted);
|
||||
deleteAllSharedLinks.mockResolvedValue(mockSharedLinksDeleted);
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
/** Verify that shared links cleanup was called for the same user */
|
||||
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
|
||||
|
||||
/** Verify no shared links remain for deleted conversations */
|
||||
expect(deleteAllSharedLinks).toHaveBeenCalledAfter(deleteConvos);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /', () => {
|
||||
it('should delete a single conversation, tool calls, and associated shared links', async () => {
|
||||
const mockConversationId = 'conv-123';
|
||||
const mockDbResponse = {
|
||||
deletedCount: 1,
|
||||
message: 'Conversation deleted successfully',
|
||||
};
|
||||
|
||||
deleteConvos.mockResolvedValue(mockDbResponse);
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 3 });
|
||||
deleteConvoSharedLink.mockResolvedValue({
|
||||
message: 'Shared links deleted successfully',
|
||||
deletedCount: 1,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockDbResponse);
|
||||
|
||||
/** Verify deleteConvos was called with correct parameters */
|
||||
expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {
|
||||
conversationId: mockConversationId,
|
||||
});
|
||||
|
||||
/** Verify deleteToolCalls was called */
|
||||
expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123', mockConversationId);
|
||||
|
||||
/** Verify deleteConvoSharedLink was called */
|
||||
expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId);
|
||||
});
|
||||
|
||||
it('should not call deleteConvoSharedLink when no conversationId provided', async () => {
|
||||
deleteConvos.mockResolvedValue({ deletedCount: 0 });
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/convos')
|
||||
.send({
|
||||
arg: {
|
||||
source: 'button',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(deleteConvoSharedLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle deletion of conversation without shared links', async () => {
|
||||
const mockConversationId = 'conv-no-shares';
|
||||
|
||||
deleteConvos.mockResolvedValue({ deletedCount: 1 });
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
|
||||
deleteConvoSharedLink.mockResolvedValue({
|
||||
message: 'Shared links deleted successfully',
|
||||
deletedCount: 0,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId);
|
||||
});
|
||||
|
||||
it('should return 400 when no parameters provided', async () => {
|
||||
const response = await request(app).delete('/api/convos').send({
|
||||
arg: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'no parameters provided' });
|
||||
expect(deleteConvos).not.toHaveBeenCalled();
|
||||
expect(deleteConvoSharedLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 500 if deleteConvoSharedLink fails', async () => {
|
||||
const mockConversationId = 'conv-error';
|
||||
|
||||
deleteConvos.mockResolvedValue({ deletedCount: 1 });
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 2 });
|
||||
deleteConvoSharedLink.mockRejectedValue(new Error('Failed to delete shared links'));
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.text).toBe('Error clearing conversations');
|
||||
});
|
||||
|
||||
it('should execute deletions in correct sequence for single conversation', async () => {
|
||||
const mockConversationId = 'conv-sequence';
|
||||
const executionOrder = [];
|
||||
|
||||
deleteConvos.mockImplementation(() => {
|
||||
executionOrder.push('deleteConvos');
|
||||
return Promise.resolve({ deletedCount: 1 });
|
||||
});
|
||||
|
||||
deleteToolCalls.mockImplementation(() => {
|
||||
executionOrder.push('deleteToolCalls');
|
||||
return Promise.resolve({ deletedCount: 2 });
|
||||
});
|
||||
|
||||
deleteConvoSharedLink.mockImplementation(() => {
|
||||
executionOrder.push('deleteConvoSharedLink');
|
||||
return Promise.resolve({ deletedCount: 1 });
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.delete('/api/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(executionOrder).toEqual(['deleteConvos', 'deleteToolCalls', 'deleteConvoSharedLink']);
|
||||
});
|
||||
|
||||
it('should prevent orphaned shared links when deleting single conversation', async () => {
|
||||
const mockConversationId = 'conv-with-shares';
|
||||
|
||||
deleteConvos.mockResolvedValue({ deletedCount: 1 });
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 4 });
|
||||
deleteConvoSharedLink.mockResolvedValue({
|
||||
message: 'Shared links deleted successfully',
|
||||
deletedCount: 2,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
/** Verify shared links were deleted for the specific conversation */
|
||||
expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId);
|
||||
|
||||
/** Verify it was called after the conversation was deleted */
|
||||
expect(deleteConvoSharedLink).toHaveBeenCalledAfter(deleteConvos);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Custom Jest matcher to verify function call order
|
||||
*/
|
||||
expect.extend({
|
||||
toHaveBeenCalledAfter(received, other) {
|
||||
const receivedCalls = received.mock.invocationCallOrder;
|
||||
const otherCalls = other.mock.invocationCallOrder;
|
||||
|
||||
if (receivedCalls.length === 0) {
|
||||
return {
|
||||
pass: false,
|
||||
message: () =>
|
||||
`Expected ${received.getMockName()} to have been called after ${other.getMockName()}, but ${received.getMockName()} was never called`,
|
||||
};
|
||||
}
|
||||
|
||||
if (otherCalls.length === 0) {
|
||||
return {
|
||||
pass: false,
|
||||
message: () =>
|
||||
`Expected ${received.getMockName()} to have been called after ${other.getMockName()}, but ${other.getMockName()} was never called`,
|
||||
};
|
||||
}
|
||||
|
||||
const lastReceivedCall = receivedCalls[receivedCalls.length - 1];
|
||||
const firstOtherCall = otherCalls[0];
|
||||
|
||||
const pass = lastReceivedCall > firstOtherCall;
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected ${received.getMockName()} not to have been called after ${other.getMockName()}`
|
||||
: `Expected ${received.getMockName()} to have been called after ${other.getMockName()}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -15,6 +15,10 @@ jest.mock('@librechat/api', () => ({
|
|||
storeTokens: jest.fn(),
|
||||
},
|
||||
getUserMCPAuthMap: jest.fn(),
|
||||
mcpServersRegistry: {
|
||||
getServerConfig: jest.fn(),
|
||||
getOAuthServers: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
|
|
@ -47,6 +51,7 @@ jest.mock('~/models', () => ({
|
|||
jest.mock('~/server/services/Config', () => ({
|
||||
setCachedTools: jest.fn(),
|
||||
getCachedTools: jest.fn(),
|
||||
getMCPServerTools: jest.fn(),
|
||||
loadCustomConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -114,7 +119,7 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
|
||||
describe('GET /:serverName/oauth/initiate', () => {
|
||||
const { MCPOAuthHandler } = require('@librechat/api');
|
||||
const { MCPOAuthHandler, mcpServersRegistry } = require('@librechat/api');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
it('should initiate OAuth flow successfully', async () => {
|
||||
|
|
@ -127,13 +132,9 @@ describe('MCP Routes', () => {
|
|||
}),
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
|
||||
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
|
||||
authorizationUrl: 'https://oauth.example.com/auth',
|
||||
|
|
@ -287,7 +288,9 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
|
||||
it('should handle OAuth callback successfully', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
|
@ -306,6 +309,7 @@ describe('MCP Routes', () => {
|
|||
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
|
|
@ -320,7 +324,6 @@ describe('MCP Routes', () => {
|
|||
};
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
|
|
@ -378,7 +381,9 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
|
||||
it('should handle system-level OAuth completion', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
|
@ -397,14 +402,10 @@ describe('MCP Routes', () => {
|
|||
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
code: 'test-auth-code',
|
||||
state: 'test-flow-id',
|
||||
|
|
@ -416,7 +417,9 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
|
||||
it('should handle reconnection failure after OAuth', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
|
@ -435,12 +438,12 @@ describe('MCP Routes', () => {
|
|||
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
|
|
@ -460,6 +463,7 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
|
||||
it('should redirect to error page if token storage fails', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
|
|
@ -479,6 +483,7 @@ describe('MCP Routes', () => {
|
|||
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockRejectedValue(new Error('store failed'));
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
|
|
@ -496,6 +501,108 @@ describe('MCP Routes', () => {
|
|||
expect(response.headers.location).toBe('/oauth/error?error=callback_failed');
|
||||
expect(mockMcpManager.getUserConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use original flow state credentials when storing tokens', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn(),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const clientInfo = {
|
||||
client_id: 'client123',
|
||||
client_secret: 'client_secret',
|
||||
};
|
||||
const flowState = {
|
||||
serverName: 'test-server',
|
||||
userId: 'test-user-id',
|
||||
metadata: { toolFlowId: 'tool-flow-123', serverUrl: 'http://example.com' },
|
||||
clientInfo: clientInfo,
|
||||
codeVerifier: 'test-verifier',
|
||||
status: 'PENDING',
|
||||
};
|
||||
const mockTokens = {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
};
|
||||
|
||||
// First call checks idempotency (status PENDING = not completed)
|
||||
// Second call retrieves flow state for processing
|
||||
mockFlowManager.getFlowState
|
||||
.mockResolvedValueOnce({ status: 'PENDING' })
|
||||
.mockResolvedValueOnce(flowState);
|
||||
|
||||
MCPOAuthHandler.getFlowState.mockResolvedValue(flowState);
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const mockUserConnection = {
|
||||
fetchTools: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getOAuthReconnectionManager = jest.fn().mockReturnValue({
|
||||
clearReconnection: jest.fn(),
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
code: 'test-auth-code',
|
||||
state: 'test-flow-id',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
|
||||
|
||||
// Verify storeTokens was called with ORIGINAL flow state credentials
|
||||
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'test-user-id',
|
||||
serverName: 'test-server',
|
||||
tokens: mockTokens,
|
||||
clientInfo: clientInfo, // Uses original flow state, not any "updated" credentials
|
||||
metadata: flowState.metadata,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent duplicate token exchange with idempotency check', async () => {
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn(),
|
||||
};
|
||||
|
||||
// Flow is already completed
|
||||
mockFlowManager.getFlowState.mockResolvedValue({
|
||||
status: 'COMPLETED',
|
||||
serverName: 'test-server',
|
||||
userId: 'test-user-id',
|
||||
});
|
||||
|
||||
MCPOAuthHandler.getFlowState.mockResolvedValue({
|
||||
status: 'COMPLETED',
|
||||
serverName: 'test-server',
|
||||
userId: 'test-user-id',
|
||||
});
|
||||
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
code: 'test-auth-code',
|
||||
state: 'test-flow-id',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
|
||||
|
||||
// Verify completeOAuthFlow was NOT called (prevented duplicate)
|
||||
expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
|
||||
expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /oauth/tokens/:flowId', () => {
|
||||
|
|
@ -729,12 +836,14 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
|
||||
describe('POST /:serverName/reinitialize', () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
it('should return 404 when server is not found in configuration', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue(null),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue(null);
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
|
|
@ -749,9 +858,6 @@ describe('MCP Routes', () => {
|
|||
|
||||
it('should handle OAuth requirement during reinitialize', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: {},
|
||||
}),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
mcpConfigs: {},
|
||||
getUserConnection: jest.fn().mockImplementation(async ({ oauthStart }) => {
|
||||
|
|
@ -762,6 +868,9 @@ describe('MCP Routes', () => {
|
|||
}),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({
|
||||
customUserVars: {},
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
|
|
@ -787,12 +896,12 @@ describe('MCP Routes', () => {
|
|||
|
||||
it('should return 500 when reinitialize fails with non-OAuth error', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
mcpConfigs: {},
|
||||
getUserConnection: jest.fn().mockRejectedValue(new Error('Connection failed')),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
|
|
@ -808,11 +917,12 @@ describe('MCP Routes', () => {
|
|||
|
||||
it('should return 500 when unexpected error occurs', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockImplementation(() => {
|
||||
throw new Error('Config loading failed');
|
||||
}),
|
||||
disconnectUserConnection: jest.fn(),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockImplementation(() => {
|
||||
throw new Error('Config loading failed');
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
||||
|
|
@ -845,11 +955,11 @@ describe('MCP Routes', () => {
|
|||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({ endpoint: 'http://test-server.com' }),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({ endpoint: 'http://test-server.com' });
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
|
|
@ -890,16 +1000,16 @@ describe('MCP Routes', () => {
|
|||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
endpoint: 'http://test-server.com',
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
},
|
||||
}),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({
|
||||
endpoint: 'http://test-server.com',
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
},
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
|
|
@ -1104,17 +1214,17 @@ describe('MCP Routes', () => {
|
|||
|
||||
describe('GET /:serverName/auth-values', () => {
|
||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
it('should return auth value flags for server', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
SECRET_TOKEN: 'another-env-var',
|
||||
},
|
||||
}),
|
||||
};
|
||||
const mockMcpManager = {};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
SECRET_TOKEN: 'another-env-var',
|
||||
},
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
getUserPluginAuthValue.mockResolvedValueOnce('some-api-key-value').mockResolvedValueOnce('');
|
||||
|
||||
|
|
@ -1134,10 +1244,9 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
|
||||
it('should return 404 when server is not found in configuration', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
const mockMcpManager = {};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue(null);
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/non-existent-server/auth-values');
|
||||
|
|
@ -1149,14 +1258,13 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
|
||||
it('should handle errors when checking auth values', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
},
|
||||
}),
|
||||
};
|
||||
const mockMcpManager = {};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
},
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
getUserPluginAuthValue.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
|
|
@ -1173,12 +1281,11 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
|
||||
it('should return 500 when auth values check throws unexpected error', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockImplementation(() => {
|
||||
throw new Error('Config loading failed');
|
||||
}),
|
||||
};
|
||||
const mockMcpManager = {};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockImplementation(() => {
|
||||
throw new Error('Config loading failed');
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
|
|
@ -1188,12 +1295,11 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
|
||||
it('should handle customUserVars that is not an object', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: 'not-an-object',
|
||||
}),
|
||||
};
|
||||
const mockMcpManager = {};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({
|
||||
customUserVars: 'not-an-object',
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
|
|
@ -1220,7 +1326,7 @@ describe('MCP Routes', () => {
|
|||
|
||||
describe('GET /:serverName/oauth/callback - Edge Cases', () => {
|
||||
it('should handle OAuth callback without toolFlowId (falsy toolFlowId)', async () => {
|
||||
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
|
||||
const { MCPOAuthHandler, MCPTokenStorage, mcpServersRegistry } = require('@librechat/api');
|
||||
const mockTokens = {
|
||||
access_token: 'edge-access-token',
|
||||
refresh_token: 'edge-refresh-token',
|
||||
|
|
@ -1238,9 +1344,12 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
MCPOAuthHandler.completeOAuthFlow = jest.fn().mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
|
|
@ -1248,7 +1357,6 @@ describe('MCP Routes', () => {
|
|||
getUserConnection: jest.fn().mockResolvedValue({
|
||||
fetchTools: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
|
|
@ -1263,7 +1371,7 @@ describe('MCP Routes', () => {
|
|||
it('should handle null cached tools in OAuth callback (triggers || {} fallback)', async () => {
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
getCachedTools.mockResolvedValue(null);
|
||||
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
|
||||
const { MCPOAuthHandler, MCPTokenStorage, mcpServersRegistry } = require('@librechat/api');
|
||||
const mockTokens = {
|
||||
access_token: 'edge-access-token',
|
||||
refresh_token: 'edge-refresh-token',
|
||||
|
|
@ -1289,6 +1397,7 @@ describe('MCP Routes', () => {
|
|||
});
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockResolvedValue({
|
||||
|
|
@ -1296,7 +1405,6 @@ describe('MCP Routes', () => {
|
|||
.fn()
|
||||
.mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
|
||||
}),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ const {
|
|||
PermissionTypes,
|
||||
actionDelimiter,
|
||||
removeNullishValues,
|
||||
validateActionDomain,
|
||||
validateAndParseOpenAPISpec,
|
||||
} = require('librechat-data-provider');
|
||||
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
|
||||
const { findAccessibleResources } = require('~/server/services/PermissionService');
|
||||
|
|
@ -83,6 +85,32 @@ router.post(
|
|||
|
||||
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
|
||||
const appConfig = req.config;
|
||||
|
||||
// SECURITY: Validate the OpenAPI spec and extract the server URL
|
||||
if (metadata.raw_spec) {
|
||||
const validationResult = validateAndParseOpenAPISpec(metadata.raw_spec);
|
||||
if (!validationResult.status || !validationResult.serverUrl) {
|
||||
return res.status(400).json({
|
||||
message: validationResult.message || 'Invalid OpenAPI specification',
|
||||
});
|
||||
}
|
||||
|
||||
// SECURITY: Validate the client-provided domain matches the spec's server URL domain
|
||||
// This prevents SSRF attacks where an attacker provides a whitelisted domain
|
||||
// but uses a different (potentially internal) URL in the raw_spec
|
||||
const domainValidation = validateActionDomain(metadata.domain, validationResult.serverUrl);
|
||||
if (!domainValidation.isValid) {
|
||||
logger.warn(`Domain mismatch detected: ${domainValidation.message}`, {
|
||||
userId: req.user.id,
|
||||
agent_id,
|
||||
});
|
||||
return res.status(400).json({
|
||||
message:
|
||||
'Domain mismatch: The domain in the OpenAPI spec does not match the provided domain',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isDomainAllowed = await isActionDomainAllowed(
|
||||
metadata.domain,
|
||||
appConfig?.actions?.allowedDomains,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const { getAppConfig } = require('~/server/services/Config/app');
|
|||
const { getProjectByName } = require('~/models/Project');
|
||||
const { getMCPManager } = require('~/config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
const router = express.Router();
|
||||
const emailLoginEnabled =
|
||||
|
|
@ -125,7 +126,7 @@ router.get('/', async function (req, res) {
|
|||
payload.minPasswordLength = minPasswordLength;
|
||||
}
|
||||
|
||||
const getMCPServers = () => {
|
||||
const getMCPServers = async () => {
|
||||
try {
|
||||
if (appConfig?.mcpConfig == null) {
|
||||
return;
|
||||
|
|
@ -134,9 +135,8 @@ router.get('/', async function (req, res) {
|
|||
if (!mcpManager) {
|
||||
return;
|
||||
}
|
||||
const mcpServers = mcpManager.getAllServers();
|
||||
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
|
||||
if (!mcpServers) return;
|
||||
const oauthServers = mcpManager.getOAuthServers();
|
||||
for (const serverName in mcpServers) {
|
||||
if (!payload.mcpServers) {
|
||||
payload.mcpServers = {};
|
||||
|
|
@ -145,7 +145,7 @@ router.get('/', async function (req, res) {
|
|||
payload.mcpServers[serverName] = removeNullishValues({
|
||||
startup: serverConfig?.startup,
|
||||
chatMenu: serverConfig?.chatMenu,
|
||||
isOAuth: oauthServers?.has(serverName),
|
||||
isOAuth: serverConfig.requiresOAuth,
|
||||
customUserVars: serverConfig?.customUserVars,
|
||||
});
|
||||
}
|
||||
|
|
@ -154,7 +154,7 @@ router.get('/', async function (req, res) {
|
|||
}
|
||||
};
|
||||
|
||||
getMCPServers();
|
||||
await getMCPServers();
|
||||
const webSearchConfig = appConfig?.webSearch;
|
||||
if (
|
||||
webSearchConfig != null &&
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const {
|
|||
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
||||
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
|
||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||
const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const { importConversations } = require('~/server/utils/import');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
|
|
@ -124,7 +125,10 @@ router.delete('/', async (req, res) => {
|
|||
|
||||
try {
|
||||
const dbResponse = await deleteConvos(req.user.id, filter);
|
||||
await deleteToolCalls(req.user.id, filter.conversationId);
|
||||
if (filter.conversationId) {
|
||||
await deleteToolCalls(req.user.id, filter.conversationId);
|
||||
await deleteConvoSharedLink(req.user.id, filter.conversationId);
|
||||
}
|
||||
res.status(201).json(dbResponse);
|
||||
} catch (error) {
|
||||
logger.error('Error clearing conversations', error);
|
||||
|
|
@ -136,6 +140,7 @@ router.delete('/all', async (req, res) => {
|
|||
try {
|
||||
const dbResponse = await deleteConvos(req.user.id, {});
|
||||
await deleteToolCalls(req.user.id);
|
||||
await deleteAllSharedLinks(req.user.id);
|
||||
res.status(201).json(dbResponse);
|
||||
} catch (error) {
|
||||
logger.error('Error clearing conversations', error);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ const path = require('path');
|
|||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { sanitizeFilename } = require('@librechat/api');
|
||||
const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider');
|
||||
const {
|
||||
mergeFileConfig,
|
||||
getEndpointFileConfig,
|
||||
fileConfig: defaultFileConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
|
|
@ -53,12 +57,14 @@ const createFileFilter = (customFileConfig) => {
|
|||
}
|
||||
|
||||
const endpoint = req.body.endpoint;
|
||||
const supportedTypes =
|
||||
customFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes ??
|
||||
customFileConfig?.endpoints?.default.supportedMimeTypes ??
|
||||
defaultFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes;
|
||||
const endpointType = req.body.endpointType;
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
fileConfig: customFileConfig,
|
||||
endpoint,
|
||||
endpointType,
|
||||
});
|
||||
|
||||
if (!defaultFileConfig.checkType(file.mimetype, supportedTypes)) {
|
||||
if (!defaultFileConfig.checkType(file.mimetype, endpointFileConfig.supportedMimeTypes)) {
|
||||
return cb(new Error('Unsupported file type: ' + file.mimetype), false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const {
|
|||
MCPOAuthHandler,
|
||||
MCPTokenStorage,
|
||||
getUserMCPAuthMap,
|
||||
mcpServersRegistry,
|
||||
} = require('@librechat/api');
|
||||
const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
|
||||
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
|
||||
|
|
@ -61,11 +62,12 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
|
|||
return res.status(400).json({ error: 'Invalid flow state' });
|
||||
}
|
||||
|
||||
const oauthHeaders = await getOAuthHeaders(serverName, userId);
|
||||
const { authorizationUrl, flowId: oauthFlowId } = await MCPOAuthHandler.initiateOAuthFlow(
|
||||
serverName,
|
||||
serverUrl,
|
||||
userId,
|
||||
getOAuthHeaders(serverName),
|
||||
oauthHeaders,
|
||||
oauthConfig,
|
||||
);
|
||||
|
||||
|
|
@ -132,13 +134,19 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
|||
hasCodeVerifier: !!flowState.codeVerifier,
|
||||
});
|
||||
|
||||
/** Check if this flow has already been completed (idempotency protection) */
|
||||
const currentFlowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||
if (currentFlowState?.status === 'COMPLETED') {
|
||||
logger.warn('[MCP OAuth] Flow already completed, preventing duplicate token exchange', {
|
||||
flowId,
|
||||
serverName,
|
||||
});
|
||||
return res.redirect(`/oauth/success?serverName=${encodeURIComponent(serverName)}`);
|
||||
}
|
||||
|
||||
logger.debug('[MCP OAuth] Completing OAuth flow');
|
||||
const tokens = await MCPOAuthHandler.completeOAuthFlow(
|
||||
flowId,
|
||||
code,
|
||||
flowManager,
|
||||
getOAuthHeaders(serverName),
|
||||
);
|
||||
const oauthHeaders = await getOAuthHeaders(serverName, flowState.userId);
|
||||
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager, oauthHeaders);
|
||||
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
|
||||
|
||||
/** Persist tokens immediately so reconnection uses fresh credentials */
|
||||
|
|
@ -205,6 +213,7 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
|||
|
||||
const tools = await userConnection.fetchTools();
|
||||
await updateMCPServerTools({
|
||||
userId: flowState.userId,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
|
|
@ -355,7 +364,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
|
|||
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
|
||||
|
||||
const mcpManager = getMCPManager();
|
||||
const serverConfig = mcpManager.getRawConfig(serverName);
|
||||
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, user.id);
|
||||
if (!serverConfig) {
|
||||
return res.status(404).json({
|
||||
error: `MCP server '${serverName}' not found in configuration`,
|
||||
|
|
@ -504,8 +513,7 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
|
|||
return res.status(401).json({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const mcpManager = getMCPManager();
|
||||
const serverConfig = mcpManager.getRawConfig(serverName);
|
||||
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, user.id);
|
||||
if (!serverConfig) {
|
||||
return res.status(404).json({
|
||||
error: `MCP server '${serverName}' not found in configuration`,
|
||||
|
|
@ -544,9 +552,8 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
function getOAuthHeaders(serverName) {
|
||||
const mcpManager = getMCPManager();
|
||||
const serverConfig = mcpManager.getRawConfig(serverName);
|
||||
async function getOAuthHeaders(serverName, userId) {
|
||||
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
|
||||
return serverConfig?.oauth_headers ?? {};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const express = require('express');
|
||||
const { unescapeLaTeX } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
const {
|
||||
|
|
@ -134,17 +135,32 @@ router.post('/artifact/:messageId', async (req, res) => {
|
|||
return res.status(400).json({ error: 'Artifact index out of bounds' });
|
||||
}
|
||||
|
||||
// Unescape LaTeX preprocessing done by the frontend
|
||||
// The frontend escapes $ signs for display, but the database has unescaped versions
|
||||
const unescapedOriginal = unescapeLaTeX(original);
|
||||
const unescapedUpdated = unescapeLaTeX(updated);
|
||||
|
||||
const targetArtifact = artifacts[index];
|
||||
let updatedText = null;
|
||||
|
||||
if (targetArtifact.source === 'content') {
|
||||
const part = message.content[targetArtifact.partIndex];
|
||||
updatedText = replaceArtifactContent(part.text, targetArtifact, original, updated);
|
||||
updatedText = replaceArtifactContent(
|
||||
part.text,
|
||||
targetArtifact,
|
||||
unescapedOriginal,
|
||||
unescapedUpdated,
|
||||
);
|
||||
if (updatedText) {
|
||||
part.text = updatedText;
|
||||
}
|
||||
} else {
|
||||
updatedText = replaceArtifactContent(message.text, targetArtifact, original, updated);
|
||||
updatedText = replaceArtifactContent(
|
||||
message.text,
|
||||
targetArtifact,
|
||||
unescapedOriginal,
|
||||
unescapedUpdated,
|
||||
);
|
||||
if (updatedText) {
|
||||
message.text = updatedText;
|
||||
}
|
||||
|
|
|
|||
10
api/server/services/Config/__tests__/getCachedTools.spec.js
Normal file
10
api/server/services/Config/__tests__/getCachedTools.spec.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
const { ToolCacheKeys } = require('../getCachedTools');
|
||||
|
||||
describe('getCachedTools - Cache Isolation Security', () => {
|
||||
describe('ToolCacheKeys.MCP_SERVER', () => {
|
||||
it('should generate cache keys that include userId', () => {
|
||||
const key = ToolCacheKeys.MCP_SERVER('user123', 'github');
|
||||
expect(key).toBe('tools:mcp:user123:github');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,24 +7,25 @@ const getLogStores = require('~/cache/getLogStores');
|
|||
const ToolCacheKeys = {
|
||||
/** Global tools available to all users */
|
||||
GLOBAL: 'tools:global',
|
||||
/** MCP tools cached by server name */
|
||||
MCP_SERVER: (serverName) => `tools:mcp:${serverName}`,
|
||||
/** MCP tools cached by user ID and server name */
|
||||
MCP_SERVER: (userId, serverName) => `tools:mcp:${userId}:${serverName}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves available tools from cache
|
||||
* @function getCachedTools
|
||||
* @param {Object} options - Options for retrieving tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name to get cached tools for
|
||||
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
|
||||
*/
|
||||
async function getCachedTools(options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { serverName } = options;
|
||||
const { userId, serverName } = options;
|
||||
|
||||
// Return MCP server-specific tools if requested
|
||||
if (serverName) {
|
||||
return await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
if (serverName && userId) {
|
||||
return await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
}
|
||||
|
||||
// Default to global tools
|
||||
|
|
@ -36,17 +37,18 @@ async function getCachedTools(options = {}) {
|
|||
* @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 MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name for server-specific 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 { serverName, ttl } = options;
|
||||
const { userId, serverName, ttl } = options;
|
||||
|
||||
// Cache by MCP server if specified
|
||||
if (serverName) {
|
||||
return await cache.set(ToolCacheKeys.MCP_SERVER(serverName), tools, ttl);
|
||||
// Cache by MCP server if specified (requires userId)
|
||||
if (serverName && userId) {
|
||||
return await cache.set(ToolCacheKeys.MCP_SERVER(userId, serverName), tools, ttl);
|
||||
}
|
||||
|
||||
// Default to global cache
|
||||
|
|
@ -57,13 +59,14 @@ async function setCachedTools(tools, options = {}) {
|
|||
* Invalidates cached tools
|
||||
* @function invalidateCachedTools
|
||||
* @param {Object} options - Options for invalidating tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name to invalidate
|
||||
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function invalidateCachedTools(options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { serverName, invalidateGlobal = false } = options;
|
||||
const { userId, serverName, invalidateGlobal = false } = options;
|
||||
|
||||
const keysToDelete = [];
|
||||
|
||||
|
|
@ -71,22 +74,23 @@ async function invalidateCachedTools(options = {}) {
|
|||
keysToDelete.push(ToolCacheKeys.GLOBAL);
|
||||
}
|
||||
|
||||
if (serverName) {
|
||||
keysToDelete.push(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
if (serverName && userId) {
|
||||
keysToDelete.push(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
}
|
||||
|
||||
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MCP tools for a specific server from cache or merges with global tools
|
||||
* Gets MCP tools for a specific server from cache
|
||||
* @function getMCPServerTools
|
||||
* @param {string} userId - The user ID
|
||||
* @param {string} serverName - The MCP server name
|
||||
* @returns {Promise<LCAvailableTools|null>} The available tools for the server
|
||||
*/
|
||||
async function getMCPServerTools(serverName) {
|
||||
async function getMCPServerTools(userId, serverName) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
|
||||
if (serverTools) {
|
||||
return serverTools;
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ async function getEndpointsConfig(req) {
|
|||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const checkCapability = async (req, capability) => {
|
||||
const isAgents = isAgentsEndpoint(req.body?.original_endpoint || req.body?.endpoint);
|
||||
const isAgents = isAgentsEndpoint(req.body?.endpointType || req.body?.endpoint);
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
const capabilities =
|
||||
isAgents || endpointsConfig?.[EModelEndpoint.agents]?.capabilities != null
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
const { isUserProvided, normalizeEndpointName } = require('@librechat/api');
|
||||
const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider');
|
||||
const { isUserProvided } = require('@librechat/api');
|
||||
const {
|
||||
EModelEndpoint,
|
||||
extractEnvVariable,
|
||||
normalizeEndpointName,
|
||||
} = require('librechat-data-provider');
|
||||
const { fetchModels } = require('~/server/services/ModelService');
|
||||
const { getAppConfig } = require('./app');
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ const { getLogStores } = require('~/cache');
|
|||
/**
|
||||
* Updates MCP tools in the cache for a specific server
|
||||
* @param {Object} params - Parameters for updating MCP tools
|
||||
* @param {string} params.userId - User ID for user-specific caching
|
||||
* @param {string} params.serverName - MCP server name
|
||||
* @param {Array} params.tools - Array of tool objects from MCP server
|
||||
* @returns {Promise<LCAvailableTools>}
|
||||
*/
|
||||
async function updateMCPServerTools({ serverName, tools }) {
|
||||
async function updateMCPServerTools({ userId, serverName, tools }) {
|
||||
try {
|
||||
const serverTools = {};
|
||||
const mcpDelimiter = Constants.mcp_delimiter;
|
||||
|
|
@ -27,14 +28,16 @@ async function updateMCPServerTools({ serverName, tools }) {
|
|||
};
|
||||
}
|
||||
|
||||
await setCachedTools(serverTools, { serverName });
|
||||
await setCachedTools(serverTools, { userId, serverName });
|
||||
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
logger.debug(`[MCP Cache] Updated ${tools.length} tools for server ${serverName}`);
|
||||
logger.debug(
|
||||
`[MCP Cache] Updated ${tools.length} tools for server ${serverName} (user: ${userId})`,
|
||||
);
|
||||
return serverTools;
|
||||
} catch (error) {
|
||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
|
||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName} (user: ${userId}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -65,21 +68,22 @@ async function mergeAppTools(appTools) {
|
|||
/**
|
||||
* Caches MCP server tools (no longer merges with global)
|
||||
* @param {object} params
|
||||
* @param {string} params.userId - User ID for user-specific caching
|
||||
* @param {string} params.serverName
|
||||
* @param {import('@librechat/api').LCAvailableTools} params.serverTools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function cacheMCPServerTools({ serverName, serverTools }) {
|
||||
async function cacheMCPServerTools({ userId, serverName, serverTools }) {
|
||||
try {
|
||||
const count = Object.keys(serverTools).length;
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
// Only cache server-specific tools, no merging with global
|
||||
await setCachedTools(serverTools, { serverName });
|
||||
logger.debug(`Cached ${count} MCP server tools for ${serverName}`);
|
||||
await setCachedTools(serverTools, { userId, serverName });
|
||||
logger.debug(`Cached ${count} MCP server tools for ${serverName} (user: ${userId})`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cache MCP server tools for ${serverName}:`, error);
|
||||
logger.error(`Failed to cache MCP server tools for ${serverName} (user: ${userId}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ const {
|
|||
primeResources,
|
||||
getModelMaxTokens,
|
||||
extractLibreChatParams,
|
||||
filterFilesByEndpointConfig,
|
||||
optionalChainWithEmptyCheck,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
ErrorTypes,
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
paramEndpoints,
|
||||
isAgentsEndpoint,
|
||||
replaceSpecialVars,
|
||||
providerEndpointMap,
|
||||
|
|
@ -71,6 +73,9 @@ const initializeAgent = async ({
|
|||
|
||||
const { resendFiles, maxContextTokens, modelOptions } = extractLibreChatParams(_modelOptions);
|
||||
|
||||
const provider = agent.provider;
|
||||
agent.endpoint = provider;
|
||||
|
||||
if (isInitialAgent && conversationId != null && resendFiles) {
|
||||
const fileIds = (await getConvoFiles(conversationId)) ?? [];
|
||||
/** @type {Set<EToolResources>} */
|
||||
|
|
@ -88,6 +93,19 @@ const initializeAgent = async ({
|
|||
currentFiles = await processFiles(requestFiles);
|
||||
}
|
||||
|
||||
if (currentFiles && currentFiles.length) {
|
||||
let endpointType;
|
||||
if (!paramEndpoints.has(agent.endpoint)) {
|
||||
endpointType = EModelEndpoint.custom;
|
||||
}
|
||||
|
||||
currentFiles = filterFilesByEndpointConfig(req, {
|
||||
files: currentFiles,
|
||||
endpoint: agent.endpoint,
|
||||
endpointType,
|
||||
});
|
||||
}
|
||||
|
||||
const { attachments, tool_resources } = await primeResources({
|
||||
req,
|
||||
getFiles,
|
||||
|
|
@ -98,7 +116,6 @@ const initializeAgent = async ({
|
|||
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
|
||||
});
|
||||
|
||||
const provider = agent.provider;
|
||||
const {
|
||||
tools: structuredTools,
|
||||
toolContextMap,
|
||||
|
|
@ -113,7 +130,6 @@ const initializeAgent = async ({
|
|||
tool_resources,
|
||||
})) ?? {};
|
||||
|
||||
agent.endpoint = provider;
|
||||
const { getOptions, overrideProvider } = getProviderConfig({ provider, appConfig });
|
||||
if (overrideProvider !== agent.provider) {
|
||||
agent.provider = overrideProvider;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { createContentAggregator } = require('@librechat/agents');
|
||||
const { validateAgentModel, getCustomEndpointConfig } = require('@librechat/api');
|
||||
const {
|
||||
validateAgentModel,
|
||||
getCustomEndpointConfig,
|
||||
createSequentialChainEdges,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
|
|
@ -119,44 +123,90 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
|
||||
const agent_ids = primaryConfig.agent_ids;
|
||||
let userMCPAuthMap = primaryConfig.userMCPAuthMap;
|
||||
if (agent_ids?.length) {
|
||||
for (const agentId of agent_ids) {
|
||||
const agent = await getAgent({ id: agentId });
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
|
||||
async function processAgent(agentId) {
|
||||
const agent = await getAgent({ id: agentId });
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
}
|
||||
|
||||
const config = await initializeAgent({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
requestFiles,
|
||||
conversationId,
|
||||
endpointOption,
|
||||
allowedProviders,
|
||||
});
|
||||
if (userMCPAuthMap != null) {
|
||||
Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {});
|
||||
} else {
|
||||
userMCPAuthMap = config.userMCPAuthMap;
|
||||
}
|
||||
agentConfigs.set(agentId, config);
|
||||
}
|
||||
|
||||
let edges = primaryConfig.edges;
|
||||
const checkAgentInit = (agentId) => agentId === primaryConfig.id || agentConfigs.has(agentId);
|
||||
if ((edges?.length ?? 0) > 0) {
|
||||
for (const edge of edges) {
|
||||
if (Array.isArray(edge.to)) {
|
||||
for (const to of edge.to) {
|
||||
if (checkAgentInit(to)) {
|
||||
continue;
|
||||
}
|
||||
await processAgent(to);
|
||||
}
|
||||
} else if (typeof edge.to === 'string' && checkAgentInit(edge.to)) {
|
||||
continue;
|
||||
} else if (typeof edge.to === 'string') {
|
||||
await processAgent(edge.to);
|
||||
}
|
||||
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
if (Array.isArray(edge.from)) {
|
||||
for (const from of edge.from) {
|
||||
if (checkAgentInit(from)) {
|
||||
continue;
|
||||
}
|
||||
await processAgent(from);
|
||||
}
|
||||
} else if (typeof edge.from === 'string' && checkAgentInit(edge.from)) {
|
||||
continue;
|
||||
} else if (typeof edge.from === 'string') {
|
||||
await processAgent(edge.from);
|
||||
}
|
||||
|
||||
const config = await initializeAgent({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
requestFiles,
|
||||
conversationId,
|
||||
endpointOption,
|
||||
allowedProviders,
|
||||
});
|
||||
if (userMCPAuthMap != null) {
|
||||
Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {});
|
||||
} else {
|
||||
userMCPAuthMap = config.userMCPAuthMap;
|
||||
}
|
||||
agentConfigs.set(agentId, config);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Agent Chain */
|
||||
if (agent_ids?.length) {
|
||||
for (const agentId of agent_ids) {
|
||||
if (checkAgentInit(agentId)) {
|
||||
continue;
|
||||
}
|
||||
await processAgent(agentId);
|
||||
}
|
||||
|
||||
const chain = await createSequentialChainEdges([primaryConfig.id].concat(agent_ids), '{convo}');
|
||||
edges = edges ? edges.concat(chain) : chain;
|
||||
}
|
||||
|
||||
primaryConfig.edges = edges;
|
||||
|
||||
let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint];
|
||||
if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
|
|||
const anthropicConfig = appConfig.endpoints?.[EModelEndpoint.anthropic];
|
||||
|
||||
if (anthropicConfig) {
|
||||
clientOptions.streamRate = anthropicConfig.streamRate;
|
||||
clientOptions._lc_stream_delay = anthropicConfig.streamRate;
|
||||
clientOptions.titleModel = anthropicConfig.titleModel;
|
||||
}
|
||||
|
||||
const allConfig = appConfig.endpoints?.all;
|
||||
if (allConfig) {
|
||||
clientOptions.streamRate = allConfig.streamRate;
|
||||
clientOptions._lc_stream_delay = allConfig.streamRate;
|
||||
}
|
||||
|
||||
if (optionsOnly) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { createHandleLLMNewToken } = require('@librechat/api');
|
||||
const {
|
||||
AuthType,
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
bedrockInputParser,
|
||||
bedrockOutputParser,
|
||||
|
|
@ -11,7 +9,6 @@ const {
|
|||
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
|
||||
|
||||
const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
||||
const appConfig = req.config;
|
||||
const {
|
||||
BEDROCK_AWS_SECRET_ACCESS_KEY,
|
||||
BEDROCK_AWS_ACCESS_KEY_ID,
|
||||
|
|
@ -47,10 +44,12 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
|||
checkUserKeyExpiry(expiresAt, EModelEndpoint.bedrock);
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
/*
|
||||
Callback for stream rate no longer awaits and may end the stream prematurely
|
||||
/** @type {number}
|
||||
let streamRate = Constants.DEFAULT_STREAM_RATE;
|
||||
|
||||
/** @type {undefined | TBaseEndpoint} */
|
||||
/** @type {undefined | TBaseEndpoint}
|
||||
const bedrockConfig = appConfig.endpoints?.[EModelEndpoint.bedrock];
|
||||
|
||||
if (bedrockConfig && bedrockConfig.streamRate) {
|
||||
|
|
@ -61,6 +60,7 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
|||
if (allConfig && allConfig.streamRate) {
|
||||
streamRate = allConfig.streamRate;
|
||||
}
|
||||
*/
|
||||
|
||||
/** @type {BedrockClientOptions} */
|
||||
const requestOptions = {
|
||||
|
|
@ -88,12 +88,6 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
|||
llmConfig.endpointHost = BEDROCK_REVERSE_PROXY;
|
||||
}
|
||||
|
||||
llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(streamRate),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
/** @type {BedrockClientOptions} */
|
||||
llmConfig,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ const {
|
|||
isUserProvided,
|
||||
getOpenAIConfig,
|
||||
getCustomEndpointConfig,
|
||||
createHandleLLMNewToken,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
CacheKeys,
|
||||
|
|
@ -157,11 +156,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
|||
if (!clientOptions.streamRate) {
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
|
||||
},
|
||||
];
|
||||
options.llmConfig._lc_stream_delay = clientOptions.streamRate;
|
||||
return options;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ jest.mock('@librechat/api', () => ({
|
|||
...jest.requireActual('@librechat/api'),
|
||||
resolveHeaders: jest.fn(),
|
||||
getOpenAIConfig: jest.fn(),
|
||||
createHandleLLMNewToken: jest.fn(),
|
||||
getCustomEndpointConfig: jest.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://test.com',
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ const {
|
|||
isUserProvided,
|
||||
getOpenAIConfig,
|
||||
getAzureCredentials,
|
||||
createHandleLLMNewToken,
|
||||
shouldUseEntraId,
|
||||
getEntraIdAccessToken,
|
||||
} = require('@librechat/api');
|
||||
const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService');
|
||||
const OpenAIClient = require('~/app/clients/OpenAIClient');
|
||||
|
|
@ -167,11 +165,7 @@ const initializeClient = async ({
|
|||
if (!streamRate) {
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(streamRate),
|
||||
},
|
||||
];
|
||||
options.llmConfig._lc_stream_delay = streamRate;
|
||||
return options;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -227,7 +227,6 @@ class STTService {
|
|||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...(apiKey && { 'api-key': apiKey }),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
const axios = require('axios');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { logAxiosError, validateImage } = require('@librechat/api');
|
||||
const {
|
||||
FileSources,
|
||||
VisionModes,
|
||||
ImageDetail,
|
||||
ContentTypes,
|
||||
EModelEndpoint,
|
||||
mergeFileConfig,
|
||||
getEndpointFileConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
|
||||
|
|
@ -84,11 +86,15 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]);
|
|||
* Encodes and formats the given files.
|
||||
* @param {ServerRequest} req - The request object.
|
||||
* @param {Array<MongoFile>} files - The array of files to encode and format.
|
||||
* @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image.
|
||||
* @param {object} params - Object containing provider/endpoint information
|
||||
* @param {Providers | EModelEndpoint | string} [params.provider] - The provider for the image
|
||||
* @param {string} [params.endpoint] - Optional: The endpoint for the image
|
||||
* @param {string} [mode] - Optional: The endpoint mode for the image.
|
||||
* @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
|
||||
*/
|
||||
async function encodeAndFormat(req, files, endpoint, mode) {
|
||||
async function encodeAndFormat(req, files, params, mode) {
|
||||
const { provider, endpoint } = params;
|
||||
const effectiveEndpoint = endpoint ?? provider;
|
||||
const promises = [];
|
||||
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */
|
||||
const encodingMethods = {};
|
||||
|
|
@ -134,7 +140,7 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
|||
} catch (error) {
|
||||
logger.error('Error processing image from blob storage:', error);
|
||||
}
|
||||
} else if (source !== FileSources.local && base64Only.has(endpoint)) {
|
||||
} else if (source !== FileSources.local && base64Only.has(effectiveEndpoint)) {
|
||||
const [_file, imageURL] = await preparePayload(req, file);
|
||||
promises.push([_file, await fetchImageToBase64(imageURL)]);
|
||||
continue;
|
||||
|
|
@ -148,6 +154,17 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
|||
const formattedImages = await Promise.all(promises);
|
||||
promises.length = 0;
|
||||
|
||||
/** Extract configured file size limit from fileConfig for this endpoint */
|
||||
let configuredFileSizeLimit;
|
||||
if (req.config?.fileConfig) {
|
||||
const fileConfig = mergeFileConfig(req.config.fileConfig);
|
||||
const endpointConfig = getEndpointFileConfig({
|
||||
fileConfig,
|
||||
endpoint: effectiveEndpoint,
|
||||
});
|
||||
configuredFileSizeLimit = endpointConfig?.fileSizeLimit;
|
||||
}
|
||||
|
||||
for (const [file, imageContent] of formattedImages) {
|
||||
const fileMetadata = {
|
||||
type: file.type,
|
||||
|
|
@ -168,6 +185,26 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
|||
continue;
|
||||
}
|
||||
|
||||
/** Validate image buffer against size limits */
|
||||
if (file.height && file.width) {
|
||||
const imageBuffer = imageContent.startsWith('http')
|
||||
? null
|
||||
: Buffer.from(imageContent, 'base64');
|
||||
|
||||
if (imageBuffer) {
|
||||
const validation = await validateImage(
|
||||
imageBuffer,
|
||||
imageBuffer.length,
|
||||
effectiveEndpoint,
|
||||
configuredFileSizeLimit,
|
||||
);
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imagePart = {
|
||||
type: ContentTypes.IMAGE_URL,
|
||||
image_url: {
|
||||
|
|
@ -184,15 +221,19 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (endpoint && endpoint === EModelEndpoint.google && mode === VisionModes.generative) {
|
||||
if (
|
||||
effectiveEndpoint &&
|
||||
effectiveEndpoint === EModelEndpoint.google &&
|
||||
mode === VisionModes.generative
|
||||
) {
|
||||
delete imagePart.image_url;
|
||||
imagePart.inlineData = {
|
||||
mimeType: file.type,
|
||||
data: imageContent,
|
||||
};
|
||||
} else if (endpoint && endpoint === EModelEndpoint.google) {
|
||||
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.google) {
|
||||
imagePart.image_url = imagePart.image_url.url;
|
||||
} else if (endpoint && endpoint === EModelEndpoint.anthropic) {
|
||||
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.anthropic) {
|
||||
imagePart.type = 'image';
|
||||
imagePart.source = {
|
||||
type: 'base64',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const {
|
|||
checkOpenAIStorage,
|
||||
removeNullishValues,
|
||||
isAssistantsEndpoint,
|
||||
getEndpointFileConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { EnvVar } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
|
@ -994,7 +995,7 @@ async function saveBase64Image(
|
|||
*/
|
||||
function filterFile({ req, image, isAvatar }) {
|
||||
const { file } = req;
|
||||
const { endpoint, file_id, width, height } = req.body;
|
||||
const { endpoint, endpointType, file_id, width, height } = req.body;
|
||||
|
||||
if (!file_id && !isAvatar) {
|
||||
throw new Error('No file_id provided');
|
||||
|
|
@ -1016,9 +1017,13 @@ function filterFile({ req, image, isAvatar }) {
|
|||
const appConfig = req.config;
|
||||
const fileConfig = mergeFileConfig(appConfig.fileConfig);
|
||||
|
||||
const { fileSizeLimit: sizeLimit, supportedMimeTypes } =
|
||||
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
|
||||
const fileSizeLimit = isAvatar === true ? fileConfig.avatarSizeLimit : sizeLimit;
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
endpoint,
|
||||
fileConfig,
|
||||
endpointType,
|
||||
});
|
||||
const fileSizeLimit =
|
||||
isAvatar === true ? fileConfig.avatarSizeLimit : endpointFileConfig.fileSizeLimit;
|
||||
|
||||
if (file.size > fileSizeLimit) {
|
||||
throw new Error(
|
||||
|
|
@ -1028,7 +1033,10 @@ function filterFile({ req, image, isAvatar }) {
|
|||
);
|
||||
}
|
||||
|
||||
const isSupportedMimeType = fileConfig.checkType(file.mimetype, supportedMimeTypes);
|
||||
const isSupportedMimeType = fileConfig.checkType(
|
||||
file.mimetype,
|
||||
endpointFileConfig.supportedMimeTypes,
|
||||
);
|
||||
|
||||
if (!isSupportedMimeType) {
|
||||
throw new Error('Unsupported file type');
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const { findToken, createToken, updateToken } = require('~/models');
|
|||
const { reinitMCPServer } = require('./Tools/mcp');
|
||||
const { getAppConfig } = require('./Config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
|
|
@ -450,7 +451,7 @@ async function getMCPSetupData(userId) {
|
|||
logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error);
|
||||
}
|
||||
const userConnections = mcpManager.getUserConnections(userId) || new Map();
|
||||
const oauthServers = mcpManager.getOAuthServers();
|
||||
const oauthServers = await mcpServersRegistry.getOAuthServers();
|
||||
|
||||
return {
|
||||
mcpConfig,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ jest.mock('@librechat/api', () => ({
|
|||
sendEvent: jest.fn(),
|
||||
normalizeServerName: jest.fn((name) => name),
|
||||
convertWithResolvedRefs: jest.fn((params) => params),
|
||||
mcpServersRegistry: {
|
||||
getOAuthServers: jest.fn(() => Promise.resolve(new Set())),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
|
|
@ -100,6 +103,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
let mockGetFlowStateManager;
|
||||
let mockGetLogStores;
|
||||
let mockGetOAuthReconnectionManager;
|
||||
let mockMcpServersRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
@ -108,6 +112,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
mockGetFlowStateManager = require('~/config').getFlowStateManager;
|
||||
mockGetLogStores = require('~/cache').getLogStores;
|
||||
mockGetOAuthReconnectionManager = require('~/config').getOAuthReconnectionManager;
|
||||
mockMcpServersRegistry = require('@librechat/api').mcpServersRegistry;
|
||||
});
|
||||
|
||||
describe('getMCPSetupData', () => {
|
||||
|
|
@ -125,8 +130,8 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
mockGetMCPManager.mockReturnValue({
|
||||
appConnections: { getAll: jest.fn(() => new Map()) },
|
||||
getUserConnections: jest.fn(() => new Map()),
|
||||
getOAuthServers: jest.fn(() => new Set()),
|
||||
});
|
||||
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set());
|
||||
});
|
||||
|
||||
it('should successfully return MCP setup data', async () => {
|
||||
|
|
@ -139,9 +144,9 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
const mockMCPManager = {
|
||||
appConnections: { getAll: jest.fn(() => mockAppConnections) },
|
||||
getUserConnections: jest.fn(() => mockUserConnections),
|
||||
getOAuthServers: jest.fn(() => mockOAuthServers),
|
||||
};
|
||||
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
||||
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(mockOAuthServers);
|
||||
|
||||
const result = await getMCPSetupData(mockUserId);
|
||||
|
||||
|
|
@ -149,7 +154,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMCPManager.appConnections.getAll).toHaveBeenCalled();
|
||||
expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMCPManager.getOAuthServers).toHaveBeenCalled();
|
||||
expect(mockMcpServersRegistry.getOAuthServers).toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({
|
||||
mcpConfig: mockConfig.mcpServers,
|
||||
|
|
@ -170,9 +175,9 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
const mockMCPManager = {
|
||||
appConnections: { getAll: jest.fn(() => null) },
|
||||
getUserConnections: jest.fn(() => null),
|
||||
getOAuthServers: jest.fn(() => new Set()),
|
||||
};
|
||||
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
||||
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set());
|
||||
|
||||
const result = await getMCPSetupData(mockUserId);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const {
|
|||
ImageVisionTool,
|
||||
openapiToFunction,
|
||||
AgentCapabilities,
|
||||
validateActionDomain,
|
||||
defaultAgentCapabilities,
|
||||
validateAndParseOpenAPISpec,
|
||||
} = require('librechat-data-provider');
|
||||
|
|
@ -236,12 +237,26 @@ async function processRequiredActions(client, requiredActions) {
|
|||
|
||||
// Validate and parse OpenAPI spec
|
||||
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
|
||||
if (!validationResult.spec) {
|
||||
if (!validationResult.spec || !validationResult.serverUrl) {
|
||||
throw new Error(
|
||||
`Invalid spec: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: Validate the domain from the spec matches the stored domain
|
||||
// This is defense-in-depth to prevent any stored malicious actions
|
||||
const domainValidation = validateActionDomain(
|
||||
action.metadata.domain,
|
||||
validationResult.serverUrl,
|
||||
);
|
||||
if (!domainValidation.isValid) {
|
||||
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
|
||||
userId: client.req.user.id,
|
||||
action_id: action.action_id,
|
||||
});
|
||||
continue; // Skip this action rather than failing the entire request
|
||||
}
|
||||
|
||||
// Process the OpenAPI spec
|
||||
const { requestBuilders } = openapiToFunction(validationResult.spec);
|
||||
|
||||
|
|
@ -525,10 +540,25 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
|
|||
|
||||
// Validate and parse OpenAPI spec once per action set
|
||||
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
|
||||
if (!validationResult.spec) {
|
||||
if (!validationResult.spec || !validationResult.serverUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// SECURITY: Validate the domain from the spec matches the stored domain
|
||||
// This is defense-in-depth to prevent any stored malicious actions
|
||||
const domainValidation = validateActionDomain(
|
||||
action.metadata.domain,
|
||||
validationResult.serverUrl,
|
||||
);
|
||||
if (!domainValidation.isValid) {
|
||||
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
|
||||
userId: req.user.id,
|
||||
agent_id: agent.id,
|
||||
action_id: action.action_id,
|
||||
});
|
||||
continue; // Skip this action rather than failing the entire request
|
||||
}
|
||||
|
||||
const encrypted = {
|
||||
oauth_client_id: action.metadata.oauth_client_id,
|
||||
oauth_client_secret: action.metadata.oauth_client_secret,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ async function reinitMCPServer({
|
|||
if (connection && !oauthRequired) {
|
||||
tools = await connection.fetchTools();
|
||||
availableTools = await updateMCPServerTools({
|
||||
userId: user.id,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ async function initializeMCPs() {
|
|||
const mcpManager = await createMCPManager(mcpServers);
|
||||
|
||||
try {
|
||||
const mcpTools = mcpManager.getAppToolFunctions() || {};
|
||||
const mcpTools = (await mcpManager.getAppToolFunctions()) || {};
|
||||
await mergeAppTools(mcpTools);
|
||||
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Tool } = require('@langchain/core/tools');
|
||||
const { Calculator } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { zodToJsonSchema } = require('zod-to-json-schema');
|
||||
const { Tools, ImageVisionTool } = require('librechat-data-provider');
|
||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||
const { getToolkitKey, oaiToolkit, ytToolkit } = require('@librechat/api');
|
||||
const { toolkits } = require('~/app/clients/tools/manifest');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue