Merge branch 'main' into feat/multi-lang-Terms-of-service

This commit is contained in:
Ruben Talstra 2025-04-05 15:09:43 +02:00 committed by GitHub
commit 7c0324695a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
258 changed files with 8260 additions and 3717 deletions

View file

@ -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()
@ -861,6 +863,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',
@ -1004,6 +1008,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',
}
/**
@ -1096,6 +1104,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',
}
/**
@ -1208,7 +1220,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 */

View file

@ -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';

View file

@ -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;

View file

@ -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>;

View file

@ -375,9 +375,23 @@ export function parseTextParts(contentParts: a.TMessageContentParts[]): 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) {
const textValue = typeof part.think === 'string' ? part.think : '';
if (
result.length > 0 &&
textValue.length > 0 &&

View 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,
});

View file

@ -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,72 @@ 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]: {},
[PermissionTypes.BOOKMARKS]: {},
[PermissionTypes.AGENTS]: {},
[PermissionTypes.MULTI_CONVO]: {},
[PermissionTypes.TEMPORARY_CHAT]: {},
[PermissionTypes.RUN_CODE]: {},
},
},
[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]: {},
},
},
});

View file

@ -230,7 +230,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 +645,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,
/**

View file

@ -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;
@ -57,7 +59,6 @@ export type TSubmission = {
isTemporary: boolean;
messages: TMessage[];
isRegenerate?: boolean;
conversationId?: string;
initialResponse?: TMessage;
conversation: Partial<TConversation>;
endpointOption: TEndpointOption;

View file

@ -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[] }
| {

View file

@ -4,6 +4,7 @@ export enum FileSources {
local = 'local',
firebase = 'firebase',
azure = 'azure',
azure_blob = 'azure_blob',
openai = 'openai',
s3 = 's3',
vectordb = 'vectordb',

View file

@ -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;

View file

@ -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 = [

View file

@ -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();
}

View file

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

View file

@ -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 },
}),
},
});

View file

@ -68,7 +68,7 @@
"registry": "https://registry.npmjs.org/"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.7.0",
"@modelcontextprotocol/sdk": "^1.8.0",
"diff": "^7.0.0",
"eventsource": "^3.0.2",
"express": "^4.21.2"

View file

@ -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> {

View file

@ -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.');
}
}
}