mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-28 21:26:13 +01:00
Merge branch 'main' into feat/openid-custom-data
This commit is contained in:
commit
f0a42d20a2
296 changed files with 9736 additions and 4122 deletions
|
|
@ -51,6 +51,7 @@ export const excludedKeys = new Set([
|
|||
'tools',
|
||||
'model',
|
||||
'files',
|
||||
'spec',
|
||||
]);
|
||||
|
||||
export enum SettingsViews {
|
||||
|
|
@ -236,6 +237,7 @@ export const agentsEndpointSChema = baseEndpointSchema.merge(
|
|||
recursionLimit: z.number().optional(),
|
||||
disableBuilder: z.boolean().optional(),
|
||||
maxRecursionLimit: z.number().optional(),
|
||||
allowedProviders: z.array(z.union([z.string(), eModelEndpointSchema])).optional(),
|
||||
capabilities: z
|
||||
.array(z.nativeEnum(AgentCapabilities))
|
||||
.optional()
|
||||
|
|
@ -862,6 +864,8 @@ export const visionModels = [
|
|||
'gemini-exp',
|
||||
'gemini-1.5',
|
||||
'gemini-2.0',
|
||||
'gemini-2.5',
|
||||
'gemini-3',
|
||||
'moondream',
|
||||
'llama3.2-vision',
|
||||
'llama-3.2-11b-vision',
|
||||
|
|
@ -1005,6 +1009,10 @@ export enum CacheKeys {
|
|||
* Key for in-progress flow states.
|
||||
*/
|
||||
FLOWS = 'flows',
|
||||
/**
|
||||
* Key for s3 check intervals per user
|
||||
*/
|
||||
S3_EXPIRY_INTERVAL = 'S3_EXPIRY_INTERVAL',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1097,6 +1105,10 @@ export enum ErrorTypes {
|
|||
* Google provider returned an error
|
||||
*/
|
||||
GOOGLE_ERROR = 'google_error',
|
||||
/**
|
||||
* Invalid Agent Provider (excluded by Admin)
|
||||
*/
|
||||
INVALID_AGENT_PROVIDER = 'invalid_agent_provider',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1209,7 +1221,7 @@ export enum Constants {
|
|||
/** Key for the app's version. */
|
||||
VERSION = 'v0.7.7',
|
||||
/** Key for the Custom Config's version (librechat.yaml). */
|
||||
CONFIG_VERSION = '1.2.3',
|
||||
CONFIG_VERSION = '1.2.4',
|
||||
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
|
||||
NO_PARENT = '00000000-0000-0000-0000-000000000000',
|
||||
/** Standard value for the initial conversationId before a request is sent */
|
||||
|
|
@ -1234,6 +1246,8 @@ export enum Constants {
|
|||
GLOBAL_PROJECT_NAME = 'instance',
|
||||
/** Delimiter for MCP tools */
|
||||
mcp_delimiter = '_mcp_',
|
||||
/** Placeholder Agent ID for Ephemeral Agents */
|
||||
EPHEMERAL_AGENT_ID = 'ephemeral',
|
||||
}
|
||||
|
||||
export enum LocalStorageKeys {
|
||||
|
|
@ -1269,6 +1283,10 @@ export enum LocalStorageKeys {
|
|||
ENABLE_USER_MSG_MARKDOWN = 'enableUserMsgMarkdown',
|
||||
/** Key for displaying analysis tool code input */
|
||||
SHOW_ANALYSIS_CODE = 'showAnalysisCode',
|
||||
/** Last selected MCP values per conversation ID */
|
||||
LAST_MCP_ = 'LAST_MCP_',
|
||||
/** Last checked toggle for Code Interpreter API per conversation ID */
|
||||
LAST_CODE_TOGGLE_ = 'LAST_CODE_TOGGLE_',
|
||||
}
|
||||
|
||||
export enum ForkOptions {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,15 @@ import { EndpointURLs } from './config';
|
|||
import * as s from './schemas';
|
||||
|
||||
export default function createPayload(submission: t.TSubmission) {
|
||||
const { conversation, userMessage, endpointOption, isEdited, isContinued, isTemporary } =
|
||||
submission;
|
||||
const {
|
||||
conversation,
|
||||
userMessage,
|
||||
endpointOption,
|
||||
isEdited,
|
||||
isContinued,
|
||||
isTemporary,
|
||||
ephemeralAgent,
|
||||
} = submission;
|
||||
const { conversationId } = s.tConvoUpdateSchema.parse(conversation);
|
||||
const { endpoint, endpointType } = endpointOption as {
|
||||
endpoint: s.EModelEndpoint;
|
||||
|
|
@ -12,16 +19,20 @@ export default function createPayload(submission: t.TSubmission) {
|
|||
};
|
||||
|
||||
let server = EndpointURLs[endpointType ?? endpoint];
|
||||
const isEphemeral = s.isEphemeralAgent(endpoint, ephemeralAgent);
|
||||
|
||||
if (isEdited && s.isAssistantsEndpoint(endpoint)) {
|
||||
server += '/modify';
|
||||
} else if (isEdited) {
|
||||
server = server.replace('/ask/', '/edit/');
|
||||
} else if (isEphemeral) {
|
||||
server = `${EndpointURLs[s.EModelEndpoint.agents]}/${endpoint}`;
|
||||
}
|
||||
|
||||
const payload: t.TPayload = {
|
||||
...userMessage,
|
||||
...endpointOption,
|
||||
ephemeralAgent: isEphemeral ? ephemeralAgent : undefined,
|
||||
isContinued: !!(isEdited && isContinued),
|
||||
conversationId,
|
||||
isTemporary,
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export const excelMimeTypes =
|
|||
/^application\/(vnd\.ms-excel|msexcel|x-msexcel|x-ms-excel|x-excel|x-dos_ms_excel|xls|x-xls|vnd\.openxmlformats-officedocument\.spreadsheetml\.sheet)$/;
|
||||
|
||||
export const textMimeTypes =
|
||||
/^(text\/(x-c|x-csharp|x-c\+\+|x-java|html|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|css|vtt|javascript|csv))$/;
|
||||
/^(text\/(x-c|x-csharp|tab-separated-values|x-c\+\+|x-java|html|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|css|vtt|javascript|csv))$/;
|
||||
|
||||
export const applicationMimeTypes =
|
||||
/^(application\/(epub\+zip|csv|json|pdf|x-tar|typescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|xml|zip))$/;
|
||||
|
|
@ -152,6 +152,7 @@ export const codeTypeMapping: { [key: string]: string } = {
|
|||
yml: 'application/x-yaml',
|
||||
yaml: 'application/x-yaml',
|
||||
log: 'text/plain',
|
||||
tsv: 'text/tab-separated-values',
|
||||
};
|
||||
|
||||
export const retrievalMimeTypes = [
|
||||
|
|
@ -230,7 +231,7 @@ export const convertStringsToRegex = (patterns: string[]): RegExp[] =>
|
|||
const regex = new RegExp(pattern);
|
||||
acc.push(regex);
|
||||
} catch (error) {
|
||||
console.error(`Invalid regex pattern "${pattern}" skipped.`);
|
||||
console.error(`Invalid regex pattern "${pattern}" skipped.`, error);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export * from './models';
|
|||
/* mcp */
|
||||
export * from './mcp';
|
||||
/* RBAC */
|
||||
export * from './permissions';
|
||||
export * from './roles';
|
||||
/* types (exports schemas from `./types` as they contain needed in other defs) */
|
||||
export * from './types';
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export const WebSocketOptionsSchema = BaseOptionsSchema.extend({
|
|||
|
||||
export const SSEOptionsSchema = BaseOptionsSchema.extend({
|
||||
type: z.literal('sse').optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
|
|
@ -92,9 +93,10 @@ export type MCPOptions = z.infer<typeof MCPOptionsSchema>;
|
|||
/**
|
||||
* Recursively processes an object to replace environment variables in string values
|
||||
* @param {MCPOptions} obj - The object to process
|
||||
* @param {string} [userId] - The user ID
|
||||
* @returns {MCPOptions} - The processed object with environment variables replaced
|
||||
*/
|
||||
export function processMCPEnv(obj: MCPOptions): MCPOptions {
|
||||
export function processMCPEnv(obj: MCPOptions, userId?: string): MCPOptions {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
|
@ -105,6 +107,16 @@ export function processMCPEnv(obj: MCPOptions): MCPOptions {
|
|||
processedEnv[key] = extractEnvVariable(value);
|
||||
}
|
||||
obj.env = processedEnv;
|
||||
} else if ('headers' in obj && obj.headers) {
|
||||
const processedHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(obj.headers)) {
|
||||
if (value === '{{LIBRECHAT_USER_ID}}' && userId != null && userId) {
|
||||
processedHeaders[key] = userId;
|
||||
continue;
|
||||
}
|
||||
processedHeaders[key] = extractEnvVariable(value);
|
||||
}
|
||||
obj.headers = processedHeaders;
|
||||
}
|
||||
|
||||
return obj;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export const specsConfigSchema = z.object({
|
|||
enforce: z.boolean().default(false),
|
||||
prioritize: z.boolean().default(true),
|
||||
list: z.array(tModelSpecSchema).min(1),
|
||||
addedEndpoints: z.array(z.union([z.string(), eModelEndpointSchema])).optional(),
|
||||
});
|
||||
|
||||
export type TSpecsConfig = z.infer<typeof specsConfigSchema>;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ import {
|
|||
// agentsSchema,
|
||||
compactAgentsSchema,
|
||||
compactGoogleSchema,
|
||||
compactChatGPTSchema,
|
||||
chatGPTBrowserSchema,
|
||||
compactPluginsSchema,
|
||||
compactAssistantSchema,
|
||||
} from './schemas';
|
||||
|
|
@ -26,19 +24,19 @@ type EndpointSchema =
|
|||
| typeof openAISchema
|
||||
| typeof googleSchema
|
||||
| typeof anthropicSchema
|
||||
| typeof chatGPTBrowserSchema
|
||||
| typeof gptPluginsSchema
|
||||
| typeof assistantSchema
|
||||
| typeof compactAgentsSchema
|
||||
| typeof bedrockInputSchema;
|
||||
|
||||
const endpointSchemas: Record<EModelEndpoint, EndpointSchema> = {
|
||||
type EndpointSchemaKey = Exclude<EModelEndpoint, EModelEndpoint.chatGPTBrowser>;
|
||||
|
||||
const endpointSchemas: Record<EndpointSchemaKey, EndpointSchema> = {
|
||||
[EModelEndpoint.openAI]: openAISchema,
|
||||
[EModelEndpoint.azureOpenAI]: openAISchema,
|
||||
[EModelEndpoint.custom]: openAISchema,
|
||||
[EModelEndpoint.google]: googleSchema,
|
||||
[EModelEndpoint.anthropic]: anthropicSchema,
|
||||
[EModelEndpoint.chatGPTBrowser]: chatGPTBrowserSchema,
|
||||
[EModelEndpoint.gptPlugins]: gptPluginsSchema,
|
||||
[EModelEndpoint.assistants]: assistantSchema,
|
||||
[EModelEndpoint.azureAssistants]: assistantSchema,
|
||||
|
|
@ -167,8 +165,8 @@ export const parseConvo = ({
|
|||
conversation,
|
||||
possibleValues,
|
||||
}: {
|
||||
endpoint: EModelEndpoint;
|
||||
endpointType?: EModelEndpoint | null;
|
||||
endpoint: EndpointSchemaKey;
|
||||
endpointType?: EndpointSchemaKey | null;
|
||||
conversation: Partial<s.TConversation | s.TPreset> | null;
|
||||
possibleValues?: TPossibleValues;
|
||||
// TODO: POC for default schema
|
||||
|
|
@ -252,7 +250,7 @@ export const getResponseSender = (endpointOption: t.TEndpointOption): string =>
|
|||
return modelLabel;
|
||||
} else if (model && extractOmniVersion(model)) {
|
||||
return extractOmniVersion(model);
|
||||
} else if (model && model.includes('mistral')) {
|
||||
} else if (model && (model.includes('mistral') || model.includes('codestral'))) {
|
||||
return 'Mistral';
|
||||
} else if (model && model.includes('gpt-')) {
|
||||
const gptVersion = extractGPTVersion(model);
|
||||
|
|
@ -288,7 +286,7 @@ export const getResponseSender = (endpointOption: t.TEndpointOption): string =>
|
|||
return chatGptLabel;
|
||||
} else if (model && extractOmniVersion(model)) {
|
||||
return extractOmniVersion(model);
|
||||
} else if (model && model.includes('mistral')) {
|
||||
} else if (model && (model.includes('mistral') || model.includes('codestral'))) {
|
||||
return 'Mistral';
|
||||
} else if (model && model.includes('gpt-')) {
|
||||
const gptVersion = extractGPTVersion(model);
|
||||
|
|
@ -309,11 +307,10 @@ type CompactEndpointSchema =
|
|||
| typeof compactAgentsSchema
|
||||
| typeof compactGoogleSchema
|
||||
| typeof anthropicSchema
|
||||
| typeof compactChatGPTSchema
|
||||
| typeof bedrockInputSchema
|
||||
| typeof compactPluginsSchema;
|
||||
|
||||
const compactEndpointSchemas: Record<string, CompactEndpointSchema> = {
|
||||
const compactEndpointSchemas: Record<EndpointSchemaKey, CompactEndpointSchema> = {
|
||||
[EModelEndpoint.openAI]: openAISchema,
|
||||
[EModelEndpoint.azureOpenAI]: openAISchema,
|
||||
[EModelEndpoint.custom]: openAISchema,
|
||||
|
|
@ -323,7 +320,6 @@ const compactEndpointSchemas: Record<string, CompactEndpointSchema> = {
|
|||
[EModelEndpoint.google]: compactGoogleSchema,
|
||||
[EModelEndpoint.bedrock]: bedrockInputSchema,
|
||||
[EModelEndpoint.anthropic]: anthropicSchema,
|
||||
[EModelEndpoint.chatGPTBrowser]: compactChatGPTSchema,
|
||||
[EModelEndpoint.gptPlugins]: compactPluginsSchema,
|
||||
};
|
||||
|
||||
|
|
@ -333,8 +329,8 @@ export const parseCompactConvo = ({
|
|||
conversation,
|
||||
possibleValues,
|
||||
}: {
|
||||
endpoint?: EModelEndpoint;
|
||||
endpointType?: EModelEndpoint | null;
|
||||
endpoint?: EndpointSchemaKey;
|
||||
endpointType?: EndpointSchemaKey | null;
|
||||
conversation: Partial<s.TConversation | s.TPreset>;
|
||||
possibleValues?: TPossibleValues;
|
||||
// TODO: POC for default schema
|
||||
|
|
@ -371,13 +367,30 @@ export const parseCompactConvo = ({
|
|||
return convo;
|
||||
};
|
||||
|
||||
export function parseTextParts(contentParts: a.TMessageContentParts[]): string {
|
||||
export function parseTextParts(
|
||||
contentParts: a.TMessageContentParts[],
|
||||
skipReasoning: boolean = false,
|
||||
): string {
|
||||
let result = '';
|
||||
|
||||
for (const part of contentParts) {
|
||||
if (!part.type) {
|
||||
continue;
|
||||
}
|
||||
if (part.type === ContentTypes.TEXT) {
|
||||
const textValue = typeof part.text === 'string' ? part.text : part.text.value;
|
||||
|
||||
if (
|
||||
result.length > 0 &&
|
||||
textValue.length > 0 &&
|
||||
result[result.length - 1] !== ' ' &&
|
||||
textValue[0] !== ' '
|
||||
) {
|
||||
result += ' ';
|
||||
}
|
||||
result += textValue;
|
||||
} else if (part.type === ContentTypes.THINK && !skipReasoning) {
|
||||
const textValue = typeof part.think === 'string' ? part.think : '';
|
||||
if (
|
||||
result.length > 0 &&
|
||||
textValue.length > 0 &&
|
||||
|
|
|
|||
90
packages/data-provider/src/permissions.ts
Normal file
90
packages/data-provider/src/permissions.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Enum for Permission Types
|
||||
*/
|
||||
export enum PermissionTypes {
|
||||
/**
|
||||
* Type for Prompt Permissions
|
||||
*/
|
||||
PROMPTS = 'PROMPTS',
|
||||
/**
|
||||
* Type for Bookmark Permissions
|
||||
*/
|
||||
BOOKMARKS = 'BOOKMARKS',
|
||||
/**
|
||||
* Type for Agent Permissions
|
||||
*/
|
||||
AGENTS = 'AGENTS',
|
||||
/**
|
||||
* Type for Multi-Conversation Permissions
|
||||
*/
|
||||
MULTI_CONVO = 'MULTI_CONVO',
|
||||
/**
|
||||
* Type for Temporary Chat
|
||||
*/
|
||||
TEMPORARY_CHAT = 'TEMPORARY_CHAT',
|
||||
/**
|
||||
* Type for using the "Run Code" LC Code Interpreter API feature
|
||||
*/
|
||||
RUN_CODE = 'RUN_CODE',
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum for Role-Based Access Control Constants
|
||||
*/
|
||||
export enum Permissions {
|
||||
SHARED_GLOBAL = 'SHARED_GLOBAL',
|
||||
USE = 'USE',
|
||||
CREATE = 'CREATE',
|
||||
UPDATE = 'UPDATE',
|
||||
READ = 'READ',
|
||||
READ_AUTHOR = 'READ_AUTHOR',
|
||||
SHARE = 'SHARE',
|
||||
}
|
||||
|
||||
export const promptPermissionsSchema = z.object({
|
||||
[Permissions.SHARED_GLOBAL]: z.boolean().default(false),
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
[Permissions.CREATE]: z.boolean().default(true),
|
||||
// [Permissions.SHARE]: z.boolean().default(false),
|
||||
});
|
||||
export type TPromptPermissions = z.infer<typeof promptPermissionsSchema>;
|
||||
|
||||
export const bookmarkPermissionsSchema = z.object({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
});
|
||||
export type TBookmarkPermissions = z.infer<typeof bookmarkPermissionsSchema>;
|
||||
|
||||
export const agentPermissionsSchema = z.object({
|
||||
[Permissions.SHARED_GLOBAL]: z.boolean().default(false),
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
[Permissions.CREATE]: z.boolean().default(true),
|
||||
// [Permissions.SHARE]: z.boolean().default(false),
|
||||
});
|
||||
export type TAgentPermissions = z.infer<typeof agentPermissionsSchema>;
|
||||
|
||||
export const multiConvoPermissionsSchema = z.object({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
});
|
||||
export type TMultiConvoPermissions = z.infer<typeof multiConvoPermissionsSchema>;
|
||||
|
||||
export const temporaryChatPermissionsSchema = z.object({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
});
|
||||
export type TTemporaryChatPermissions = z.infer<typeof temporaryChatPermissionsSchema>;
|
||||
|
||||
export const runCodePermissionsSchema = z.object({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
});
|
||||
export type TRunCodePermissions = z.infer<typeof runCodePermissionsSchema>;
|
||||
|
||||
// Define a single permissions schema that holds all permission types.
|
||||
export const permissionsSchema = z.object({
|
||||
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
|
||||
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema,
|
||||
[PermissionTypes.AGENTS]: agentPermissionsSchema,
|
||||
[PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema,
|
||||
[PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema,
|
||||
[PermissionTypes.RUN_CODE]: runCodePermissionsSchema,
|
||||
});
|
||||
|
|
@ -1,4 +1,15 @@
|
|||
import { z } from 'zod';
|
||||
import {
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
permissionsSchema,
|
||||
agentPermissionsSchema,
|
||||
promptPermissionsSchema,
|
||||
runCodePermissionsSchema,
|
||||
bookmarkPermissionsSchema,
|
||||
multiConvoPermissionsSchema,
|
||||
temporaryChatPermissionsSchema,
|
||||
} from './permissions';
|
||||
|
||||
/**
|
||||
* Enum for System Defined Roles
|
||||
|
|
@ -14,153 +25,88 @@ export enum SystemRoles {
|
|||
USER = 'USER',
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum for Permission Types
|
||||
*/
|
||||
export enum PermissionTypes {
|
||||
/**
|
||||
* Type for Prompt Permissions
|
||||
*/
|
||||
PROMPTS = 'PROMPTS',
|
||||
/**
|
||||
* Type for Bookmark Permissions
|
||||
*/
|
||||
BOOKMARKS = 'BOOKMARKS',
|
||||
/**
|
||||
* Type for Agent Permissions
|
||||
*/
|
||||
AGENTS = 'AGENTS',
|
||||
/**
|
||||
* Type for Multi-Conversation Permissions
|
||||
*/
|
||||
MULTI_CONVO = 'MULTI_CONVO',
|
||||
/**
|
||||
* Type for Temporary Chat
|
||||
*/
|
||||
TEMPORARY_CHAT = 'TEMPORARY_CHAT',
|
||||
/**
|
||||
* Type for using the "Run Code" LC Code Interpreter API feature
|
||||
*/
|
||||
RUN_CODE = 'RUN_CODE',
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum for Role-Based Access Control Constants
|
||||
*/
|
||||
export enum Permissions {
|
||||
SHARED_GLOBAL = 'SHARED_GLOBAL',
|
||||
USE = 'USE',
|
||||
CREATE = 'CREATE',
|
||||
UPDATE = 'UPDATE',
|
||||
READ = 'READ',
|
||||
READ_AUTHOR = 'READ_AUTHOR',
|
||||
SHARE = 'SHARE',
|
||||
}
|
||||
|
||||
export const promptPermissionsSchema = z.object({
|
||||
[Permissions.SHARED_GLOBAL]: z.boolean().default(false),
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
[Permissions.CREATE]: z.boolean().default(true),
|
||||
// [Permissions.SHARE]: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const bookmarkPermissionsSchema = z.object({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const agentPermissionsSchema = z.object({
|
||||
[Permissions.SHARED_GLOBAL]: z.boolean().default(false),
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
[Permissions.CREATE]: z.boolean().default(true),
|
||||
// [Permissions.SHARE]: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const multiConvoPermissionsSchema = z.object({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const temporaryChatPermissionsSchema = z.object({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const runCodePermissionsSchema = z.object({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
});
|
||||
|
||||
// The role schema now only needs to reference the permissions schema.
|
||||
export const roleSchema = z.object({
|
||||
name: z.string(),
|
||||
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
|
||||
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema,
|
||||
[PermissionTypes.AGENTS]: agentPermissionsSchema,
|
||||
[PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema,
|
||||
[PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema,
|
||||
[PermissionTypes.RUN_CODE]: runCodePermissionsSchema,
|
||||
permissions: permissionsSchema,
|
||||
});
|
||||
|
||||
export type TRole = z.infer<typeof roleSchema>;
|
||||
export type TAgentPermissions = z.infer<typeof agentPermissionsSchema>;
|
||||
export type TPromptPermissions = z.infer<typeof promptPermissionsSchema>;
|
||||
export type TBookmarkPermissions = z.infer<typeof bookmarkPermissionsSchema>;
|
||||
export type TMultiConvoPermissions = z.infer<typeof multiConvoPermissionsSchema>;
|
||||
export type TTemporaryChatPermissions = z.infer<typeof temporaryChatPermissionsSchema>;
|
||||
export type TRunCodePermissions = z.infer<typeof runCodePermissionsSchema>;
|
||||
|
||||
// Define default roles using the new structure.
|
||||
const defaultRolesSchema = z.object({
|
||||
[SystemRoles.ADMIN]: roleSchema.extend({
|
||||
name: z.literal(SystemRoles.ADMIN),
|
||||
[PermissionTypes.PROMPTS]: promptPermissionsSchema.extend({
|
||||
[Permissions.SHARED_GLOBAL]: z.boolean().default(true),
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
[Permissions.CREATE]: z.boolean().default(true),
|
||||
// [Permissions.SHARE]: z.boolean().default(true),
|
||||
}),
|
||||
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema.extend({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
}),
|
||||
[PermissionTypes.AGENTS]: agentPermissionsSchema.extend({
|
||||
[Permissions.SHARED_GLOBAL]: z.boolean().default(true),
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
[Permissions.CREATE]: z.boolean().default(true),
|
||||
// [Permissions.SHARE]: z.boolean().default(true),
|
||||
}),
|
||||
[PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema.extend({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
}),
|
||||
[PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema.extend({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
}),
|
||||
[PermissionTypes.RUN_CODE]: runCodePermissionsSchema.extend({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
permissions: permissionsSchema.extend({
|
||||
[PermissionTypes.PROMPTS]: promptPermissionsSchema.extend({
|
||||
[Permissions.SHARED_GLOBAL]: z.boolean().default(true),
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
[Permissions.CREATE]: z.boolean().default(true),
|
||||
// [Permissions.SHARE]: z.boolean().default(true),
|
||||
}),
|
||||
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema.extend({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
}),
|
||||
[PermissionTypes.AGENTS]: agentPermissionsSchema.extend({
|
||||
[Permissions.SHARED_GLOBAL]: z.boolean().default(true),
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
[Permissions.CREATE]: z.boolean().default(true),
|
||||
// [Permissions.SHARE]: z.boolean().default(true),
|
||||
}),
|
||||
[PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema.extend({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
}),
|
||||
[PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema.extend({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
}),
|
||||
[PermissionTypes.RUN_CODE]: runCodePermissionsSchema.extend({
|
||||
[Permissions.USE]: z.boolean().default(true),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
[SystemRoles.USER]: roleSchema.extend({
|
||||
name: z.literal(SystemRoles.USER),
|
||||
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
|
||||
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema,
|
||||
[PermissionTypes.AGENTS]: agentPermissionsSchema,
|
||||
[PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema,
|
||||
[PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema,
|
||||
[PermissionTypes.RUN_CODE]: runCodePermissionsSchema,
|
||||
permissions: permissionsSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
export const roleDefaults = defaultRolesSchema.parse({
|
||||
[SystemRoles.ADMIN]: {
|
||||
name: SystemRoles.ADMIN,
|
||||
[PermissionTypes.PROMPTS]: {},
|
||||
[PermissionTypes.BOOKMARKS]: {},
|
||||
[PermissionTypes.AGENTS]: {},
|
||||
[PermissionTypes.MULTI_CONVO]: {},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: {},
|
||||
[PermissionTypes.RUN_CODE]: {},
|
||||
permissions: {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.SHARED_GLOBAL]: true,
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.SHARED_GLOBAL]: true,
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.RUN_CODE]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
[SystemRoles.USER]: {
|
||||
name: SystemRoles.USER,
|
||||
[PermissionTypes.PROMPTS]: {},
|
||||
[PermissionTypes.BOOKMARKS]: {},
|
||||
[PermissionTypes.AGENTS]: {},
|
||||
[PermissionTypes.MULTI_CONVO]: {},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: {},
|
||||
[PermissionTypes.RUN_CODE]: {},
|
||||
permissions: {
|
||||
[PermissionTypes.PROMPTS]: {},
|
||||
[PermissionTypes.BOOKMARKS]: {},
|
||||
[PermissionTypes.AGENTS]: {},
|
||||
[PermissionTypes.MULTI_CONVO]: {},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: {},
|
||||
[PermissionTypes.RUN_CODE]: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
import { Tools } from './types/assistants';
|
||||
import type { TMessageContentParts, FunctionTool, FunctionToolCall } from './types/assistants';
|
||||
import type { TEphemeralAgent } from './types';
|
||||
import type { TFile } from './types/files';
|
||||
|
||||
export const isUUID = z.string().uuid();
|
||||
|
|
@ -88,6 +89,21 @@ export const isAgentsEndpoint = (_endpoint?: EModelEndpoint.agents | null | stri
|
|||
return endpoint === EModelEndpoint.agents;
|
||||
};
|
||||
|
||||
export const isEphemeralAgent = (
|
||||
endpoint?: EModelEndpoint.agents | null | string,
|
||||
ephemeralAgent?: TEphemeralAgent | null,
|
||||
) => {
|
||||
if (!ephemeralAgent) {
|
||||
return false;
|
||||
}
|
||||
if (isAgentsEndpoint(endpoint)) {
|
||||
return false;
|
||||
}
|
||||
const hasMCPSelected = (ephemeralAgent?.mcp?.length ?? 0) > 0;
|
||||
const hasCodeSelected = (ephemeralAgent?.execute_code ?? false) === true;
|
||||
return hasMCPSelected || hasCodeSelected;
|
||||
};
|
||||
|
||||
export const isParamEndpoint = (
|
||||
endpoint: EModelEndpoint | string,
|
||||
endpointType?: EModelEndpoint | string,
|
||||
|
|
@ -230,7 +246,7 @@ export const googleSettings = {
|
|||
},
|
||||
maxOutputTokens: {
|
||||
min: 1 as const,
|
||||
max: 8192 as const,
|
||||
max: 64000 as const,
|
||||
step: 1 as const,
|
||||
default: 8192 as const,
|
||||
},
|
||||
|
|
@ -645,6 +661,8 @@ export const tConvoUpdateSchema = tConversationSchema.merge(
|
|||
export const tQueryParamsSchema = tConversationSchema
|
||||
.pick({
|
||||
// librechat settings
|
||||
/** The model spec to be used */
|
||||
spec: true,
|
||||
/** The AI context window, overrides the system-defined window as determined by `model` value */
|
||||
maxContextTokens: true,
|
||||
/**
|
||||
|
|
@ -750,22 +768,23 @@ export const tConversationTagSchema = z.object({
|
|||
});
|
||||
export type TConversationTag = z.infer<typeof tConversationTagSchema>;
|
||||
|
||||
export const googleSchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
promptPrefix: true,
|
||||
examples: true,
|
||||
temperature: true,
|
||||
maxOutputTokens: true,
|
||||
artifacts: true,
|
||||
topP: true,
|
||||
topK: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
maxContextTokens: true,
|
||||
})
|
||||
export const googleBaseSchema = tConversationSchema.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
promptPrefix: true,
|
||||
examples: true,
|
||||
temperature: true,
|
||||
maxOutputTokens: true,
|
||||
artifacts: true,
|
||||
topP: true,
|
||||
topK: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
maxContextTokens: true,
|
||||
});
|
||||
|
||||
export const googleSchema = googleBaseSchema
|
||||
.transform((obj: Partial<TConversation>) => removeNullishValues(obj))
|
||||
.catch(() => ({}));
|
||||
|
||||
|
|
@ -788,36 +807,25 @@ export const googleGenConfigSchema = z
|
|||
.strip()
|
||||
.optional();
|
||||
|
||||
export const chatGPTBrowserSchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
})
|
||||
.transform((obj) => ({
|
||||
...obj,
|
||||
model: obj.model ?? 'text-davinci-002-render-sha',
|
||||
}))
|
||||
.catch(() => ({
|
||||
model: 'text-davinci-002-render-sha',
|
||||
}));
|
||||
const gptPluginsBaseSchema = tConversationSchema.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
chatGptLabel: true,
|
||||
promptPrefix: true,
|
||||
temperature: true,
|
||||
artifacts: true,
|
||||
top_p: true,
|
||||
presence_penalty: true,
|
||||
frequency_penalty: true,
|
||||
tools: true,
|
||||
agentOptions: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
maxContextTokens: true,
|
||||
});
|
||||
|
||||
export const gptPluginsSchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
chatGptLabel: true,
|
||||
promptPrefix: true,
|
||||
temperature: true,
|
||||
artifacts: true,
|
||||
top_p: true,
|
||||
presence_penalty: true,
|
||||
frequency_penalty: true,
|
||||
tools: true,
|
||||
agentOptions: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
maxContextTokens: true,
|
||||
})
|
||||
export const gptPluginsSchema = gptPluginsBaseSchema
|
||||
.transform((obj) => {
|
||||
const result = {
|
||||
...obj,
|
||||
|
|
@ -887,18 +895,19 @@ export function removeNullishValues<T extends Record<string, unknown>>(
|
|||
return newObj;
|
||||
}
|
||||
|
||||
export const assistantSchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
assistant_id: true,
|
||||
instructions: true,
|
||||
artifacts: true,
|
||||
promptPrefix: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
append_current_datetime: true,
|
||||
})
|
||||
const assistantBaseSchema = tConversationSchema.pick({
|
||||
model: true,
|
||||
assistant_id: true,
|
||||
instructions: true,
|
||||
artifacts: true,
|
||||
promptPrefix: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
append_current_datetime: true,
|
||||
});
|
||||
|
||||
export const assistantSchema = assistantBaseSchema
|
||||
.transform((obj) => ({
|
||||
...obj,
|
||||
model: obj.model ?? openAISettings.model.default,
|
||||
|
|
@ -921,37 +930,39 @@ export const assistantSchema = tConversationSchema
|
|||
append_current_datetime: false,
|
||||
}));
|
||||
|
||||
export const compactAssistantSchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
assistant_id: true,
|
||||
instructions: true,
|
||||
promptPrefix: true,
|
||||
artifacts: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
})
|
||||
const compactAssistantBaseSchema = tConversationSchema.pick({
|
||||
model: true,
|
||||
assistant_id: true,
|
||||
instructions: true,
|
||||
promptPrefix: true,
|
||||
artifacts: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
});
|
||||
|
||||
export const compactAssistantSchema = compactAssistantBaseSchema
|
||||
.transform((obj) => removeNullishValues(obj))
|
||||
.catch(() => ({}));
|
||||
|
||||
export const agentsSchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
temperature: true,
|
||||
top_p: true,
|
||||
presence_penalty: true,
|
||||
frequency_penalty: true,
|
||||
resendFiles: true,
|
||||
imageDetail: true,
|
||||
agent_id: true,
|
||||
instructions: true,
|
||||
promptPrefix: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
maxContextTokens: true,
|
||||
})
|
||||
export const agentsBaseSchema = tConversationSchema.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
temperature: true,
|
||||
top_p: true,
|
||||
presence_penalty: true,
|
||||
frequency_penalty: true,
|
||||
resendFiles: true,
|
||||
imageDetail: true,
|
||||
agent_id: true,
|
||||
instructions: true,
|
||||
promptPrefix: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
maxContextTokens: true,
|
||||
});
|
||||
|
||||
export const agentsSchema = agentsBaseSchema
|
||||
.transform((obj) => ({
|
||||
...obj,
|
||||
model: obj.model ?? agentsSettings.model.default,
|
||||
|
|
@ -987,46 +998,32 @@ export const agentsSchema = tConversationSchema
|
|||
maxContextTokens: undefined,
|
||||
}));
|
||||
|
||||
export const openAISchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
chatGptLabel: true,
|
||||
promptPrefix: true,
|
||||
temperature: true,
|
||||
top_p: true,
|
||||
presence_penalty: true,
|
||||
frequency_penalty: true,
|
||||
resendFiles: true,
|
||||
artifacts: true,
|
||||
imageDetail: true,
|
||||
stop: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
maxContextTokens: true,
|
||||
max_tokens: true,
|
||||
reasoning_effort: true,
|
||||
})
|
||||
export const openAIBaseSchema = tConversationSchema.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
chatGptLabel: true,
|
||||
promptPrefix: true,
|
||||
temperature: true,
|
||||
top_p: true,
|
||||
presence_penalty: true,
|
||||
frequency_penalty: true,
|
||||
resendFiles: true,
|
||||
artifacts: true,
|
||||
imageDetail: true,
|
||||
stop: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
maxContextTokens: true,
|
||||
max_tokens: true,
|
||||
reasoning_effort: true,
|
||||
});
|
||||
|
||||
export const openAISchema = openAIBaseSchema
|
||||
.transform((obj: Partial<TConversation>) => removeNullishValues(obj))
|
||||
.catch(() => ({}));
|
||||
|
||||
export const compactGoogleSchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
promptPrefix: true,
|
||||
examples: true,
|
||||
temperature: true,
|
||||
maxOutputTokens: true,
|
||||
artifacts: true,
|
||||
topP: true,
|
||||
topK: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
maxContextTokens: true,
|
||||
})
|
||||
export const compactGoogleSchema = googleBaseSchema
|
||||
.transform((obj) => {
|
||||
const newObj: Partial<TConversation> = { ...obj };
|
||||
if (newObj.temperature === google.temperature.default) {
|
||||
|
|
@ -1046,55 +1043,30 @@ export const compactGoogleSchema = tConversationSchema
|
|||
})
|
||||
.catch(() => ({}));
|
||||
|
||||
export const anthropicSchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
promptPrefix: true,
|
||||
temperature: true,
|
||||
maxOutputTokens: true,
|
||||
topP: true,
|
||||
topK: true,
|
||||
resendFiles: true,
|
||||
promptCache: true,
|
||||
thinking: true,
|
||||
thinkingBudget: true,
|
||||
artifacts: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
maxContextTokens: true,
|
||||
})
|
||||
export const anthropicBaseSchema = tConversationSchema.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
promptPrefix: true,
|
||||
temperature: true,
|
||||
maxOutputTokens: true,
|
||||
topP: true,
|
||||
topK: true,
|
||||
resendFiles: true,
|
||||
promptCache: true,
|
||||
thinking: true,
|
||||
thinkingBudget: true,
|
||||
artifacts: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
maxContextTokens: true,
|
||||
});
|
||||
|
||||
export const anthropicSchema = anthropicBaseSchema
|
||||
.transform((obj) => removeNullishValues(obj))
|
||||
.catch(() => ({}));
|
||||
|
||||
export const compactChatGPTSchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
})
|
||||
.transform((obj) => {
|
||||
const newObj: Partial<TConversation> = { ...obj };
|
||||
return removeNullishValues(newObj);
|
||||
})
|
||||
.catch(() => ({}));
|
||||
|
||||
export const compactPluginsSchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
chatGptLabel: true,
|
||||
promptPrefix: true,
|
||||
temperature: true,
|
||||
top_p: true,
|
||||
presence_penalty: true,
|
||||
frequency_penalty: true,
|
||||
tools: true,
|
||||
agentOptions: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
spec: true,
|
||||
maxContextTokens: true,
|
||||
})
|
||||
export const compactPluginsSchema = gptPluginsBaseSchema
|
||||
.transform((obj) => {
|
||||
const newObj: Partial<TConversation> = { ...obj };
|
||||
if (newObj.modelLabel === null) {
|
||||
|
|
@ -1147,15 +1119,16 @@ export const tBannerSchema = z.object({
|
|||
});
|
||||
export type TBanner = z.infer<typeof tBannerSchema>;
|
||||
|
||||
export const compactAgentsSchema = tConversationSchema
|
||||
.pick({
|
||||
spec: true,
|
||||
// model: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
agent_id: true,
|
||||
instructions: true,
|
||||
additional_instructions: true,
|
||||
})
|
||||
export const compactAgentsBaseSchema = tConversationSchema.pick({
|
||||
spec: true,
|
||||
// model: true,
|
||||
iconURL: true,
|
||||
greeting: true,
|
||||
agent_id: true,
|
||||
instructions: true,
|
||||
additional_instructions: true,
|
||||
});
|
||||
|
||||
export const compactAgentsSchema = compactAgentsBaseSchema
|
||||
.transform((obj) => removeNullishValues(obj))
|
||||
.catch(() => ({}));
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export type TMessages = TMessage[];
|
|||
|
||||
/* TODO: Cleanup EndpointOption types */
|
||||
export type TEndpointOption = {
|
||||
spec?: string | null;
|
||||
iconURL?: string | null;
|
||||
endpoint: EModelEndpoint;
|
||||
endpointType?: EModelEndpoint;
|
||||
modelDisplayLabel?: string;
|
||||
|
|
@ -39,12 +41,18 @@ export type TEndpointOption = {
|
|||
overrideUserMessageId?: string;
|
||||
};
|
||||
|
||||
export type TEphemeralAgent = {
|
||||
mcp?: string[];
|
||||
execute_code?: boolean;
|
||||
};
|
||||
|
||||
export type TPayload = Partial<TMessage> &
|
||||
Partial<TEndpointOption> & {
|
||||
isContinued: boolean;
|
||||
conversationId: string | null;
|
||||
messages?: TMessages;
|
||||
isTemporary: boolean;
|
||||
ephemeralAgent?: TEphemeralAgent | null;
|
||||
};
|
||||
|
||||
export type TSubmission = {
|
||||
|
|
@ -57,11 +65,11 @@ export type TSubmission = {
|
|||
isTemporary: boolean;
|
||||
messages: TMessage[];
|
||||
isRegenerate?: boolean;
|
||||
conversationId?: string;
|
||||
initialResponse?: TMessage;
|
||||
conversation: Partial<TConversation>;
|
||||
endpointOption: TEndpointOption;
|
||||
clientTimestamp?: string;
|
||||
ephemeralAgent?: TEphemeralAgent | null;
|
||||
};
|
||||
|
||||
export type EventSubmission = Omit<TSubmission, 'initialResponse'> & { initialResponse: TMessage };
|
||||
|
|
|
|||
|
|
@ -448,7 +448,7 @@ export type ContentPart = (
|
|||
PartMetadata;
|
||||
|
||||
export type TMessageContentParts =
|
||||
| { type: ContentTypes.ERROR; text: Text & PartMetadata }
|
||||
| { type: ContentTypes.ERROR; text?: string | (Text & PartMetadata); error?: string }
|
||||
| { type: ContentTypes.THINK; think: string | (Text & PartMetadata) }
|
||||
| { type: ContentTypes.TEXT; text: string | (Text & PartMetadata); tool_call_ids?: string[] }
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export enum FileSources {
|
|||
local = 'local',
|
||||
firebase = 'firebase',
|
||||
azure = 'azure',
|
||||
azure_blob = 'azure_blob',
|
||||
openai = 'openai',
|
||||
s3 = 's3',
|
||||
vectordb = 'vectordb',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as types from '../types';
|
||||
import * as r from '../roles';
|
||||
import * as p from '../permissions';
|
||||
import {
|
||||
Tools,
|
||||
Assistant,
|
||||
|
|
@ -251,9 +252,9 @@ export type UpdatePermVars<T> = {
|
|||
updates: Partial<T>;
|
||||
};
|
||||
|
||||
export type UpdatePromptPermVars = UpdatePermVars<r.TPromptPermissions>;
|
||||
export type UpdatePromptPermVars = UpdatePermVars<p.TPromptPermissions>;
|
||||
|
||||
export type UpdateAgentPermVars = UpdatePermVars<r.TAgentPermissions>;
|
||||
export type UpdateAgentPermVars = UpdatePermVars<p.TAgentPermissions>;
|
||||
|
||||
export type UpdatePermResponse = r.TRole;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* eslint-disable jest/no-conditional-expect */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// zod.spec.ts
|
||||
import { z } from 'zod';
|
||||
|
|
@ -468,6 +467,156 @@ describe('convertJsonSchemaToZod', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('additionalProperties handling', () => {
|
||||
it('should allow any additional properties when additionalProperties is true', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
};
|
||||
const zodSchema = convertJsonSchemaToZod(schema);
|
||||
|
||||
// Should accept the defined property
|
||||
expect(zodSchema?.parse({ name: 'John' })).toEqual({ name: 'John' });
|
||||
|
||||
// Should also accept additional properties of any type
|
||||
expect(zodSchema?.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', age: 30 });
|
||||
expect(zodSchema?.parse({ name: 'John', isActive: true })).toEqual({
|
||||
name: 'John',
|
||||
isActive: true,
|
||||
});
|
||||
expect(zodSchema?.parse({ name: 'John', tags: ['tag1', 'tag2'] })).toEqual({
|
||||
name: 'John',
|
||||
tags: ['tag1', 'tag2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate additional properties according to schema when additionalProperties is an object', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
additionalProperties: { type: 'number' },
|
||||
};
|
||||
const zodSchema = convertJsonSchemaToZod(schema);
|
||||
|
||||
// Should accept the defined property
|
||||
expect(zodSchema?.parse({ name: 'John' })).toEqual({ name: 'John' });
|
||||
|
||||
// Should accept additional properties that match the additionalProperties schema
|
||||
expect(zodSchema?.parse({ name: 'John', age: 30, score: 100 })).toEqual({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
score: 100,
|
||||
});
|
||||
|
||||
// Should reject additional properties that don't match the additionalProperties schema
|
||||
expect(() => zodSchema?.parse({ name: 'John', isActive: true })).toThrow();
|
||||
expect(() => zodSchema?.parse({ name: 'John', tags: ['tag1', 'tag2'] })).toThrow();
|
||||
});
|
||||
|
||||
it('should strip additional properties when additionalProperties is false or not specified', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
const zodSchema = convertJsonSchemaToZod(schema);
|
||||
|
||||
// Should accept the defined properties
|
||||
expect(zodSchema?.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', age: 30 });
|
||||
|
||||
// Current implementation strips additional properties when additionalProperties is false
|
||||
const objWithExtra = { name: 'John', age: 30, isActive: true };
|
||||
expect(zodSchema?.parse(objWithExtra)).toEqual({ name: 'John', age: 30 });
|
||||
|
||||
// Test with additionalProperties not specified (should behave the same)
|
||||
const schemaWithoutAdditionalProps: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
};
|
||||
const zodSchemaWithoutAdditionalProps = convertJsonSchemaToZod(schemaWithoutAdditionalProps);
|
||||
|
||||
expect(zodSchemaWithoutAdditionalProps?.parse({ name: 'John', age: 30 })).toEqual({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
});
|
||||
|
||||
// Current implementation strips additional properties when additionalProperties is not specified
|
||||
const objWithExtra2 = { name: 'John', age: 30, isActive: true };
|
||||
expect(zodSchemaWithoutAdditionalProps?.parse(objWithExtra2)).toEqual({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex nested objects with additionalProperties', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
profile: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bio: { type: 'string' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
const zodSchema = convertJsonSchemaToZod(schema);
|
||||
|
||||
const validData = {
|
||||
user: {
|
||||
name: 'John',
|
||||
profile: {
|
||||
bio: 'Developer',
|
||||
location: 'New York', // Additional property allowed in profile
|
||||
website: 'https://example.com', // Additional property allowed in profile
|
||||
},
|
||||
role: 'admin', // Additional property of type string allowed in user
|
||||
level: 'senior', // Additional property of type string allowed in user
|
||||
},
|
||||
};
|
||||
|
||||
expect(zodSchema?.parse(validData)).toEqual(validData);
|
||||
|
||||
// Current implementation strips additional properties at the top level
|
||||
// when additionalProperties is false
|
||||
const dataWithExtraTopLevel = {
|
||||
user: { name: 'John' },
|
||||
extraField: 'not allowed', // This should be stripped
|
||||
};
|
||||
expect(zodSchema?.parse(dataWithExtraTopLevel)).toEqual({ user: { name: 'John' } });
|
||||
|
||||
// Should reject additional properties in user that don't match the string type
|
||||
expect(() =>
|
||||
zodSchema?.parse({
|
||||
user: {
|
||||
name: 'John',
|
||||
age: 30, // Not a string
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty object handling', () => {
|
||||
it('should return undefined for empty object schemas when allowEmptyObject is false', () => {
|
||||
const emptyObjectSchemas = [
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export type JsonSchemaType = {
|
|||
properties?: Record<string, JsonSchemaType>;
|
||||
required?: string[];
|
||||
description?: string;
|
||||
additionalProperties?: boolean | JsonSchemaType;
|
||||
};
|
||||
|
||||
function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean {
|
||||
|
|
@ -72,7 +73,20 @@ export function convertJsonSchemaToZod(
|
|||
} else {
|
||||
objectSchema = objectSchema.partial();
|
||||
}
|
||||
zodSchema = objectSchema;
|
||||
|
||||
// Handle additionalProperties for open-ended objects
|
||||
if (schema.additionalProperties === true) {
|
||||
// This allows any additional properties with any type
|
||||
zodSchema = objectSchema.passthrough();
|
||||
} else if (typeof schema.additionalProperties === 'object') {
|
||||
// For specific additional property types
|
||||
const additionalSchema = convertJsonSchemaToZod(
|
||||
schema.additionalProperties as JsonSchemaType,
|
||||
);
|
||||
zodSchema = objectSchema.catchall(additionalSchema as z.ZodType);
|
||||
} else {
|
||||
zodSchema = objectSchema;
|
||||
}
|
||||
} else {
|
||||
zodSchema = z.unknown();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,88 +3,81 @@ import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
|||
|
||||
export interface IRole extends Document {
|
||||
name: string;
|
||||
[PermissionTypes.BOOKMARKS]?: {
|
||||
[Permissions.USE]?: boolean;
|
||||
};
|
||||
[PermissionTypes.PROMPTS]?: {
|
||||
[Permissions.SHARED_GLOBAL]?: boolean;
|
||||
[Permissions.USE]?: boolean;
|
||||
[Permissions.CREATE]?: boolean;
|
||||
};
|
||||
[PermissionTypes.AGENTS]?: {
|
||||
[Permissions.SHARED_GLOBAL]?: boolean;
|
||||
[Permissions.USE]?: boolean;
|
||||
[Permissions.CREATE]?: boolean;
|
||||
};
|
||||
[PermissionTypes.MULTI_CONVO]?: {
|
||||
[Permissions.USE]?: boolean;
|
||||
};
|
||||
[PermissionTypes.TEMPORARY_CHAT]?: {
|
||||
[Permissions.USE]?: boolean;
|
||||
};
|
||||
[PermissionTypes.RUN_CODE]?: {
|
||||
[Permissions.USE]?: boolean;
|
||||
permissions: {
|
||||
[PermissionTypes.BOOKMARKS]?: {
|
||||
[Permissions.USE]?: boolean;
|
||||
};
|
||||
[PermissionTypes.PROMPTS]?: {
|
||||
[Permissions.SHARED_GLOBAL]?: boolean;
|
||||
[Permissions.USE]?: boolean;
|
||||
[Permissions.CREATE]?: boolean;
|
||||
};
|
||||
[PermissionTypes.AGENTS]?: {
|
||||
[Permissions.SHARED_GLOBAL]?: boolean;
|
||||
[Permissions.USE]?: boolean;
|
||||
[Permissions.CREATE]?: boolean;
|
||||
};
|
||||
[PermissionTypes.MULTI_CONVO]?: {
|
||||
[Permissions.USE]?: boolean;
|
||||
};
|
||||
[PermissionTypes.TEMPORARY_CHAT]?: {
|
||||
[Permissions.USE]?: boolean;
|
||||
};
|
||||
[PermissionTypes.RUN_CODE]?: {
|
||||
[Permissions.USE]?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Create a sub-schema for permissions. Notice we disable _id for this subdocument.
|
||||
const rolePermissionsSchema = new Schema(
|
||||
{
|
||||
[PermissionTypes.BOOKMARKS]: {
|
||||
[Permissions.USE]: { type: Boolean, default: true },
|
||||
},
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.SHARED_GLOBAL]: { type: Boolean, default: false },
|
||||
[Permissions.USE]: { type: Boolean, default: true },
|
||||
[Permissions.CREATE]: { type: Boolean, default: true },
|
||||
},
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.SHARED_GLOBAL]: { type: Boolean, default: false },
|
||||
[Permissions.USE]: { type: Boolean, default: true },
|
||||
[Permissions.CREATE]: { type: Boolean, default: true },
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: {
|
||||
[Permissions.USE]: { type: Boolean, default: true },
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: {
|
||||
[Permissions.USE]: { type: Boolean, default: true },
|
||||
},
|
||||
[PermissionTypes.RUN_CODE]: {
|
||||
[Permissions.USE]: { type: Boolean, default: true },
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const roleSchema: Schema<IRole> = new Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: {
|
||||
[Permissions.USE]: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.SHARED_GLOBAL]: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
[Permissions.USE]: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
[Permissions.CREATE]: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.SHARED_GLOBAL]: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
[Permissions.USE]: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
[Permissions.CREATE]: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: {
|
||||
[Permissions.USE]: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: {
|
||||
[Permissions.USE]: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
[PermissionTypes.RUN_CODE]: {
|
||||
[Permissions.USE]: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
name: { type: String, required: true, unique: true, index: true },
|
||||
permissions: {
|
||||
type: rolePermissionsSchema,
|
||||
default: () => ({
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.SHARED_GLOBAL]: false,
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
},
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.SHARED_GLOBAL]: false,
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"name": "librechat-mcp",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"version": "1.2.0",
|
||||
"type": "commonjs",
|
||||
"description": "MCP services for LibreChat",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.es.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/types/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"build": "npm run clean && rollup -c --configPlugin=@rollup/plugin-typescript",
|
||||
"build:watch": "rollup -c -w --configPlugin=@rollup/plugin-typescript",
|
||||
"build": "npm run clean && rollup -c --bundleConfigAsCjs",
|
||||
"build:watch": "rollup -c -w --bundleConfigAsCjs",
|
||||
"test": "jest --coverage --watch",
|
||||
"test:ci": "jest --coverage --ci",
|
||||
"verify": "npm run test:ci",
|
||||
|
|
@ -60,7 +60,6 @@
|
|||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-generate-package-json": "^3.2.0",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
"rollup-plugin-typescript2": "^0.35.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
|
|
@ -68,7 +67,7 @@
|
|||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.7.0",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"diff": "^7.0.0",
|
||||
"eventsource": "^3.0.2",
|
||||
"express": "^4.21.2"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// rollup.config.js
|
||||
import typescript from 'rollup-plugin-typescript2';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import { readFileSync } from 'fs';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
|
||||
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
||||
|
||||
|
|
@ -24,16 +24,18 @@ const plugins = [
|
|||
}),
|
||||
typescript({
|
||||
tsconfig: './tsconfig.json',
|
||||
useTsconfigDeclarationDir: true,
|
||||
outDir: './dist',
|
||||
sourceMap: true,
|
||||
inlineSourceMap: true,
|
||||
}),
|
||||
terser(),
|
||||
];
|
||||
|
||||
const esmBuild = {
|
||||
const cjsBuild = {
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
file: pkg.module,
|
||||
format: 'esm',
|
||||
file: pkg.main,
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
exports: 'named',
|
||||
},
|
||||
|
|
@ -42,4 +44,4 @@ const esmBuild = {
|
|||
plugins,
|
||||
};
|
||||
|
||||
export default esmBuild;
|
||||
export default cjsBuild;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ function isSSEOptions(options: t.MCPOptions): options is t.SSEOptions {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const FIVE_MINUTES = 5 * 60 * 1000;
|
||||
export class MCPConnection extends EventEmitter {
|
||||
private static instance: MCPConnection | null = null;
|
||||
public client: Client;
|
||||
|
|
@ -44,21 +46,26 @@ export class MCPConnection extends EventEmitter {
|
|||
private reconnectAttempts = 0;
|
||||
iconPath?: string;
|
||||
timeout?: number;
|
||||
private readonly userId?: string;
|
||||
private lastPingTime: number;
|
||||
|
||||
constructor(
|
||||
serverName: string,
|
||||
private readonly options: t.MCPOptions,
|
||||
private logger?: Logger,
|
||||
userId?: string,
|
||||
) {
|
||||
super();
|
||||
this.serverName = serverName;
|
||||
this.logger = logger;
|
||||
this.userId = userId;
|
||||
this.iconPath = options.iconPath;
|
||||
this.timeout = options.timeout;
|
||||
this.lastPingTime = Date.now();
|
||||
this.client = new Client(
|
||||
{
|
||||
name: 'librechat-mcp-client',
|
||||
version: '1.1.0',
|
||||
version: '1.2.0',
|
||||
},
|
||||
{
|
||||
capabilities: {},
|
||||
|
|
@ -68,13 +75,20 @@ export class MCPConnection extends EventEmitter {
|
|||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/** Helper to generate consistent log prefixes */
|
||||
private getLogPrefix(): string {
|
||||
const userPart = this.userId ? `[User: ${this.userId}]` : '';
|
||||
return `[MCP]${userPart}[${this.serverName}]`;
|
||||
}
|
||||
|
||||
public static getInstance(
|
||||
serverName: string,
|
||||
options: t.MCPOptions,
|
||||
logger?: Logger,
|
||||
userId?: string,
|
||||
): MCPConnection {
|
||||
if (!MCPConnection.instance) {
|
||||
MCPConnection.instance = new MCPConnection(serverName, options, logger);
|
||||
MCPConnection.instance = new MCPConnection(serverName, options, logger, userId);
|
||||
}
|
||||
return MCPConnection.instance;
|
||||
}
|
||||
|
|
@ -92,7 +106,7 @@ export class MCPConnection extends EventEmitter {
|
|||
|
||||
private emitError(error: unknown, errorContext: string): void {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger?.error(`[MCP][${this.serverName}] ${errorContext}: ${errorMessage}`);
|
||||
this.logger?.error(`${this.getLogPrefix()} ${errorContext}: ${errorMessage}`);
|
||||
this.emit('error', new Error(`${errorContext}: ${errorMessage}`));
|
||||
}
|
||||
|
||||
|
|
@ -133,27 +147,28 @@ export class MCPConnection extends EventEmitter {
|
|||
throw new Error('Invalid options for sse transport.');
|
||||
}
|
||||
const url = new URL(options.url);
|
||||
this.logger?.info(`[MCP][${this.serverName}] Creating SSE transport: ${url.toString()}`);
|
||||
this.logger?.info(`${this.getLogPrefix()} Creating SSE transport: ${url.toString()}`);
|
||||
const abortController = new AbortController();
|
||||
const transport = new SSEClientTransport(url, {
|
||||
requestInit: {
|
||||
headers: options.headers,
|
||||
signal: abortController.signal,
|
||||
},
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
this.logger?.info(`[MCP][${this.serverName}] SSE transport closed`);
|
||||
this.logger?.info(`${this.getLogPrefix()} SSE transport closed`);
|
||||
this.emit('connectionChange', 'disconnected');
|
||||
};
|
||||
|
||||
transport.onerror = (error) => {
|
||||
this.logger?.error(`[MCP][${this.serverName}] SSE transport error:`, error);
|
||||
this.logger?.error(`${this.getLogPrefix()} SSE transport error:`, error);
|
||||
this.emitError(error, 'SSE transport error:');
|
||||
};
|
||||
|
||||
transport.onmessage = (message) => {
|
||||
this.logger?.info(
|
||||
`[MCP][${this.serverName}] Message received: ${JSON.stringify(message)}`,
|
||||
`${this.getLogPrefix()} Message received: ${JSON.stringify(message)}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -193,7 +208,7 @@ export class MCPConnection extends EventEmitter {
|
|||
*/
|
||||
} else if (state === 'error' && !this.isReconnecting && !this.isInitializing) {
|
||||
this.handleReconnection().catch((error) => {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Reconnection handler failed:`, error);
|
||||
this.logger?.error(`${this.getLogPrefix()} Reconnection handler failed:`, error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -218,7 +233,7 @@ export class MCPConnection extends EventEmitter {
|
|||
const delay = backoffDelay(this.reconnectAttempts);
|
||||
|
||||
this.logger?.info(
|
||||
`[MCP][${this.serverName}] Reconnecting ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} (delay: ${delay}ms)`,
|
||||
`${this.getLogPrefix()} Reconnecting ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} (delay: ${delay}ms)`,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
|
@ -228,13 +243,13 @@ export class MCPConnection extends EventEmitter {
|
|||
this.reconnectAttempts = 0;
|
||||
return;
|
||||
} catch (error) {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Reconnection attempt failed:`, error);
|
||||
this.logger?.error(`${this.getLogPrefix()} Reconnection attempt failed:`, error);
|
||||
|
||||
if (
|
||||
this.reconnectAttempts === this.MAX_RECONNECT_ATTEMPTS ||
|
||||
(this.shouldStopReconnecting as boolean)
|
||||
) {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Stopping reconnection attempts`);
|
||||
this.logger?.error(`${this.getLogPrefix()} Stopping reconnection attempts`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -278,7 +293,7 @@ export class MCPConnection extends EventEmitter {
|
|||
await this.client.close();
|
||||
this.transport = null;
|
||||
} catch (error) {
|
||||
this.logger?.warn(`[MCP][${this.serverName}] Error closing connection:`, error);
|
||||
this.logger?.warn(`${this.getLogPrefix()} Error closing connection:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -315,15 +330,18 @@ export class MCPConnection extends EventEmitter {
|
|||
}
|
||||
|
||||
this.transport.onmessage = (msg) => {
|
||||
this.logger?.debug(`[MCP][${this.serverName}] Transport received: ${JSON.stringify(msg)}`);
|
||||
this.logger?.debug(`${this.getLogPrefix()} Transport received: ${JSON.stringify(msg)}`);
|
||||
};
|
||||
|
||||
const originalSend = this.transport.send.bind(this.transport);
|
||||
this.transport.send = async (msg) => {
|
||||
if ('result' in msg && !('method' in msg) && Object.keys(msg.result ?? {}).length === 0) {
|
||||
throw new Error('Empty result');
|
||||
if (Date.now() - this.lastPingTime < FIVE_MINUTES) {
|
||||
throw new Error('Empty result');
|
||||
}
|
||||
this.lastPingTime = Date.now();
|
||||
}
|
||||
this.logger?.debug(`[MCP][${this.serverName}] Transport sending: ${JSON.stringify(msg)}`);
|
||||
this.logger?.debug(`${this.getLogPrefix()} Transport sending: ${JSON.stringify(msg)}`);
|
||||
return originalSend(msg);
|
||||
};
|
||||
}
|
||||
|
|
@ -336,28 +354,16 @@ export class MCPConnection extends EventEmitter {
|
|||
throw new Error('Connection not established');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Connection failed:`, error);
|
||||
this.logger?.error(`${this.getLogPrefix()} Connection failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setupTransportErrorHandlers(transport: Transport): void {
|
||||
transport.onerror = (error) => {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Transport error:`, error);
|
||||
this.logger?.error(`${this.getLogPrefix()} Transport error:`, error);
|
||||
this.emit('connectionChange', 'error');
|
||||
};
|
||||
|
||||
const errorHandler = (error: Error) => {
|
||||
try {
|
||||
this.logger?.error(`[MCP][${this.serverName}] Uncaught transport error:`, error);
|
||||
} catch {
|
||||
console.error(`[MCP][${this.serverName}] Critical error logging failed`, error);
|
||||
}
|
||||
this.emit('connectionChange', 'error');
|
||||
};
|
||||
|
||||
process.on('uncaughtException', errorHandler);
|
||||
process.on('unhandledRejection', errorHandler);
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -55,13 +55,18 @@ export class FlowStateManager<T = unknown> {
|
|||
/**
|
||||
* Creates a new flow and waits for its completion
|
||||
*/
|
||||
async createFlow(flowId: string, type: string, metadata: FlowMetadata = {}): Promise<T> {
|
||||
async createFlow(
|
||||
flowId: string,
|
||||
type: string,
|
||||
metadata: FlowMetadata = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const flowKey = this.getFlowKey(flowId, type);
|
||||
|
||||
let existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
||||
if (existingState) {
|
||||
this.logger.debug(`[${flowKey}] Flow already exists`);
|
||||
return this.monitorFlow(flowKey, type);
|
||||
return this.monitorFlow(flowKey, type, signal);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
|
@ -69,7 +74,7 @@ export class FlowStateManager<T = unknown> {
|
|||
existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
||||
if (existingState) {
|
||||
this.logger.debug(`[${flowKey}] Flow exists on 2nd check`);
|
||||
return this.monitorFlow(flowKey, type);
|
||||
return this.monitorFlow(flowKey, type, signal);
|
||||
}
|
||||
|
||||
const initialState: FlowState = {
|
||||
|
|
@ -81,10 +86,10 @@ export class FlowStateManager<T = unknown> {
|
|||
|
||||
this.logger.debug('Creating initial flow state:', flowKey);
|
||||
await this.keyv.set(flowKey, initialState, this.ttl);
|
||||
return this.monitorFlow(flowKey, type);
|
||||
return this.monitorFlow(flowKey, type, signal);
|
||||
}
|
||||
|
||||
private monitorFlow(flowKey: string, type: string): Promise<T> {
|
||||
private monitorFlow(flowKey: string, type: string, signal?: AbortSignal): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const checkInterval = 2000;
|
||||
let elapsedTime = 0;
|
||||
|
|
@ -101,6 +106,16 @@ export class FlowStateManager<T = unknown> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
clearInterval(intervalId);
|
||||
this.intervals.delete(intervalId);
|
||||
this.logger.warn(`[${flowKey}] Flow aborted`);
|
||||
const message = `${type} flow aborted`;
|
||||
await this.keyv.delete(flowKey);
|
||||
reject(new Error(message));
|
||||
return;
|
||||
}
|
||||
|
||||
if (flowState.status !== 'PENDING') {
|
||||
clearInterval(intervalId);
|
||||
this.intervals.delete(intervalId);
|
||||
|
|
@ -197,19 +212,19 @@ export class FlowStateManager<T = unknown> {
|
|||
* @param flowId - The ID of the flow
|
||||
* @param type - The type of flow
|
||||
* @param handler - Async function to execute if no existing flow is found
|
||||
* @param metadata - Optional metadata for the flow
|
||||
* @param signal - Optional AbortSignal to cancel the flow
|
||||
*/
|
||||
async createFlowWithHandler(
|
||||
flowId: string,
|
||||
type: string,
|
||||
handler: () => Promise<T>,
|
||||
metadata: FlowMetadata = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const flowKey = this.getFlowKey(flowId, type);
|
||||
let existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
||||
if (existingState) {
|
||||
this.logger.debug(`[${flowKey}] Flow already exists`);
|
||||
return this.monitorFlow(flowKey, type);
|
||||
return this.monitorFlow(flowKey, type, signal);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
|
@ -217,13 +232,13 @@ export class FlowStateManager<T = unknown> {
|
|||
existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
||||
if (existingState) {
|
||||
this.logger.debug(`[${flowKey}] Flow exists on 2nd check`);
|
||||
return this.monitorFlow(flowKey, type);
|
||||
return this.monitorFlow(flowKey, type, signal);
|
||||
}
|
||||
|
||||
const initialState: FlowState = {
|
||||
type,
|
||||
status: 'PENDING',
|
||||
metadata,
|
||||
metadata: {},
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.logger.debug(`[${flowKey}] Creating initial flow state`);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||
import type { JsonSchemaType, MCPOptions } from 'librechat-data-provider';
|
||||
import type { Logger } from 'winston';
|
||||
|
|
@ -7,9 +7,21 @@ import { formatToolContent } from './parsers';
|
|||
import { MCPConnection } from './connection';
|
||||
import { CONSTANTS } from './enum';
|
||||
|
||||
export interface CallToolOptions extends RequestOptions {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class MCPManager {
|
||||
private static instance: MCPManager | null = null;
|
||||
/** App-level connections initialized at startup */
|
||||
private connections: Map<string, MCPConnection> = new Map();
|
||||
/** User-specific connections initialized on demand */
|
||||
private userConnections: Map<string, Map<string, MCPConnection>> = new Map();
|
||||
/** Last activity timestamp for users (not per server) */
|
||||
private userLastActivity: Map<string, number> = new Map();
|
||||
private readonly USER_CONNECTION_IDLE_TIMEOUT = 15 * 60 * 1000; // 15 minutes (TODO: make configurable)
|
||||
private mcpConfigs: t.MCPServers = {};
|
||||
private processMCPEnv?: (obj: MCPOptions, userId?: string) => MCPOptions; // Store the processing function
|
||||
private logger: Logger;
|
||||
|
||||
private static getDefaultLogger(): Logger {
|
||||
|
|
@ -29,37 +41,39 @@ export class MCPManager {
|
|||
if (!MCPManager.instance) {
|
||||
MCPManager.instance = new MCPManager(logger);
|
||||
}
|
||||
// Check for idle connections when getInstance is called
|
||||
MCPManager.instance.checkIdleConnections();
|
||||
return MCPManager.instance;
|
||||
}
|
||||
|
||||
/** Stores configs and initializes app-level connections */
|
||||
public async initializeMCP(
|
||||
mcpServers: t.MCPServers,
|
||||
processMCPEnv?: (obj: MCPOptions) => MCPOptions,
|
||||
): Promise<void> {
|
||||
this.logger.info('[MCP] Initializing servers');
|
||||
this.logger.info('[MCP] Initializing app-level servers');
|
||||
this.processMCPEnv = processMCPEnv; // Store the function
|
||||
this.mcpConfigs = mcpServers;
|
||||
|
||||
const entries = Object.entries(mcpServers);
|
||||
const initializedServers = new Set();
|
||||
const connectionResults = await Promise.allSettled(
|
||||
entries.map(async ([serverName, _config], i) => {
|
||||
const config = processMCPEnv ? processMCPEnv(_config) : _config;
|
||||
/** Process env for app-level connections */
|
||||
const config = this.processMCPEnv ? this.processMCPEnv(_config) : _config;
|
||||
const connection = new MCPConnection(serverName, config, this.logger);
|
||||
|
||||
connection.on('connectionChange', (state) => {
|
||||
this.logger.info(`[MCP][${serverName}] Connection state: ${state}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const connectionTimeout = new Promise<void>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Connection timeout')), 30000),
|
||||
);
|
||||
|
||||
const connectionAttempt = this.initializeServer(connection, serverName);
|
||||
const connectionAttempt = this.initializeServer(connection, `[MCP][${serverName}]`);
|
||||
await Promise.race([connectionAttempt, connectionTimeout]);
|
||||
|
||||
if (connection.isConnected()) {
|
||||
initializedServers.add(i);
|
||||
this.connections.set(serverName, connection);
|
||||
this.connections.set(serverName, connection); // Store in app-level map
|
||||
|
||||
const serverCapabilities = connection.client.getServerCapabilities();
|
||||
this.logger.info(
|
||||
|
|
@ -88,11 +102,13 @@ export class MCPManager {
|
|||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
|
||||
this.logger.info(`[MCP] Initialized ${initializedServers.size}/${entries.length} server(s)`);
|
||||
this.logger.info(
|
||||
`[MCP] Initialized ${initializedServers.size}/${entries.length} app-level server(s)`,
|
||||
);
|
||||
|
||||
if (failedConnections.length > 0) {
|
||||
this.logger.warn(
|
||||
`[MCP] ${failedConnections.length}/${entries.length} server(s) failed to initialize`,
|
||||
`[MCP] ${failedConnections.length}/${entries.length} app-level server(s) failed to initialize`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -105,49 +121,224 @@ export class MCPManager {
|
|||
});
|
||||
|
||||
if (initializedServers.size === entries.length) {
|
||||
this.logger.info('[MCP] All servers initialized successfully');
|
||||
this.logger.info('[MCP] All app-level servers initialized successfully');
|
||||
} else if (initializedServers.size === 0) {
|
||||
this.logger.error('[MCP] No servers initialized');
|
||||
this.logger.warn('[MCP] No app-level servers initialized');
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeServer(connection: MCPConnection, serverName: string): Promise<void> {
|
||||
/** Generic server initialization logic */
|
||||
private async initializeServer(connection: MCPConnection, logPrefix: string): Promise<void> {
|
||||
const maxAttempts = 3;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
await connection.connect();
|
||||
|
||||
if (connection.isConnected()) {
|
||||
return;
|
||||
}
|
||||
throw new Error('Connection attempt succeeded but status is not connected');
|
||||
} catch (error) {
|
||||
attempts++;
|
||||
|
||||
if (attempts === maxAttempts) {
|
||||
this.logger.error(`[MCP][${serverName}] Failed after ${maxAttempts} attempts`);
|
||||
throw error;
|
||||
this.logger.error(`${logPrefix} Failed to connect after ${maxAttempts} attempts`, error);
|
||||
throw error; // Re-throw the last error
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000 * attempts));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check for and disconnect idle connections */
|
||||
private checkIdleConnections(): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Iterate through all users to check for idle ones
|
||||
for (const [userId, lastActivity] of this.userLastActivity.entries()) {
|
||||
if (now - lastActivity > this.USER_CONNECTION_IDLE_TIMEOUT) {
|
||||
this.logger.info(
|
||||
`[MCP][User: ${userId}] User idle for too long. Disconnecting all connections...`,
|
||||
);
|
||||
// Disconnect all user connections asynchronously (fire and forget)
|
||||
this.disconnectUserConnections(userId).catch((err) =>
|
||||
this.logger.error(`[MCP][User: ${userId}] Error disconnecting idle connections:`, err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Updates the last activity timestamp for a user */
|
||||
private updateUserLastActivity(userId: string): void {
|
||||
const now = Date.now();
|
||||
this.userLastActivity.set(userId, now);
|
||||
this.logger.debug(
|
||||
`[MCP][User: ${userId}] Updated last activity timestamp: ${new Date(now).toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Gets or creates a connection for a specific user */
|
||||
public async getUserConnection(userId: string, serverName: string): Promise<MCPConnection> {
|
||||
const userServerMap = this.userConnections.get(userId);
|
||||
let connection = userServerMap?.get(serverName);
|
||||
const now = Date.now();
|
||||
|
||||
// Check if user is idle
|
||||
const lastActivity = this.userLastActivity.get(userId);
|
||||
if (lastActivity && now - lastActivity > this.USER_CONNECTION_IDLE_TIMEOUT) {
|
||||
this.logger.info(
|
||||
`[MCP][User: ${userId}] User idle for too long. Disconnecting all connections.`,
|
||||
);
|
||||
// Disconnect all user connections
|
||||
await this.disconnectUserConnections(userId).catch((err) =>
|
||||
this.logger.error(`[MCP][User: ${userId}] Error disconnecting idle connections:`, err),
|
||||
);
|
||||
connection = undefined; // Force creation of a new connection
|
||||
} else if (connection) {
|
||||
if (connection.isConnected()) {
|
||||
this.logger.debug(`[MCP][User: ${userId}][${serverName}] Reusing active connection`);
|
||||
// Update timestamp on reuse
|
||||
this.updateUserLastActivity(userId);
|
||||
return connection;
|
||||
} else {
|
||||
// Connection exists but is not connected, attempt to remove potentially stale entry
|
||||
this.logger.warn(
|
||||
`[MCP][User: ${userId}][${serverName}] Found existing but disconnected connection object. Cleaning up.`,
|
||||
);
|
||||
this.removeUserConnection(userId, serverName); // Clean up maps
|
||||
connection = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid connection exists, create a new one
|
||||
if (!connection) {
|
||||
this.logger.info(`[MCP][User: ${userId}][${serverName}] Establishing new connection`);
|
||||
}
|
||||
|
||||
let config = this.mcpConfigs[serverName];
|
||||
if (!config) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
`[MCP][User: ${userId}] Configuration for server "${serverName}" not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.processMCPEnv) {
|
||||
config = { ...(this.processMCPEnv(config, userId) ?? {}) };
|
||||
}
|
||||
|
||||
connection = new MCPConnection(serverName, config, this.logger, userId);
|
||||
|
||||
try {
|
||||
const connectionTimeout = new Promise<void>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Connection timeout')), 30000),
|
||||
);
|
||||
const connectionAttempt = this.initializeServer(
|
||||
connection,
|
||||
`[MCP][User: ${userId}][${serverName}]`,
|
||||
);
|
||||
await Promise.race([connectionAttempt, connectionTimeout]);
|
||||
|
||||
if (!connection.isConnected()) {
|
||||
throw new Error('Failed to establish connection after initialization attempt.');
|
||||
}
|
||||
|
||||
if (!this.userConnections.has(userId)) {
|
||||
this.userConnections.set(userId, new Map());
|
||||
}
|
||||
this.userConnections.get(userId)?.set(serverName, connection);
|
||||
this.logger.info(`[MCP][User: ${userId}][${serverName}] Connection successfully established`);
|
||||
// Update timestamp on creation
|
||||
this.updateUserLastActivity(userId);
|
||||
return connection;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[MCP][User: ${userId}][${serverName}] Failed to establish connection`,
|
||||
error,
|
||||
);
|
||||
// Ensure partial connection state is cleaned up if initialization fails
|
||||
await connection.disconnect().catch((disconnectError) => {
|
||||
this.logger.error(
|
||||
`[MCP][User: ${userId}][${serverName}] Error during cleanup after failed connection`,
|
||||
disconnectError,
|
||||
);
|
||||
});
|
||||
// Ensure cleanup even if connection attempt fails
|
||||
this.removeUserConnection(userId, serverName);
|
||||
throw error; // Re-throw the error to the caller
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a specific user connection entry */
|
||||
private removeUserConnection(userId: string, serverName: string): void {
|
||||
// Remove connection object
|
||||
const userMap = this.userConnections.get(userId);
|
||||
if (userMap) {
|
||||
userMap.delete(serverName);
|
||||
if (userMap.size === 0) {
|
||||
this.userConnections.delete(userId);
|
||||
// Only remove user activity timestamp if all connections are gone
|
||||
this.userLastActivity.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(`[MCP][User: ${userId}][${serverName}] Removed connection entry.`);
|
||||
}
|
||||
|
||||
/** Disconnects and removes a specific user connection */
|
||||
public async disconnectUserConnection(userId: string, serverName: string): Promise<void> {
|
||||
const userMap = this.userConnections.get(userId);
|
||||
const connection = userMap?.get(serverName);
|
||||
if (connection) {
|
||||
this.logger.info(`[MCP][User: ${userId}][${serverName}] Disconnecting...`);
|
||||
await connection.disconnect();
|
||||
this.removeUserConnection(userId, serverName);
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnects and removes all connections for a specific user */
|
||||
public async disconnectUserConnections(userId: string): Promise<void> {
|
||||
const userMap = this.userConnections.get(userId);
|
||||
if (userMap) {
|
||||
this.logger.info(`[MCP][User: ${userId}] Disconnecting all servers...`);
|
||||
const disconnectPromises = Array.from(userMap.keys()).map(async (serverName) => {
|
||||
try {
|
||||
await this.disconnectUserConnection(userId, serverName);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[MCP][User: ${userId}][${serverName}] Error during disconnection:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(disconnectPromises);
|
||||
// Ensure user activity timestamp is removed
|
||||
this.userLastActivity.delete(userId);
|
||||
this.logger.info(`[MCP][User: ${userId}] All connections processed for disconnection.`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the app-level connection (used for mapping tools, etc.) */
|
||||
public getConnection(serverName: string): MCPConnection | undefined {
|
||||
return this.connections.get(serverName);
|
||||
}
|
||||
|
||||
/** Returns all app-level connections */
|
||||
public getAllConnections(): Map<string, MCPConnection> {
|
||||
return this.connections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps available tools from all app-level connections into the provided object.
|
||||
* The object is modified in place.
|
||||
*/
|
||||
public async mapAvailableTools(availableTools: t.LCAvailableTools): Promise<void> {
|
||||
for (const [serverName, connection] of this.connections.entries()) {
|
||||
try {
|
||||
if (connection.isConnected() !== true) {
|
||||
this.logger.warn(`Connection ${serverName} is not connected. Skipping tool fetch.`);
|
||||
this.logger.warn(
|
||||
`[MCP][${serverName}] Connection not established. Skipping tool mapping.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -164,16 +355,21 @@ export class MCPManager {
|
|||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`[MCP][${serverName}] Error fetching tools:`, error);
|
||||
this.logger.warn(`[MCP][${serverName}] Error fetching tools for mapping:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads tools from all app-level connections into the manifest.
|
||||
*/
|
||||
public async loadManifestTools(manifestTools: t.LCToolManifest): Promise<void> {
|
||||
for (const [serverName, connection] of this.connections.entries()) {
|
||||
try {
|
||||
if (connection.isConnected() !== true) {
|
||||
this.logger.warn(`Connection ${serverName} is not connected. Skipping tool fetch.`);
|
||||
this.logger.warn(
|
||||
`[MCP][${serverName}] Connection not established. Skipping manifest loading.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -188,11 +384,16 @@ export class MCPManager {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`[MCP][${serverName}] Error fetching tools:`, error);
|
||||
this.logger.error(`[MCP][${serverName}] Error fetching tools for manifest:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a tool on an MCP server, using either a user-specific connection
|
||||
* (if userId is provided) or an app-level connection. Updates the last activity timestamp
|
||||
* for user-specific connections upon successful call initiation.
|
||||
*/
|
||||
async callTool({
|
||||
serverName,
|
||||
toolName,
|
||||
|
|
@ -204,51 +405,102 @@ export class MCPManager {
|
|||
toolName: string;
|
||||
provider: t.Provider;
|
||||
toolArguments?: Record<string, unknown>;
|
||||
options?: RequestOptions;
|
||||
options?: CallToolOptions;
|
||||
}): Promise<t.FormattedToolResponse> {
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) {
|
||||
throw new Error(
|
||||
`No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
|
||||
);
|
||||
}
|
||||
const result = await connection.client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: toolArguments,
|
||||
let connection: MCPConnection | undefined;
|
||||
const { userId, ...callOptions } = options ?? {};
|
||||
const logPrefix = userId ? `[MCP][User: ${userId}][${serverName}]` : `[MCP][${serverName}]`;
|
||||
|
||||
try {
|
||||
if (userId) {
|
||||
this.updateUserLastActivity(userId);
|
||||
// Get or create user-specific connection
|
||||
connection = await this.getUserConnection(userId, serverName);
|
||||
} else {
|
||||
// Use app-level connection
|
||||
connection = this.connections.get(serverName);
|
||||
if (!connection) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
`${logPrefix} No app-level connection found. Cannot execute tool ${toolName}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!connection.isConnected()) {
|
||||
// This might happen if getUserConnection failed silently or app connection dropped
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError, // Use InternalError for connection issues
|
||||
`${logPrefix} Connection is not active. Cannot execute tool ${toolName}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await connection.client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: toolArguments,
|
||||
},
|
||||
},
|
||||
},
|
||||
CallToolResultSchema,
|
||||
{
|
||||
timeout: connection.timeout,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
return formatToolContent(result, provider);
|
||||
CallToolResultSchema,
|
||||
{
|
||||
timeout: connection.timeout,
|
||||
...callOptions,
|
||||
},
|
||||
);
|
||||
if (userId) {
|
||||
this.updateUserLastActivity(userId);
|
||||
}
|
||||
this.checkIdleConnections();
|
||||
return formatToolContent(result, provider);
|
||||
} catch (error) {
|
||||
// Log with context and re-throw or handle as needed
|
||||
this.logger.error(`${logPrefix}[${toolName}] Tool call failed`, error);
|
||||
// Rethrowing allows the caller (createMCPTool) to handle the final user message
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnects a specific app-level server */
|
||||
public async disconnectServer(serverName: string): Promise<void> {
|
||||
const connection = this.connections.get(serverName);
|
||||
if (connection) {
|
||||
this.logger.info(`[MCP][${serverName}] Disconnecting...`);
|
||||
await connection.disconnect();
|
||||
this.connections.delete(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnects all app-level and user-level connections */
|
||||
public async disconnectAll(): Promise<void> {
|
||||
const disconnectPromises = Array.from(this.connections.values()).map((connection) =>
|
||||
connection.disconnect(),
|
||||
this.logger.info('[MCP] Disconnecting all app-level and user-level connections...');
|
||||
|
||||
const userDisconnectPromises = Array.from(this.userConnections.keys()).map((userId) =>
|
||||
this.disconnectUserConnections(userId),
|
||||
);
|
||||
await Promise.all(disconnectPromises);
|
||||
await Promise.allSettled(userDisconnectPromises);
|
||||
this.userLastActivity.clear();
|
||||
|
||||
// Disconnect all app-level connections
|
||||
const appDisconnectPromises = Array.from(this.connections.values()).map((connection) =>
|
||||
connection.disconnect().catch((error) => {
|
||||
this.logger.error(`[MCP][${connection.serverName}] Error during disconnectAll:`, error);
|
||||
}),
|
||||
);
|
||||
await Promise.allSettled(appDisconnectPromises);
|
||||
this.connections.clear();
|
||||
|
||||
this.logger.info('[MCP] All connections processed for disconnection.');
|
||||
}
|
||||
|
||||
/** Destroys the singleton instance and disconnects all connections */
|
||||
public static async destroyInstance(): Promise<void> {
|
||||
if (MCPManager.instance) {
|
||||
await MCPManager.instance.disconnectAll();
|
||||
MCPManager.instance = null;
|
||||
const logger = MCPManager.getDefaultLogger();
|
||||
logger.info('[MCP] Manager instance destroyed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from 'librechat-data-provider';
|
||||
import type { JsonSchemaType, TPlugin } from 'librechat-data-provider';
|
||||
import { ToolSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type * as t from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
|
||||
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
||||
|
|
@ -44,25 +45,7 @@ export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'err
|
|||
|
||||
export type MCPTool = z.infer<typeof ToolSchema>;
|
||||
export type MCPToolListResponse = z.infer<typeof ListToolsResultSchema>;
|
||||
export type ToolContentPart =
|
||||
| {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'image';
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
| {
|
||||
type: 'resource';
|
||||
resource: {
|
||||
uri: string;
|
||||
mimeType?: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
};
|
||||
};
|
||||
export type ToolContentPart = t.TextContent | t.ImageContent | t.EmbeddedResource | t.AudioContent;
|
||||
export type ImageContent = Extract<ToolContentPart, { type: 'image' }>;
|
||||
export type MCPToolCallResponse =
|
||||
| undefined
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue