Compare commits

...

10 Commits

Author SHA1 Message Date
Dustin Healy
351f30254c feat: Implement MCP Server Management API and UI Components
- Added API endpoints for managing MCP servers: create, read, update, and delete functionalities.
- Introduced new UI components for MCP server configuration, including MCPFormPanel and MCPConfig.
- Updated existing types and data provider to support MCP operations.
- Enhanced the side panel to include MCP server management options.
- Refactored related components and hooks for better integration with the new MCP features.
- Added tests for the new MCP server API functionalities.
2025-06-29 17:55:09 -07:00
Danny Avila
20100e120b 🔑 feat: Set Google Service Key File Path (#8130) 2025-06-29 17:09:37 -04:00
Danny Avila
3f3cfefc52 🗒️ feat: Add Google Vertex AI Mistral OCR Strategy (#8125)
* Implemented new uploadGoogleVertexMistralOCR function for processing OCR using Google Vertex AI.
* Added vertexMistralOCRStrategy to handle file uploads.
* Updated FileSources and OCRStrategy enums to include vertexai_mistral_ocr.
* Introduced helper functions for JWT creation and Google service account configuration loading.
2025-06-28 13:26:03 -04:00
matt burnett
3e1591d404 🤖 fix: Remove versions and __v when Duplicating an Agent (#8115)
Revert "Add tests for agent duplication controller"

This reverts commit 3e7beb1cc336bcfe1c57411e9c151f5e6aa927e4.
2025-06-28 12:35:41 -04:00
Danny Avila
1060ae8040 🐛 fix: Assistants Endpoint Handling in createPayload Function (#8123)
* 📦 chore: bump librechat-data-provider version to 0.7.89

* 🐛 fix: Assistants endpoint handling in createPayload function
2025-06-28 12:33:43 -04:00
Danny Avila
dd67e463e4 📦 chore: bump pbkdf2 to v3.1.3 (#8091) 2025-06-26 19:19:04 -04:00
github-actions[bot]
d60ad61325 🌍 i18n: Update translation.json with latest translations (#8058)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-26 19:12:46 -04:00
Danny Avila
452151e408 🐛 fix: RAG API failing with OPENID_REUSE_TOKENS Enabled (#8090)
* feat: Implement Short-Lived JWT Token Generation for RAG API

* fix: Update import paths

* fix: Correct environment variable names for OpenID on behalf flow

* fix: Remove unnecessary spaces in OpenID on behalf flow userinfo scope

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
2025-06-26 19:10:21 -04:00
Danny Avila
33b4a97b42 🔒 fix: Agents Config/Permission Checks after Streamline Change (#8089)
* refactor: access control logic to TypeScript

* chore: Change EndpointURLs to a constant object for improved type safety

* 🐛 fix: Enhance agent access control by adding skipAgentCheck functionality

* 🐛 fix: Add endpointFileConfig prop to AttachFileMenu and update file handling logic

* 🐛 fix: Update tool handling logic to support optional groupedTools and improve null checks, add dedicated tool dialog for Assistants

* chore: Export Accordion component from UI index for improved modularity

* feat: Add ActivePanelContext for managing active panel state across components

* chore: Replace string IDs with EModelEndpoint constants for assistants and agents in useSideNavLinks

* fix: Integrate access checks for agent creation and deletion routes in actions.js
2025-06-26 18:53:05 -04:00
Sebastien Bruel
9cdc62b655 📂 fix: Prevent Null Reference Errors in File Process (#8084) 2025-06-26 18:51:35 -04:00
73 changed files with 2673 additions and 578 deletions

View File

@@ -453,8 +453,8 @@ OPENID_REUSE_TOKENS=
OPENID_JWKS_URL_CACHE_ENABLED=
OPENID_JWKS_URL_CACHE_TIME= # 600000 ms eq to 10 minutes leave empty to disable caching
#Set to true to trigger token exchange flow to acquire access token for the userinfo endpoint.
OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED=
OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE = "user.read" # example for Scope Needed for Microsoft Graph API
OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED=
OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API
# Set to true to use the OpenID Connect end session endpoint for logout
OPENID_USE_END_SESSION_ENDPOINT=

View File

@@ -1,6 +1,7 @@
const axios = require('axios');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const footer = `Use the context as your learned knowledge to better answer the user.
@@ -18,7 +19,7 @@ function createContextHandlers(req, userMessageContent) {
const queryPromises = [];
const processedFiles = [];
const processedIds = new Set();
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
const useFullContext = isEnabled(process.env.RAG_USE_FULL_CONTEXT);
const query = async (file) => {

View File

@@ -1,9 +1,10 @@
const { z } = require('zod');
const axios = require('axios');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { Tools, EToolResources } = require('librechat-data-provider');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const { getFiles } = require('~/models/File');
const { logger } = require('~/config');
/**
*
@@ -59,7 +60,7 @@ const createFileSearchTool = async ({ req, files, entity_id }) => {
if (files.length === 0) {
return 'No files to search. Instruct the user to add files for the search.';
}
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
if (!jwtToken) {
return 'There was an error authenticating the file search request.';
}

View File

@@ -0,0 +1,195 @@
const { duplicateAgent } = require('../v1');
const { getAgent, createAgent } = require('~/models/Agent');
const { getActions } = require('~/models/Action');
const { nanoid } = require('nanoid');
jest.mock('~/models/Agent');
jest.mock('~/models/Action');
jest.mock('nanoid');
describe('duplicateAgent', () => {
let req, res;
beforeEach(() => {
req = {
params: { id: 'agent_123' },
user: { id: 'user_456' },
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
jest.clearAllMocks();
});
it('should duplicate an agent successfully', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
author: 'user_789',
versions: [{ name: 'Test Agent', version: 1 }],
__v: 0,
};
const mockNewAgent = {
id: 'agent_new_123',
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
author: 'user_456',
versions: [
{
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
createdAt: new Date(),
updatedAt: new Date(),
},
],
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue(mockNewAgent);
await duplicateAgent(req, res);
expect(getAgent).toHaveBeenCalledWith({ id: 'agent_123' });
expect(getActions).toHaveBeenCalledWith({ agent_id: 'agent_123' }, true);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
id: 'agent_new_123',
author: 'user_456',
name: expect.stringContaining('Test Agent ('),
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
}),
);
expect(createAgent).toHaveBeenCalledWith(
expect.not.objectContaining({
versions: expect.anything(),
__v: expect.anything(),
}),
);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
agent: mockNewAgent,
actions: [],
});
});
it('should ensure duplicated agent has clean versions array without nested fields', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
description: 'Test Description',
versions: [
{
name: 'Test Agent',
versions: [{ name: 'Nested' }],
__v: 1,
},
],
__v: 2,
};
const mockNewAgent = {
id: 'agent_new_123',
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
versions: [
{
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
createdAt: new Date(),
updatedAt: new Date(),
},
],
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue(mockNewAgent);
await duplicateAgent(req, res);
expect(mockNewAgent.versions).toHaveLength(1);
const firstVersion = mockNewAgent.versions[0];
expect(firstVersion).not.toHaveProperty('versions');
expect(firstVersion).not.toHaveProperty('__v');
expect(mockNewAgent).not.toHaveProperty('__v');
expect(res.status).toHaveBeenCalledWith(201);
});
it('should return 404 if agent not found', async () => {
getAgent.mockResolvedValue(null);
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Agent not found',
status: 'error',
});
});
it('should handle tool_resources.ocr correctly', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
tool_resources: {
ocr: { enabled: true, config: 'test' },
other: { should: 'not be copied' },
},
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue({ id: 'agent_new_123' });
await duplicateAgent(req, res);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
tool_resources: {
ocr: { enabled: true, config: 'test' },
},
}),
);
});
it('should handle errors gracefully', async () => {
getAgent.mockRejectedValue(new Error('Database error'));
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Database error' });
});
});

View File

@@ -4,6 +4,7 @@ const {
sendEvent,
createRun,
Tokenizer,
checkAccess,
memoryInstructions,
createMemoryProcessor,
} = require('@librechat/api');
@@ -39,8 +40,8 @@ const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { getProviderConfig } = require('~/server/services/Endpoints');
const { checkAccess } = require('~/server/middleware/roles/access');
const BaseClient = require('~/app/clients/BaseClient');
const { getRoleByName } = require('~/models/Role');
const { loadAgent } = require('~/models/Agent');
const { getMCPManager } = require('~/config');
@@ -401,7 +402,12 @@ class AgentClient extends BaseClient {
if (user.personalization?.memories === false) {
return;
}
const hasAccess = await checkAccess(user, PermissionTypes.MEMORIES, [Permissions.USE]);
const hasAccess = await checkAccess({
user,
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE],
getRoleByName,
});
if (!hasAccess) {
logger.debug(

View File

@@ -242,6 +242,8 @@ const duplicateAgentHandler = async (req, res) => {
createdAt: _createdAt,
updatedAt: _updatedAt,
tool_resources: _tool_resources = {},
versions: _versions,
__v: _v,
...cloneData
} = agent;
cloneData.name = `${agent.name} (${new Date().toLocaleString('en-US', {

View File

@@ -1,5 +1,7 @@
const { nanoid } = require('nanoid');
const { EnvVar } = require('@librechat/agents');
const { checkAccess } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Tools,
AuthType,
@@ -13,9 +15,8 @@ const { processCodeOutput } = require('~/server/services/Files/Code/process');
const { createToolCall, getToolCallsByConvo } = require('~/models/ToolCall');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { loadTools } = require('~/app/clients/tools/util');
const { checkAccess } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const { getMessage } = require('~/models/Message');
const { logger } = require('~/config');
const fieldsMap = {
[Tools.execute_code]: [EnvVar.CODE_API_KEY],
@@ -79,6 +80,7 @@ const verifyToolAuth = async (req, res) => {
throwError: false,
});
} catch (error) {
logger.error('Error loading auth values', error);
res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED });
return;
}
@@ -132,7 +134,12 @@ const callTool = async (req, res) => {
logger.debug(`[${toolId}/call] User: ${req.user.id}`);
let hasAccess = true;
if (toolAccessPermType[toolId]) {
hasAccess = await checkAccess(req.user, toolAccessPermType[toolId], [Permissions.USE]);
hasAccess = await checkAccess({
user: req.user,
permissionType: toolAccessPermType[toolId],
permissions: [Permissions.USE],
getRoleByName,
});
}
if (!hasAccess) {
logger.warn(

View File

@@ -1,78 +0,0 @@
const { getRoleByName } = require('~/models/Role');
const { logger } = require('~/config');
/**
* Core function to check if a user has one or more required permissions
*
* @param {object} user - The user object
* @param {PermissionTypes} permissionType - The type of permission to check
* @param {Permissions[]} permissions - The list of specific permissions to check
* @param {Record<Permissions, string[]>} [bodyProps] - An optional object where keys are permissions and values are arrays of properties to check
* @param {object} [checkObject] - The object to check properties against
* @returns {Promise<boolean>} Whether the user has the required permissions
*/
const checkAccess = async (user, permissionType, permissions, bodyProps = {}, checkObject = {}) => {
if (!user) {
return false;
}
const role = await getRoleByName(user.role);
if (role && role.permissions && role.permissions[permissionType]) {
const hasAnyPermission = permissions.some((permission) => {
if (role.permissions[permissionType][permission]) {
return true;
}
if (bodyProps[permission] && checkObject) {
return bodyProps[permission].some((prop) =>
Object.prototype.hasOwnProperty.call(checkObject, prop),
);
}
return false;
});
return hasAnyPermission;
}
return false;
};
/**
* Middleware to check if a user has one or more required permissions, optionally based on `req.body` properties.
*
* @param {PermissionTypes} permissionType - The type of permission to check.
* @param {Permissions[]} permissions - The list of specific permissions to check.
* @param {Record<Permissions, string[]>} [bodyProps] - An optional object where keys are permissions and values are arrays of `req.body` properties to check.
* @returns {(req: ServerRequest, res: ServerResponse, next: NextFunction) => Promise<void>} Express middleware function.
*/
const generateCheckAccess = (permissionType, permissions, bodyProps = {}) => {
return async (req, res, next) => {
try {
const hasAccess = await checkAccess(
req.user,
permissionType,
permissions,
bodyProps,
req.body,
);
if (hasAccess) {
return next();
}
logger.warn(
`[${permissionType}] Forbidden: Insufficient permissions for User ${req.user.id}: ${permissions.join(', ')}`,
);
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
} catch (error) {
logger.error(error);
return res.status(500).json({ message: `Server error: ${error.message}` });
}
};
};
module.exports = {
checkAccess,
generateCheckAccess,
};

View File

@@ -1,8 +1,5 @@
const checkAdmin = require('./admin');
const { checkAccess, generateCheckAccess } = require('./access');
module.exports = {
checkAdmin,
checkAccess,
generateCheckAccess,
};

View File

@@ -1,14 +1,28 @@
const express = require('express');
const { nanoid } = require('nanoid');
const { actionDelimiter, SystemRoles, removeNullishValues } = require('librechat-data-provider');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const {
SystemRoles,
Permissions,
PermissionTypes,
actionDelimiter,
removeNullishValues,
} = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { getAgent, updateAgent } = require('~/models/Agent');
const { logger } = require('~/config');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
const checkAgentCreate = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
// If the user has ADMIN role
// then action edition is possible even if not owner of the assistant
const isAdmin = (req) => {
@@ -41,7 +55,7 @@ router.get('/', async (req, res) => {
* @param {ActionMetadata} req.body.metadata - Metadata for the action.
* @returns {Object} 200 - success response - application/json
*/
router.post('/:agent_id', async (req, res) => {
router.post('/:agent_id', checkAgentCreate, async (req, res) => {
try {
const { agent_id } = req.params;
@@ -149,7 +163,7 @@ router.post('/:agent_id', async (req, res) => {
* @param {string} req.params.action_id - The ID of the action to delete.
* @returns {Object} 200 - success response - application/json
*/
router.delete('/:agent_id/:action_id', async (req, res) => {
router.delete('/:agent_id/:action_id', checkAgentCreate, async (req, res) => {
try {
const { agent_id, action_id } = req.params;
const admin = isAdmin(req);

View File

@@ -1,22 +1,28 @@
const express = require('express');
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
setHeaders,
moderateText,
// validateModel,
generateCheckAccess,
validateConvoAccess,
buildEndpointOption,
} = require('~/server/middleware');
const { initializeClient } = require('~/server/services/Endpoints/agents');
const AgentController = require('~/server/controllers/agents/request');
const addTitle = require('~/server/services/Endpoints/agents/title');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
router.use(moderateText);
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
const checkAgentAccess = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
skipCheck: skipAgentCheck,
getRoleByName,
});
router.use(checkAgentAccess);
router.use(validateConvoAccess);

View File

@@ -1,29 +1,36 @@
const express = require('express');
const { generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { requireJwtAuth } = require('~/server/middleware');
const v1 = require('~/server/controllers/agents/v1');
const { getRoleByName } = require('~/models/Role');
const actions = require('./actions');
const tools = require('./tools');
const router = express.Router();
const avatar = express.Router();
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
const checkAgentCreate = generateCheckAccess(PermissionTypes.AGENTS, [
Permissions.USE,
Permissions.CREATE,
]);
const checkAgentAccess = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
const checkAgentCreate = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
const checkGlobalAgentShare = generateCheckAccess(
PermissionTypes.AGENTS,
[Permissions.USE, Permissions.CREATE],
{
const checkGlobalAgentShare = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
bodyProps: {
[Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
},
);
getRoleByName,
});
router.use(requireJwtAuth);
router.use(checkAgentAccess);
/**
* Agent actions route.

View File

@@ -5,6 +5,13 @@ const { CacheKeys } = require('librechat-data-provider');
const { requireJwtAuth } = require('~/server/middleware');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const {
getMCPServers,
getMCPServer,
createMCPServer,
updateMCPServer,
deleteMCPServer,
} = require('@librechat/api');
const router = Router();
@@ -202,4 +209,44 @@ router.get('/oauth/status/:flowId', async (req, res) => {
}
});
/**
* Get all MCP servers for the authenticated user
* @route GET /api/mcp
* @returns {Array} Array of MCP servers
*/
router.get('/', requireJwtAuth, getMCPServers);
/**
* Get a single MCP server by ID
* @route GET /api/mcp/:mcp_id
* @param {string} mcp_id - The ID of the MCP server to fetch
* @returns {object} MCP server data
*/
router.get('/:mcp_id', requireJwtAuth, getMCPServer);
/**
* Create a new MCP server
* @route POST /api/mcp/add
* @param {object} req.body - MCP server data
* @returns {object} Created MCP server with populated tools
*/
router.post('/add', requireJwtAuth, createMCPServer);
/**
* Update an existing MCP server
* @route PUT /api/mcp/:mcp_id
* @param {string} mcp_id - The ID of the MCP server to update
* @param {object} req.body - Updated MCP server data
* @returns {object} Updated MCP server with populated tools
*/
router.put('/:mcp_id', requireJwtAuth, updateMCPServer);
/**
* Delete an MCP server
* @route DELETE /api/mcp/:mcp_id
* @param {string} mcp_id - The ID of the MCP server to delete
* @returns {object} Deletion confirmation
*/
router.delete('/:mcp_id', requireJwtAuth, deleteMCPServer);
module.exports = router;

View File

@@ -1,37 +1,43 @@
const express = require('express');
const { Tokenizer } = require('@librechat/api');
const { Tokenizer, generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
getAllUserMemories,
toggleUserMemories,
createMemory,
setMemory,
deleteMemory,
setMemory,
} = require('~/models');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { requireJwtAuth } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
const checkMemoryRead = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.READ,
]);
const checkMemoryCreate = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.CREATE,
]);
const checkMemoryUpdate = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.UPDATE,
]);
const checkMemoryDelete = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.UPDATE,
]);
const checkMemoryOptOut = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.OPT_OUT,
]);
const checkMemoryRead = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.READ],
getRoleByName,
});
const checkMemoryCreate = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
const checkMemoryUpdate = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.UPDATE],
getRoleByName,
});
const checkMemoryDelete = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.UPDATE],
getRoleByName,
});
const checkMemoryOptOut = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.OPT_OUT],
getRoleByName,
});
router.use(requireJwtAuth);

View File

@@ -1,5 +1,7 @@
const express = require('express');
const { PermissionTypes, Permissions, SystemRoles } = require('librechat-data-provider');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const { Permissions, SystemRoles, PermissionTypes } = require('librechat-data-provider');
const {
getPrompt,
getPrompts,
@@ -14,24 +16,30 @@ const {
// updatePromptLabels,
makePromptProduction,
} = require('~/models/Prompt');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { logger } = require('~/config');
const { requireJwtAuth } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
const checkPromptAccess = generateCheckAccess(PermissionTypes.PROMPTS, [Permissions.USE]);
const checkPromptCreate = generateCheckAccess(PermissionTypes.PROMPTS, [
Permissions.USE,
Permissions.CREATE,
]);
const checkPromptAccess = generateCheckAccess({
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE],
getRoleByName,
});
const checkPromptCreate = generateCheckAccess({
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
const checkGlobalPromptShare = generateCheckAccess(
PermissionTypes.PROMPTS,
[Permissions.USE, Permissions.CREATE],
{
const checkGlobalPromptShare = generateCheckAccess({
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE, Permissions.CREATE],
bodyProps: {
[Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
},
);
getRoleByName,
});
router.use(requireJwtAuth);
router.use(checkPromptAccess);

View File

@@ -1,18 +1,24 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
getConversationTags,
updateTagsForConversation,
updateConversationTag,
createConversationTag,
deleteConversationTag,
updateTagsForConversation,
getConversationTags,
} = require('~/models/ConversationTag');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { logger } = require('~/config');
const { requireJwtAuth } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
const checkBookmarkAccess = generateCheckAccess(PermissionTypes.BOOKMARKS, [Permissions.USE]);
const checkBookmarkAccess = generateCheckAccess({
permissionType: PermissionTypes.BOOKMARKS,
permissions: [Permissions.USE],
getRoleByName,
});
router.use(requireJwtAuth);
router.use(checkBookmarkAccess);

View File

@@ -1,4 +1,5 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { webcrypto } = require('node:crypto');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
@@ -499,6 +500,18 @@ const resendVerificationEmail = async (req) => {
};
}
};
/**
* Generate a short-lived JWT token
* @param {String} userId - The ID of the user
* @param {String} [expireIn='5m'] - The expiration time for the token (default is 5 minutes)
* @returns {String} - The generated JWT token
*/
const generateShortLivedToken = (userId, expireIn = '5m') => {
return jwt.sign({ id: userId }, process.env.JWT_SECRET, {
expiresIn: expireIn,
algorithm: 'HS256',
});
};
module.exports = {
logoutUser,
@@ -506,7 +519,8 @@ module.exports = {
registerUser,
setAuthTokens,
resetPassword,
setOpenIDAuthTokens,
requestPasswordReset,
resendVerificationEmail,
setOpenIDAuthTokens,
generateShortLivedToken,
};

View File

@@ -1,3 +1,5 @@
const fs = require('fs');
const path = require('path');
const { EModelEndpoint } = require('librechat-data-provider');
const { isUserProvided } = require('~/server/utils');
const { config } = require('./EndpointService');
@@ -11,9 +13,21 @@ const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, go
async function loadAsyncEndpoints(req) {
let i = 0;
let serviceKey, googleUserProvides;
const serviceKeyPath =
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
path.join(__dirname, '../../..', 'data', 'auth.json');
try {
serviceKey = require('~/data/auth.json');
} catch (e) {
if (process.env.GOOGLE_SERVICE_KEY_FILE_PATH) {
const absolutePath = path.isAbsolute(serviceKeyPath)
? serviceKeyPath
: path.resolve(serviceKeyPath);
const fileContent = fs.readFileSync(absolutePath, 'utf8');
serviceKey = JSON.parse(fileContent);
} else {
serviceKey = require('~/data/auth.json');
}
} catch {
if (i === 0) {
i++;
}
@@ -32,14 +46,14 @@ async function loadAsyncEndpoints(req) {
const gptPlugins =
useAzure || openAIApiKey || azureOpenAIApiKey
? {
availableAgents: ['classic', 'functions'],
userProvide: useAzure ? false : userProvidedOpenAI,
userProvideURL: useAzure
? false
: config[EModelEndpoint.openAI]?.userProvideURL ||
availableAgents: ['classic', 'functions'],
userProvide: useAzure ? false : userProvidedOpenAI,
userProvideURL: useAzure
? false
: config[EModelEndpoint.openAI]?.userProvideURL ||
config[EModelEndpoint.azureOpenAI]?.userProvideURL,
azure: useAzurePlugins || useAzure,
}
azure: useAzurePlugins || useAzure,
}
: false;
return { google, gptPlugins };

View File

@@ -1,3 +1,5 @@
const fs = require('fs');
const path = require('path');
const { getGoogleConfig, isEnabled } = require('@librechat/api');
const { EModelEndpoint, AuthKeys } = require('librechat-data-provider');
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
@@ -15,8 +17,20 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
}
let serviceKey = {};
try {
serviceKey = require('~/data/auth.json');
if (process.env.GOOGLE_SERVICE_KEY_FILE_PATH) {
const serviceKeyPath =
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
path.join(__dirname, '../../../../..', 'data', 'auth.json');
const absolutePath = path.isAbsolute(serviceKeyPath)
? serviceKeyPath
: path.resolve(serviceKeyPath);
const fileContent = fs.readFileSync(absolutePath, 'utf8');
serviceKey = JSON.parse(fileContent);
} else {
serviceKey = require('~/data/auth.json');
}
} catch (_e) {
// Do nothing
}

View File

@@ -1,10 +1,11 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const { logger } = require('@librechat/data-schemas');
const { EModelEndpoint } = require('librechat-data-provider');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const { getBufferMetadata } = require('~/server/utils');
const paths = require('~/config/paths');
const { logger } = require('~/config');
/**
* Saves a file to a specified output path with a new filename.
@@ -206,7 +207,7 @@ const deleteLocalFile = async (req, file) => {
const cleanFilepath = file.filepath.split('?')[0];
if (file.embedded && process.env.RAG_API_URL) {
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
axios.delete(`${process.env.RAG_API_URL}/documents`, {
headers: {
Authorization: `Bearer ${jwtToken}`,

View File

@@ -4,6 +4,7 @@ const FormData = require('form-data');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { FileSources } = require('librechat-data-provider');
const { generateShortLivedToken } = require('~/server/services/AuthService');
/**
* Deletes a file from the vector database. This function takes a file object, constructs the full path, and
@@ -23,7 +24,8 @@ const deleteVectors = async (req, file) => {
return;
}
try {
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
return await axios.delete(`${process.env.RAG_API_URL}/documents`, {
headers: {
Authorization: `Bearer ${jwtToken}`,
@@ -70,7 +72,7 @@ async function uploadVectors({ req, file, file_id, entity_id }) {
}
try {
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
const formData = new FormData();
formData.append('file_id', file_id);
formData.append('file', fs.createReadStream(file.path));

View File

@@ -55,7 +55,9 @@ const processFiles = async (files, fileIds) => {
}
if (!fileIds) {
return await Promise.all(promises);
const results = await Promise.all(promises);
// Filter out null results from failed updateFileUsage calls
return results.filter((result) => result != null);
}
for (let file_id of fileIds) {
@@ -67,7 +69,9 @@ const processFiles = async (files, fileIds) => {
}
// TODO: calculate token cost when image is first uploaded
return await Promise.all(promises);
const results = await Promise.all(promises);
// Filter out null results from failed updateFileUsage calls
return results.filter((result) => result != null);
};
/**

View File

@@ -0,0 +1,208 @@
// Mock the updateFileUsage function before importing the actual processFiles
jest.mock('~/models/File', () => ({
updateFileUsage: jest.fn(),
}));
// Mock winston and logger configuration to avoid dependency issues
jest.mock('~/config', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
}));
// Mock all other dependencies that might cause issues
jest.mock('librechat-data-provider', () => ({
isUUID: { parse: jest.fn() },
megabyte: 1024 * 1024,
FileContext: { message_attachment: 'message_attachment' },
FileSources: { local: 'local' },
EModelEndpoint: { assistants: 'assistants' },
EToolResources: { file_search: 'file_search' },
mergeFileConfig: jest.fn(),
removeNullishValues: jest.fn((obj) => obj),
isAssistantsEndpoint: jest.fn(),
}));
jest.mock('~/server/services/Files/images', () => ({
convertImage: jest.fn(),
resizeAndConvert: jest.fn(),
resizeImageBuffer: jest.fn(),
}));
jest.mock('~/server/controllers/assistants/v2', () => ({
addResourceFileId: jest.fn(),
deleteResourceFileId: jest.fn(),
}));
jest.mock('~/models/Agent', () => ({
addAgentResourceFile: jest.fn(),
removeAgentResourceFiles: jest.fn(),
}));
jest.mock('~/server/controllers/assistants/helpers', () => ({
getOpenAIClient: jest.fn(),
}));
jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
checkCapability: jest.fn(),
}));
jest.mock('~/server/utils/queue', () => ({
LB_QueueAsyncCall: jest.fn(),
}));
jest.mock('./strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
jest.mock('~/server/utils', () => ({
determineFileType: jest.fn(),
}));
// Import the actual processFiles function after all mocks are set up
const { processFiles } = require('./process');
const { updateFileUsage } = require('~/models/File');
describe('processFiles', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('null filtering functionality', () => {
it('should filter out null results from updateFileUsage when files do not exist', async () => {
const mockFiles = [
{ file_id: 'existing-file-1' },
{ file_id: 'non-existent-file' },
{ file_id: 'existing-file-2' },
];
// Mock updateFileUsage to return null for non-existent files
updateFileUsage.mockImplementation(({ file_id }) => {
if (file_id === 'non-existent-file') {
return Promise.resolve(null); // Simulate file not found in the database
}
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
expect(updateFileUsage).toHaveBeenCalledTimes(3);
expect(result).toEqual([
{ file_id: 'existing-file-1', usage: 1 },
{ file_id: 'existing-file-2', usage: 1 },
]);
// Critical test - ensure no null values in result
expect(result).not.toContain(null);
expect(result).not.toContain(undefined);
expect(result.length).toBe(2); // Only valid files should be returned
});
it('should return empty array when all updateFileUsage calls return null', async () => {
const mockFiles = [{ file_id: 'non-existent-1' }, { file_id: 'non-existent-2' }];
// All updateFileUsage calls return null
updateFileUsage.mockResolvedValue(null);
const result = await processFiles(mockFiles);
expect(updateFileUsage).toHaveBeenCalledTimes(2);
expect(result).toEqual([]);
expect(result).not.toContain(null);
expect(result.length).toBe(0);
});
it('should work correctly when all files exist', async () => {
const mockFiles = [{ file_id: 'file-1' }, { file_id: 'file-2' }];
updateFileUsage.mockImplementation(({ file_id }) => {
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
expect(result).toEqual([
{ file_id: 'file-1', usage: 1 },
{ file_id: 'file-2', usage: 1 },
]);
expect(result).not.toContain(null);
expect(result.length).toBe(2);
});
it('should handle fileIds parameter and filter nulls correctly', async () => {
const mockFiles = [{ file_id: 'file-1' }];
const mockFileIds = ['file-2', 'non-existent-file'];
updateFileUsage.mockImplementation(({ file_id }) => {
if (file_id === 'non-existent-file') {
return Promise.resolve(null);
}
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles, mockFileIds);
expect(result).toEqual([
{ file_id: 'file-1', usage: 1 },
{ file_id: 'file-2', usage: 1 },
]);
expect(result).not.toContain(null);
expect(result).not.toContain(undefined);
expect(result.length).toBe(2);
});
it('should handle duplicate file_ids correctly', async () => {
const mockFiles = [
{ file_id: 'duplicate-file' },
{ file_id: 'duplicate-file' }, // Duplicate should be ignored
{ file_id: 'unique-file' },
];
updateFileUsage.mockImplementation(({ file_id }) => {
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
// Should only call updateFileUsage twice (duplicate ignored)
expect(updateFileUsage).toHaveBeenCalledTimes(2);
expect(result).toEqual([
{ file_id: 'duplicate-file', usage: 1 },
{ file_id: 'unique-file', usage: 1 },
]);
expect(result.length).toBe(2);
});
});
describe('edge cases', () => {
it('should handle empty files array', async () => {
const result = await processFiles([]);
expect(result).toEqual([]);
expect(updateFileUsage).not.toHaveBeenCalled();
});
it('should handle mixed null and undefined returns from updateFileUsage', async () => {
const mockFiles = [{ file_id: 'file-1' }, { file_id: 'file-2' }, { file_id: 'file-3' }];
updateFileUsage.mockImplementation(({ file_id }) => {
if (file_id === 'file-1') return Promise.resolve(null);
if (file_id === 'file-2') return Promise.resolve(undefined);
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
expect(result).toEqual([{ file_id: 'file-3', usage: 1 }]);
expect(result).not.toContain(null);
expect(result).not.toContain(undefined);
expect(result.length).toBe(1);
});
});
});

View File

@@ -1,5 +1,9 @@
const { FileSources } = require('librechat-data-provider');
const { uploadMistralOCR, uploadAzureMistralOCR } = require('@librechat/api');
const {
uploadMistralOCR,
uploadAzureMistralOCR,
uploadGoogleVertexMistralOCR,
} = require('@librechat/api');
const {
getFirebaseURL,
prepareImageURL,
@@ -222,6 +226,26 @@ const azureMistralOCRStrategy = () => ({
handleFileUpload: uploadAzureMistralOCR,
});
const vertexMistralOCRStrategy = () => ({
/** @type {typeof saveFileFromURL | null} */
saveURL: null,
/** @type {typeof getLocalFileURL | null} */
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */
prepareImagePayload: null,
/** @type {typeof deleteLocalFile | null} */
deleteFile: null,
/** @type {typeof getLocalFileStream | null} */
getDownloadStream: null,
handleFileUpload: uploadGoogleVertexMistralOCR,
});
// Strategy Selector
const getStrategyFunctions = (fileSource) => {
if (fileSource === FileSources.firebase) {
@@ -244,6 +268,8 @@ const getStrategyFunctions = (fileSource) => {
return mistralOCRStrategy();
} else if (fileSource === FileSources.azure_mistral_ocr) {
return azureMistralOCRStrategy();
} else if (fileSource === FileSources.vertexai_mistral_ocr) {
return vertexMistralOCRStrategy();
} else {
throw new Error('Invalid file source');
}

View File

@@ -118,7 +118,7 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
*/
const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => {
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED);
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED);
if (onBehalfFlowRequired) {
if (fromCache) {
const cachedToken = await tokensCache.get(sub);
@@ -130,7 +130,7 @@ const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache =
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE || 'user.read',
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE || 'user.read',
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},

View File

@@ -0,0 +1,37 @@
import { createContext, useContext, useState, ReactNode } from 'react';
interface ActivePanelContextType {
active: string | undefined;
setActive: (id: string) => void;
}
const ActivePanelContext = createContext<ActivePanelContextType | undefined>(undefined);
export function ActivePanelProvider({
children,
defaultActive,
}: {
children: ReactNode;
defaultActive?: string;
}) {
const [active, _setActive] = useState<string | undefined>(defaultActive);
const setActive = (id: string) => {
localStorage.setItem('side:active-panel', id);
_setActive(id);
};
return (
<ActivePanelContext.Provider value={{ active, setActive }}>
{children}
</ActivePanelContext.Provider>
);
}
export function useActivePanel() {
const context = useContext(ActivePanelContext);
if (context === undefined) {
throw new Error('useActivePanel must be used within an ActivePanelProvider');
}
return context;
}

View File

@@ -40,41 +40,40 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
agent_id: agent_id || '',
})) || [];
const groupedTools =
tools?.reduce(
(acc, tool) => {
if (tool.tool_id.includes(Constants.mcp_delimiter)) {
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
const groupKey = `${serverName.toLowerCase()}`;
if (!acc[groupKey]) {
acc[groupKey] = {
tool_id: groupKey,
metadata: {
name: `${serverName}`,
pluginKey: groupKey,
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
icon: tool.metadata.icon || '',
} as TPlugin,
agent_id: agent_id || '',
tools: [],
};
}
acc[groupKey].tools?.push({
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
});
} else {
acc[tool.tool_id] = {
tool_id: tool.tool_id,
metadata: tool.metadata,
const groupedTools = tools?.reduce(
(acc, tool) => {
if (tool.tool_id.includes(Constants.mcp_delimiter)) {
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
const groupKey = `${serverName.toLowerCase()}`;
if (!acc[groupKey]) {
acc[groupKey] = {
tool_id: groupKey,
metadata: {
name: `${serverName}`,
pluginKey: groupKey,
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
icon: tool.metadata.icon || '',
} as TPlugin,
agent_id: agent_id || '',
tools: [],
};
}
return acc;
},
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
) || {};
acc[groupKey].tools?.push({
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
});
} else {
acc[tool.tool_id] = {
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
};
}
return acc;
},
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
);
const value = {
action,

View File

@@ -1,6 +1,7 @@
export { default as AssistantsProvider } from './AssistantsContext';
export { default as AgentsProvider } from './AgentsContext';
export { default as ToastProvider } from './ToastContext';
export * from './ActivePanelContext';
export * from './AgentPanelContext';
export * from './ChatContext';
export * from './ShareContext';

View File

@@ -1,26 +1,13 @@
import {
AuthorizationTypeEnum,
AuthTypeEnum,
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
import { MCPForm } from '~/common/types';
export const defaultMCPFormValues: MCPForm = {
type: AuthTypeEnum.None,
saved_auth_fields: false,
api_key: '',
authorization_type: AuthorizationTypeEnum.Basic,
custom_auth_header: '',
oauth_client_id: '',
oauth_client_secret: '',
authorization_url: '',
client_url: '',
scope: '',
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
name: '',
description: '',
url: '',
tools: [],
icon: '',
trust: false,
customHeaders: [],
requestTimeout: undefined,
connectionTimeout: undefined,
};

View File

@@ -1,5 +1,5 @@
import { RefObject } from 'react';
import { FileSources, EModelEndpoint } from 'librechat-data-provider';
import { FileSources, EModelEndpoint, TPlugin } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type * as InputNumberPrimitive from 'rc-input-number';
import type { SetterOrUpdater, RecoilState } from 'recoil';
@@ -167,13 +167,27 @@ export type ActionAuthForm = {
token_exchange_method: t.TokenExchangeMethodEnum;
};
export type MCPForm = ActionAuthForm & {
name?: string;
export type MCPForm = MCPMetadata;
export type MCP = {
mcp_id: string;
metadata: MCPMetadata;
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string });
export type MCPMetadata = {
name: string;
description?: string;
url?: string;
tools?: string[];
url: string;
tools?: TPlugin[];
icon?: string;
trust?: boolean;
customHeaders?: Array<{
id: string;
name: string;
value: string;
}>;
requestTimeout?: number;
connectionTimeout?: number;
};
export type ActionWithNullableMetadata = Omit<t.Action, 'metadata'> & {
@@ -219,11 +233,11 @@ export type AgentPanelContextType = {
mcps?: t.MCP[];
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
groupedTools: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
tools: t.AgentToolType[];
activePanel?: string;
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
groupedTools?: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
agent_id?: string;
};

View File

@@ -4,9 +4,9 @@ import {
supportsFiles,
mergeFileConfig,
isAgentsEndpoint,
EndpointFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
import { useGetFileConfig } from '~/data-provider';
import AttachFileMenu from './AttachFileMenu';
import { useChatContext } from '~/Providers';
@@ -14,22 +14,25 @@ import { useChatContext } from '~/Providers';
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
const { conversation } = useChatContext();
const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO;
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
const { endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
| EndpointFileConfig
| undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''] as EndpointFileConfig | undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) {
return <AttachFileMenu disabled={disableInputs} conversationId={conversationId} />;
return (
<AttachFileMenu
disabled={disableInputs}
conversationId={conversationId}
endpointFileConfig={endpointFileConfig}
/>
);
}
return null;

View File

@@ -2,6 +2,7 @@ import { useSetRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import React, { useRef, useState, useMemo } from 'react';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import type { EndpointFileConfig } from 'librechat-data-provider';
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
@@ -12,9 +13,10 @@ import { cn } from '~/utils';
interface AttachFileMenuProps {
conversationId: string;
disabled?: boolean | null;
endpointFileConfig?: EndpointFileConfig;
}
const AttachFileMenu = ({ disabled, conversationId }: AttachFileMenuProps) => {
const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null);
@@ -24,6 +26,7 @@ const AttachFileMenu = ({ disabled, conversationId }: AttachFileMenuProps) => {
const { data: endpointsConfig } = useGetEndpointsQuery();
const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig,
});
/** TODO: Ephemeral Agent Capabilities

View File

@@ -168,7 +168,7 @@ export default function AgentConfig({
const visibleToolIds = new Set(selectedToolIds);
// Check what group parent tools should be shown if any subtool is present
Object.entries(allTools).forEach(([toolId, toolObj]) => {
Object.entries(allTools ?? {}).forEach(([toolId, toolObj]) => {
if (toolObj.tools?.length) {
// if any subtool of this group is selected, ensure group parent tool rendered
if (toolObj.tools.some((st) => selectedToolIds.includes(st.tool_id))) {
@@ -299,6 +299,7 @@ export default function AgentConfig({
<div className="mb-1">
{/* // Render all visible IDs (including groups with subtools selected) */}
{[...visibleToolIds].map((toolId, i) => {
if (!allTools) return null;
const tool = allTools[toolId];
if (!tool) return null;
return (

View File

@@ -7,7 +7,6 @@ import VersionPanel from './Version/VersionPanel';
import { useChatContext } from '~/Providers';
import ActionsPanel from './ActionsPanel';
import AgentPanel from './AgentPanel';
import MCPPanel from './MCPPanel';
import { Panel } from '~/common';
export default function AgentPanelSwitch() {
@@ -54,8 +53,5 @@ function AgentPanelSwitchWithContext() {
if (activePanel === Panel.version) {
return <VersionPanel />;
}
if (activePanel === Panel.mcp) {
return <MCPPanel />;
}
return <AgentPanel agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />;
}

View File

@@ -19,7 +19,7 @@ export default function AgentTool({
allTools,
}: {
tool: string;
allTools: Record<string, AgentToolType & { tools?: AgentToolType[] }>;
allTools?: Record<string, AgentToolType & { tools?: AgentToolType[] }>;
agent_id?: string;
}) {
const [isHovering, setIsHovering] = useState(false);
@@ -30,8 +30,10 @@ export default function AgentTool({
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
if (!allTools) {
return null;
}
const currentTool = allTools[tool];
const getSelectedTools = () => {
if (!currentTool?.tools) return [];
const formTools = getValues('tools') || [];

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useLocalize } from '~/hooks';
import { useToastContext } from '~/Providers';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import MCP from '~/components/SidePanel/Builder/MCP';
import { MCPItem } from '~/components/SidePanel/MCP/MCPItem';
import { Panel } from '~/common';
export default function MCPSection() {
@@ -30,7 +30,7 @@ export default function MCPSection() {
{mcps
.filter((mcp) => mcp.agent_id === agent_id)
.map((mcp, i) => (
<MCP
<MCPItem
key={i}
mcp={mcp}
onClick={() => {

View File

@@ -17,9 +17,9 @@ import {
} from '~/data-provider';
import { cn, cardStyle, defaultTextProps, removeFocusOutlines } from '~/utils';
import AssistantConversationStarters from './AssistantConversationStarters';
import AssistantToolsDialog from '~/components/Tools/AssistantToolsDialog';
import { useAssistantsMapContext, useToastContext } from '~/Providers';
import { useSelectAssistant, useLocalize } from '~/hooks';
import { ToolSelectDialog } from '~/components/Tools';
import AppendDateCheckbox from './AppendDateCheckbox';
import CapabilitiesForm from './CapabilitiesForm';
import { SelectDropDown } from '~/components/ui';
@@ -468,11 +468,10 @@ export default function AssistantPanel({
</button>
</div>
</div>
<ToolSelectDialog
<AssistantToolsDialog
endpoint={endpoint}
isOpen={showToolDialog}
setIsOpen={setShowToolDialog}
toolsFormKey="functions"
endpoint={endpoint}
/>
</form>
</FormProvider>

View File

@@ -1,55 +0,0 @@
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth';
import {
AuthorizationTypeEnum,
TokenExchangeMethodEnum,
AuthTypeEnum,
} from 'librechat-data-provider';
export default function MCPAuth() {
// Create a separate form for auth
const authMethods = useForm({
defaultValues: {
/* General */
type: AuthTypeEnum.None,
saved_auth_fields: false,
/* API key */
api_key: '',
authorization_type: AuthorizationTypeEnum.Basic,
custom_auth_header: '',
/* OAuth */
oauth_client_id: '',
oauth_client_secret: '',
authorization_url: '',
client_url: '',
scope: '',
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
},
});
const { watch, setValue } = authMethods;
const type = watch('type');
// Sync form state when auth type changes
useEffect(() => {
if (type === 'none') {
// Reset auth fields when type is none
setValue('api_key', '');
setValue('authorization_type', AuthorizationTypeEnum.Basic);
setValue('custom_auth_header', '');
setValue('oauth_client_id', '');
setValue('oauth_client_secret', '');
setValue('authorization_url', '');
setValue('client_url', '');
setValue('scope', '');
setValue('token_exchange_method', TokenExchangeMethodEnum.DefaultPost);
}
}, [type, setValue]);
return (
<FormProvider {...authMethods}>
<ActionsAuth />
</FormProvider>
);
}

View File

@@ -0,0 +1,222 @@
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { Plus, Trash2, CirclePlus } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '~/components/ui/Accordion';
import { DropdownPopup } from '~/components/ui';
import { useLocalize } from '~/hooks';
interface UserInfoPlaceholder {
label: string;
value: string;
description: string;
}
const userInfoPlaceholders: UserInfoPlaceholder[] = [
{ label: 'user-id', value: '{{LIBRECHAT_USER_ID}}', description: 'Current user ID' },
{ label: 'username', value: '{{LIBRECHAT_USER_USERNAME}}', description: 'Current username' },
{ label: 'email', value: '{{LIBRECHAT_USER_EMAIL}}', description: 'Current user email' },
{ label: 'name', value: '{{LIBRECHAT_USER_NAME}}', description: 'Current user name' },
{
label: 'provider',
value: '{{LIBRECHAT_USER_PROVIDER}}',
description: 'Authentication provider',
},
{ label: 'role', value: '{{LIBRECHAT_USER_ROLE}}', description: 'User role' },
];
export function MCPConfig() {
const localize = useLocalize();
const { register, watch, setValue } = useFormContext();
const [isHeadersMenuOpen, setIsHeadersMenuOpen] = useState(false);
const customHeaders = watch('customHeaders') || [];
const addCustomHeader = () => {
const newHeader = {
id: Date.now().toString(),
name: '',
value: '',
};
setValue('customHeaders', [...customHeaders, newHeader]);
};
const removeCustomHeader = (id: string) => {
setValue(
'customHeaders',
customHeaders.filter((header: any) => header.id !== id),
);
};
const updateCustomHeader = (id: string, field: 'name' | 'value', value: string) => {
setValue(
'customHeaders',
customHeaders.map((header: any) =>
header.id === id ? { ...header, [field]: value } : header,
),
);
};
const handleAddPlaceholder = (placeholder: UserInfoPlaceholder) => {
const newHeader = {
id: Date.now().toString(),
name: placeholder.label,
value: placeholder.value,
};
setValue('customHeaders', [...customHeaders, newHeader]);
setIsHeadersMenuOpen(false);
};
const headerMenuItems = [
...userInfoPlaceholders.map((placeholder) => ({
label: `${placeholder.label} - ${placeholder.description}`,
onClick: () => handleAddPlaceholder(placeholder),
})),
];
return (
<div className="space-y-4">
{/* Authentication Accordion */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="authentication" className="rounded-lg border border-border-medium">
<AccordionTrigger className="rounded px-4 py-3 text-sm font-medium hover:no-underline focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
{localize('com_ui_authentication')}
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 pt-2">
<div className="space-y-4">
{/* Custom Headers Section - Individual Inputs Version */}
<div>
<div className="mb-3 flex items-center justify-between">
<label className="text-sm font-medium text-text-primary">
{localize('com_ui_mcp_custom_headers')}
</label>
<DropdownPopup
menuId="headers-menu"
items={headerMenuItems}
isOpen={isHeadersMenuOpen}
setIsOpen={setIsHeadersMenuOpen}
trigger={
<Menu.MenuButton
onClick={() => setIsHeadersMenuOpen(!isHeadersMenuOpen)}
className="flex h-7 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<CirclePlus className="mr-1 h-3 w-3 text-text-secondary" />
{localize('com_ui_mcp_headers')}
</Menu.MenuButton>
}
/>
</div>
<div className="space-y-2">
{customHeaders.length === 0 ? (
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 flex-1 text-sm text-text-secondary">
{localize('com_ui_mcp_no_custom_headers')}
</p>
<button
type="button"
onClick={addCustomHeader}
className="flex h-7 shrink-0 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<Plus className="h-3 w-3" />
{localize('com_ui_mcp_add_header')}
</button>
</div>
) : (
<>
{customHeaders.map((header: any) => (
<div key={header.id} className="flex min-w-0 gap-2">
<input
type="text"
placeholder={localize('com_ui_mcp_header_name')}
value={header.name}
onChange={(e) => updateCustomHeader(header.id, 'name', e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<input
type="text"
placeholder={localize('com_ui_mcp_header_value')}
value={header.value}
onChange={(e) => updateCustomHeader(header.id, 'value', e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<button
type="button"
onClick={() => removeCustomHeader(header.id)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border-medium bg-surface-primary text-text-secondary hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
{/* Add New Header Button */}
<div className="flex justify-end">
<button
type="button"
onClick={addCustomHeader}
className="flex h-7 shrink-0 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<Plus className="h-3 w-3" />
{localize('com_ui_mcp_add_header')}
</button>
</div>
</>
)}
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Configuration Accordion */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="configuration" className="rounded-lg border border-border-medium">
<AccordionTrigger className="rounded px-4 py-3 text-sm font-medium hover:no-underline focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
{localize('com_ui_mcp_configuration')}
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 pt-2">
<div className="space-y-4">
{/* Request Timeout */}
<div>
<label className="mb-2 block text-sm font-medium text-text-primary">
{localize('com_ui_mcp_request_timeout')}
</label>
<input
type="number"
placeholder="10000"
{...register('requestTimeout')}
className="h-9 w-full rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<p className="mt-1 text-xs text-text-secondary">
{localize('com_ui_mcp_request_timeout_description')}
</p>
</div>
{/* Connection Timeout */}
<div>
<label className="mb-2 block text-sm font-medium text-text-primary">
{localize('com_ui_mcp_connection_timeout')}
</label>
<input
type="number"
placeholder="10000"
{...register('connectionTimeout')}
className="h-9 w-full rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<p className="mt-1 text-xs text-text-secondary">
{localize('com_ui_mcp_connection_timeout_description')}
</p>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -1,66 +1,103 @@
import { useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { useForm, FormProvider } from 'react-hook-form';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import type { MCPForm } from '~/common';
import {
useCreateMCPMutation,
useUpdateMCPMutation,
useDeleteMCPMutation,
} from '~/data-provider/MCPs/mutations';
import type { MCP } from 'librechat-data-provider';
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { defaultMCPFormValues } from '~/common/mcp';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import useLocalize from '~/hooks/useLocalize';
import { TrashIcon } from '~/components/svg';
import type { MCPForm } from '~/common';
import MCPInput from './MCPInput';
import { Panel } from '~/common';
import {
AuthTypeEnum,
AuthorizationTypeEnum,
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
// TODO: Add MCP delete (for now mocked for ui)
// import { useDeleteAgentMCP } from '~/data-provider';
function useDeleteAgentMCP({
onSuccess,
onError,
}: {
onSuccess: () => void;
onError: (error: Error) => void;
}) {
return {
mutate: async ({ mcp_id, agent_id }: { mcp_id: string; agent_id: string }) => {
try {
console.log('Mock delete MCP:', { mcp_id, agent_id });
onSuccess();
} catch (error) {
onError(error as Error);
}
},
};
interface MCPFormPanelProps {
// Data
mcp?: MCP;
// Actions
onBack: () => void;
// UI customization
title?: string;
subtitle?: string;
showDeleteButton?: boolean;
deleteConfirmMessage?: string;
// Form customization
defaultValues?: Partial<MCPForm>;
}
export default function MCPPanel() {
export default function MCPFormPanel({
mcp,
onBack,
title,
subtitle,
showDeleteButton = true,
deleteConfirmMessage,
defaultValues = defaultMCPFormValues,
}: MCPFormPanelProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { mcp, setMcp, agent_id, setActivePanel } = useAgentPanelContext();
const deleteAgentMCP = useDeleteAgentMCP({
const create = useCreateMCPMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
onBack();
},
onError: (error) => {
console.error('Error creating MCP:', error);
showToast({
message: localize('com_ui_update_mcp_error'),
status: 'error',
});
},
});
const update = useUpdateMCPMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
onBack();
},
onError: (error) => {
console.error('Error updating MCP:', error);
showToast({
message: localize('com_ui_update_mcp_error'),
status: 'error',
});
},
});
const deleteMCP = useDeleteMCPMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_delete_mcp_success'),
status: 'success',
});
setActivePanel(Panel.builder);
setMcp(undefined);
onBack();
},
onError(error) {
onError: (error) => {
console.error('Error deleting MCP:', error);
showToast({
message: (error as Error).message ?? localize('com_ui_delete_mcp_error'),
message: localize('com_ui_delete_mcp_error'),
status: 'error',
});
},
});
const methods = useForm<MCPForm>({
defaultValues: defaultMCPFormValues,
defaultValues: defaultValues,
});
const { reset } = methods;
@@ -74,55 +111,51 @@ export default function MCPPanel() {
url: mcp.metadata.url ?? '',
tools: mcp.metadata.tools ?? [],
trust: mcp.metadata.trust ?? false,
customHeaders: mcp.metadata.customHeaders ?? [],
requestTimeout: mcp.metadata.requestTimeout,
connectionTimeout: mcp.metadata.connectionTimeout,
};
if (mcp.metadata.auth) {
Object.assign(formData, {
type: mcp.metadata.auth.type || AuthTypeEnum.None,
saved_auth_fields: false,
api_key: mcp.metadata.api_key ?? '',
authorization_type: mcp.metadata.auth.authorization_type || AuthorizationTypeEnum.Basic,
oauth_client_id: mcp.metadata.oauth_client_id ?? '',
oauth_client_secret: mcp.metadata.oauth_client_secret ?? '',
authorization_url: mcp.metadata.auth.authorization_url ?? '',
client_url: mcp.metadata.auth.client_url ?? '',
scope: mcp.metadata.auth.scope ?? '',
token_exchange_method:
mcp.metadata.auth.token_exchange_method ?? TokenExchangeMethodEnum.DefaultPost,
});
}
reset(formData);
}
}, [mcp, reset]);
const handleSave = (mcpData: MCP) => {
if (mcp) {
// Update existing MCP
update.mutate({ mcp_id: mcp.mcp_id, data: mcpData });
} else {
// Create new MCP
create.mutate(mcpData);
}
};
const handleDelete = () => {
if (mcp?.mcp_id) {
deleteMCP.mutate({ mcp_id: mcp.mcp_id });
}
};
return (
<FormProvider {...methods}>
<form className="h-full grow overflow-hidden">
<div className="h-full overflow-auto px-2 pb-12 text-sm">
<div className="relative flex flex-col items-center px-16 py-6 text-center">
<div className="absolute left-0 top-6">
<button
type="button"
className="btn btn-neutral relative"
onClick={() => {
setActivePanel(Panel.builder);
setMcp(undefined);
}}
>
<button type="button" className="btn btn-neutral relative" onClick={onBack}>
<div className="flex w-full items-center justify-center gap-2">
<ChevronLeft />
</div>
</button>
</div>
{!!mcp && (
{!!mcp && showDeleteButton && (
<OGDialog>
<OGDialogTrigger asChild>
<div className="absolute right-0 top-6">
<button
type="button"
disabled={!agent_id || !mcp.mcp_id}
disabled={!mcp.mcp_id}
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
>
<TrashIcon className="text-red-500" />
@@ -135,22 +168,11 @@ export default function MCPPanel() {
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_mcp_confirm')}
{deleteConfirmMessage || localize('com_ui_delete_mcp_confirm')}
</Label>
}
selection={{
selectHandler: () => {
if (!agent_id) {
return showToast({
message: localize('com_agents_no_agent_id_error'),
status: 'error',
});
}
deleteAgentMCP.mutate({
mcp_id: mcp.mcp_id,
agent_id,
});
},
selectHandler: handleDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),
@@ -160,11 +182,17 @@ export default function MCPPanel() {
)}
<div className="text-xl font-medium">
{mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')}
{title ||
(mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server'))}
</div>
<div className="text-xs text-text-secondary">{localize('com_agents_mcp_info')}</div>
<div className="text-xs text-text-secondary">{subtitle || ''}</div>
</div>
<MCPInput mcp={mcp} agent_id={agent_id} setMCP={setMcp} />
<MCPInput
mcp={mcp}
agent_id=""
onSave={handleSave}
isLoading={create.isLoading || update.isLoading}
/>
</div>
</form>
</FormProvider>

View File

@@ -1,58 +1,31 @@
import { useState, useEffect } from 'react';
import { Constants } from 'librechat-data-provider';
import { useFormContext, Controller } from 'react-hook-form';
import type { MCP } from 'librechat-data-provider';
import MCPAuth from '~/components/SidePanel/Builder/MCPAuth';
import type { MCP, MCPMetadata } from 'librechat-data-provider';
import { MCPConfig } from '~/components/SidePanel/MCP/MCPConfig';
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
import { Label, Checkbox } from '~/components/ui';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { MCPForm } from '~/common/types';
function useUpdateAgentMCP({
onSuccess,
onError,
}: {
onSuccess: (data: [string, MCP]) => void;
onError: (error: Error) => void;
}) {
return {
mutate: async ({
mcp_id,
metadata,
agent_id,
}: {
mcp_id?: string;
metadata: MCP['metadata'];
agent_id: string;
}) => {
try {
// TODO: Implement MCP endpoint
onSuccess(['success', { mcp_id, metadata, agent_id } as MCP]);
} catch (error) {
onError(error as Error);
}
},
isLoading: false,
};
}
interface MCPInputProps {
mcp?: MCP;
agent_id?: string;
setMCP: React.Dispatch<React.SetStateAction<MCP | undefined>>;
onSave: (mcp: MCP) => void;
isLoading?: boolean;
}
export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
export default function MCPInput({ mcp, agent_id = '', onSave, isLoading = false }: MCPInputProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const {
handleSubmit,
register,
formState: { errors },
control,
setValue,
getValues,
} = useFormContext<MCPForm>();
const [isLoading, setIsLoading] = useState(false);
const [showTools, setShowTools] = useState(false);
const [selectedTools, setSelectedTools] = useState<string[]>([]);
@@ -64,50 +37,20 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
}
}, [mcp]);
const updateAgentMCP = useUpdateAgentMCP({
onSuccess(data) {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
setMCP(data[1]);
setShowTools(true);
setSelectedTools(data[1].metadata.tools ?? []);
setIsLoading(false);
},
onError(error) {
showToast({
message: (error as Error).message || localize('com_ui_update_mcp_error'),
status: 'error',
});
setIsLoading(false);
},
});
const saveMCP = handleSubmit(async (data: MCPForm) => {
setIsLoading(true);
try {
const response = await updateAgentMCP.mutate({
agent_id: agent_id ?? '',
mcp_id: mcp?.mcp_id,
metadata: {
...data,
tools: selectedTools,
},
});
setMCP(response[1]);
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
} catch {
showToast({
message: localize('com_ui_update_mcp_error'),
status: 'error',
});
} finally {
setIsLoading(false);
}
// Generate MCP ID using server name and delimiter for new MCPs
const mcpId =
mcp?.mcp_id || `${data.name.replace(/\s+/g, '_').toLowerCase()}${Constants.mcp_delimiter}`;
const updatedMCP: MCP = {
mcp_id: mcpId,
agent_id: agent_id ?? '',
metadata: {
...data,
tools: selectedTools,
} as MCPMetadata, // Type assertion since form validation ensures required fields
};
onSave(updatedMCP);
});
const handleSelectAll = () => {
@@ -140,14 +83,15 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setMCP({
const updatedMCP: MCP = {
mcp_id: mcp?.mcp_id ?? '',
agent_id: agent_id ?? '',
metadata: {
...mcp?.metadata,
icon: base64String,
},
});
};
onSave(updatedMCP);
};
reader.readAsDataURL(file);
}
@@ -205,25 +149,48 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
</span>
)}
</div>
<MCPAuth />
<div className="my-2 flex items-center gap-2">
<MCPConfig />
<div className="my-2 flex items-center">
<Controller
name="trust"
control={control}
rules={{ required: true }}
render={({ field }) => (
<Checkbox id="trust" checked={field.value} onCheckedChange={field.onChange} />
<Checkbox
{...field}
checked={field.value ?? false}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={(field.value ?? false).toString()}
/>
)}
/>
<Label htmlFor="trust" className="flex flex-col">
{localize('com_ui_trust_app')}
<span className="text-xs text-text-secondary">
{localize('com_agents_mcp_trust_subtext')}
</span>
</Label>
<button
type="button"
className="flex items-center space-x-2"
onClick={() =>
setValue('trust', !getValues('trust'), {
shouldDirty: true,
})
}
>
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor="trust"
>
{localize('com_ui_trust_app')}
</label>
</button>
</div>
<div className="-mt-5 ml-6">
<span className="text-xs text-text-secondary">
{localize('com_agents_mcp_trust_subtext')}
</span>
</div>
{errors.trust && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
<div className="ml-6">
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
</div>
)}
</div>
@@ -231,7 +198,7 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
<button
onClick={saveMCP}
disabled={isLoading}
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white transition-colors duration-200 hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:bg-green-400"
type="button"
>
{(() => {

View File

@@ -9,7 +9,7 @@ type MCPProps = {
onClick: () => void;
};
export default function MCP({ mcp, onClick }: MCPProps) {
export function MCPItem({ mcp, onClick }: MCPProps) {
const [isHovering, setIsHovering] = useState(false);
return (

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import { useForm, Controller } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins } from 'librechat-data-provider';
import { Button, Input, Label } from '~/components/ui';
@@ -9,6 +9,7 @@ import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import MCPFormPanel from './MCPFormPanel';
interface ServerConfigWithVars {
serverName: string;
@@ -24,6 +25,7 @@ export default function MCPPanel() {
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
null,
);
const [showMCPForm, setShowMCPForm] = useState(false);
const mcpServerDefinitions = useMemo(() => {
if (!startupConfig?.mcpServers) {
@@ -89,14 +91,47 @@ export default function MCPPanel() {
setSelectedServerNameForEditing(null);
};
const handleAddMCP = () => {
setShowMCPForm(true);
};
const handleBackFromForm = () => {
setShowMCPForm(false);
};
if (showMCPForm) {
return (
<MCPFormPanel
onBack={handleBackFromForm}
title={localize('com_ui_add_mcp_server')}
subtitle={localize('com_agents_mcp_info_chat')}
/>
);
}
if (startupConfigLoading) {
return <MCPPanelSkeleton />;
}
if (mcpServerDefinitions.length === 0) {
return (
<div className="p-4 text-center text-sm text-gray-500">
{localize('com_sidepanel_mcp_no_servers_with_vars')}
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="p-4 text-center text-sm text-gray-500">
{localize('com_sidepanel_mcp_no_servers_with_vars')}
</div>
<div className="mt-4">
<button
type="button"
onClick={handleAddMCP}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="dialog"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_ui_add_mcp')}
</div>
</button>
</div>
</div>
);
}
@@ -144,15 +179,28 @@ export default function MCPPanel() {
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="space-y-2">
{mcpServerDefinitions.map((server) => (
<Button
<button
key={server.serverName}
variant="outline"
className="w-full justify-start dark:hover:bg-gray-700"
type="button"
onClick={() => handleServerClickToEdit(server.serverName)}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-label={`Configure MCP server ${server.serverName}`}
>
{server.serverName}
</Button>
<div className="flex w-full items-center justify-start gap-2">
{server.serverName}
</div>
</button>
))}
<button
type="button"
onClick={handleAddMCP}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="dialog"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_ui_add_mcp')}
</div>
</button>
</div>
</div>
);
@@ -181,7 +229,7 @@ function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariab
useEffect(() => {
// Always initialize with empty strings based on the schema
const initialFormValues = Object.keys(server.config.customUserVars).reduce(
const initialFormValues = Object.keys(server.config.customUserVars || {}).reduce(
(acc, key) => {
acc[key] = '';
return acc;
@@ -230,7 +278,7 @@ function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariab
</div>
))}
<div className="flex justify-end gap-2 pt-2">
{Object.keys(server.config.customUserVars).length > 0 && (
{Object.keys(server.config.customUserVars || {}).length > 0 && (
<Button
type="button"
onClick={handleRevokeClick}

View File

@@ -1,21 +1,15 @@
import { useState } from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import type { NavLink, NavProps } from '~/common';
import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion';
import { TooltipAnchor, Button } from '~/components';
import { AccordionContent, AccordionItem, TooltipAnchor, Accordion, Button } from '~/components/ui';
import { ActivePanelProvider, useActivePanel } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function Nav({ links, isCollapsed, resize, defaultActive }: NavProps) {
function NavContent({ links, isCollapsed, resize }: Omit<NavProps, 'defaultActive'>) {
const localize = useLocalize();
const [active, _setActive] = useState<string | undefined>(defaultActive);
const { active, setActive } = useActivePanel();
const getVariant = (link: NavLink) => (link.id === active ? 'default' : 'ghost');
const setActive = (id: string) => {
localStorage.setItem('side:active-panel', id + '');
_setActive(id);
};
return (
<div
data-collapsed={isCollapsed}
@@ -105,3 +99,11 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
</div>
);
}
export default function Nav({ links, isCollapsed, resize, defaultActive }: NavProps) {
return (
<ActivePanelProvider defaultActive={defaultActive}>
<NavContent links={links} isCollapsed={isCollapsed} resize={resize} />
</ActivePanelProvider>
);
}

View File

@@ -0,0 +1,254 @@
import { useEffect } from 'react';
import { Search, X } from 'lucide-react';
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
import { useFormContext } from 'react-hook-form';
import { isAgentsEndpoint } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type {
AssistantsEndpoint,
EModelEndpoint,
TPluginAction,
TError,
} from 'librechat-data-provider';
import type { TPluginStoreDialogProps } from '~/common/types';
import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store';
import { useLocalize, usePluginDialogHelpers } from '~/hooks';
import { useAvailableToolsQuery } from '~/data-provider';
import ToolItem from './ToolItem';
function AssistantToolsDialog({
isOpen,
endpoint,
setIsOpen,
}: TPluginStoreDialogProps & {
endpoint: AssistantsEndpoint | EModelEndpoint.agents;
}) {
const localize = useLocalize();
const { getValues, setValue } = useFormContext();
const { data: tools } = useAvailableToolsQuery(endpoint);
const isAgentTools = isAgentsEndpoint(endpoint);
const {
maxPage,
setMaxPage,
currentPage,
setCurrentPage,
itemsPerPage,
searchChanged,
setSearchChanged,
searchValue,
setSearchValue,
gridRef,
handleSearch,
handleChangePage,
error,
setError,
errorMessage,
setErrorMessage,
showPluginAuthForm,
setShowPluginAuthForm,
selectedPlugin,
setSelectedPlugin,
} = usePluginDialogHelpers();
const updateUserPlugins = useUpdateUserPluginsMutation();
const handleInstallError = (error: TError) => {
setError(true);
const errorMessage = error.response?.data?.message ?? '';
if (errorMessage) {
setErrorMessage(errorMessage);
}
setTimeout(() => {
setError(false);
setErrorMessage('');
}, 5000);
};
const handleInstall = (pluginAction: TPluginAction) => {
const addFunction = () => {
const fns = getValues('functions').slice();
fns.push(pluginAction.pluginKey);
setValue('functions', fns);
};
if (!pluginAction.auth) {
return addFunction();
}
updateUserPlugins.mutate(pluginAction, {
onError: (error: unknown) => {
handleInstallError(error as TError);
},
onSuccess: addFunction,
});
setShowPluginAuthForm(false);
};
const onRemoveTool = (tool: string) => {
setShowPluginAuthForm(false);
updateUserPlugins.mutate(
{ pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
{
onError: (error: unknown) => {
handleInstallError(error as TError);
},
onSuccess: () => {
const fns = getValues('functions').filter((fn: string) => fn !== tool);
setValue('functions', fns);
},
},
);
};
const onAddTool = (pluginKey: string) => {
setShowPluginAuthForm(false);
const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey);
setSelectedPlugin(getAvailablePluginFromKey);
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
if (authConfig && authConfig.length > 0 && !authenticated) {
setShowPluginAuthForm(true);
} else {
handleInstall({ pluginKey, action: 'install', auth: null });
}
};
const filteredTools = tools?.filter((tool) =>
tool.name.toLowerCase().includes(searchValue.toLowerCase()),
);
useEffect(() => {
if (filteredTools) {
setMaxPage(Math.ceil(filteredTools.length / itemsPerPage));
if (searchChanged) {
setCurrentPage(1);
setSearchChanged(false);
}
}
}, [
tools,
itemsPerPage,
searchValue,
filteredTools,
searchChanged,
setMaxPage,
setCurrentPage,
setSearchChanged,
]);
return (
<Dialog
open={isOpen}
onClose={() => {
setIsOpen(false);
setCurrentPage(1);
setSearchValue('');
}}
className="relative z-[102]"
>
{/* The backdrop, rendered as a fixed sibling to the panel container */}
<div className="fixed inset-0 bg-surface-primary opacity-60 transition-opacity dark:opacity-80" />
{/* Full-screen container to center the panel */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel
className="relative w-full transform overflow-hidden overflow-y-auto rounded-lg bg-surface-secondary text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
style={{ minHeight: '610px' }}
>
<div className="flex items-center justify-between border-b-[1px] border-border-medium px-4 pb-4 pt-5 sm:p-6">
<div className="flex items-center">
<div className="text-center sm:text-left">
<DialogTitle className="text-lg font-medium leading-6 text-text-primary">
{isAgentTools
? localize('com_nav_tool_dialog_agents')
: localize('com_nav_tool_dialog')}
</DialogTitle>
<Description className="text-sm text-text-secondary">
{localize('com_nav_tool_dialog_description')}
</Description>
</div>
</div>
<div>
<div className="sm:mt-0">
<button
onClick={() => {
setIsOpen(false);
setCurrentPage(1);
}}
className="inline-block rounded-full text-text-secondary transition-colors hover:text-text-primary"
aria-label="Close dialog"
type="button"
>
<X aria-hidden="true" />
</button>
</div>
</div>
</div>
{error && (
<div
className="relative m-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700"
role="alert"
>
{localize('com_nav_plugin_auth_error')} {errorMessage}
</div>
)}
{showPluginAuthForm && (
<div className="p-4 sm:p-6 sm:pt-4">
<PluginAuthForm
plugin={selectedPlugin}
onSubmit={(installActionData: TPluginAction) => handleInstall(installActionData)}
isEntityTool={true}
/>
</div>
)}
<div className="p-4 sm:p-6 sm:pt-4">
<div className="mt-4 flex flex-col gap-4">
<div className="flex items-center justify-center space-x-4">
<Search className="h-6 w-6 text-text-tertiary" />
<input
type="text"
value={searchValue}
onChange={handleSearch}
placeholder={localize('com_nav_tool_search')}
className="w-64 rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary focus:outline-none"
/>
</div>
<div
ref={gridRef}
className="grid grid-cols-1 grid-rows-2 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
style={{ minHeight: '410px' }}
>
{filteredTools &&
filteredTools
.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
.map((tool, index) => (
<ToolItem
key={index}
tool={tool}
isInstalled={getValues('functions').includes(tool.pluginKey)}
onAddTool={() => onAddTool(tool.pluginKey)}
onRemoveTool={() => onRemoveTool(tool.pluginKey)}
/>
))}
</div>
</div>
<div className="mt-2 flex flex-col items-center gap-2 sm:flex-row sm:justify-between">
{maxPage > 0 ? (
<PluginPagination
currentPage={currentPage}
maxPage={maxPage}
onChangePage={handleChangePage}
/>
) : (
<div style={{ height: '21px' }}></div>
)}
</div>
</div>
</DialogPanel>
</div>
</Dialog>
);
}
export default AssistantToolsDialog;

View File

@@ -1,9 +1,9 @@
import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react';
import { AgentToolType } from 'librechat-data-provider';
import type { TPlugin, AgentToolType } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
type ToolItemProps = {
tool: AgentToolType;
tool: TPlugin | AgentToolType;
onAddTool: () => void;
onRemoveTool: () => void;
isInstalled?: boolean;
@@ -19,9 +19,13 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
}
};
const name = tool.metadata?.name || tool.tool_id;
const description = tool.metadata?.description || '';
const icon = tool.metadata?.icon;
const name =
(tool as AgentToolType).metadata?.name ||
(tool as AgentToolType).tool_id ||
(tool as TPlugin).name;
const description =
(tool as AgentToolType).metadata?.description || (tool as TPlugin).description || '';
const icon = (tool as AgentToolType).metadata?.icon || (tool as TPlugin).icon;
return (
<div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6">

View File

@@ -67,15 +67,14 @@ function ToolSelectDialog({
}, 5000);
};
const toolsFormKey = 'tools';
const handleInstall = (pluginAction: TPluginAction) => {
const addFunction = () => {
const installedToolIds: string[] = getValues(toolsFormKey) || [];
const installedToolIds: string[] = getValues('tools') || [];
// Add the parent
installedToolIds.push(pluginAction.pluginKey);
// If this tool is a group, add subtools too
const groupObj = groupedTools[pluginAction.pluginKey];
const groupObj = groupedTools?.[pluginAction.pluginKey];
if (groupObj?.tools && groupObj.tools.length > 0) {
for (const sub of groupObj.tools) {
if (!installedToolIds.includes(sub.tool_id)) {
@@ -83,7 +82,7 @@ function ToolSelectDialog({
}
}
}
setValue(toolsFormKey, Array.from(new Set(installedToolIds))); // no duplicates just in case
setValue('tools', Array.from(new Set(installedToolIds))); // no duplicates just in case
};
if (!pluginAction.auth) {
@@ -101,7 +100,7 @@ function ToolSelectDialog({
};
const onRemoveTool = (toolId: string) => {
const groupObj = groupedTools[toolId];
const groupObj = groupedTools?.[toolId];
const toolIdsToRemove = [toolId];
if (groupObj?.tools && groupObj.tools.length > 0) {
toolIdsToRemove.push(...groupObj.tools.map((sub) => sub.tool_id));
@@ -113,8 +112,8 @@ function ToolSelectDialog({
onError: (error: unknown) => handleInstallError(error as TError),
onSuccess: () => {
const remainingToolIds =
getValues(toolsFormKey)?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || [];
setValue(toolsFormKey, remainingToolIds);
getValues('tools')?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || [];
setValue('tools', remainingToolIds);
},
},
);
@@ -268,7 +267,7 @@ function ToolSelectDialog({
<ToolItem
key={index}
tool={tool}
isInstalled={getValues(toolsFormKey)?.includes(tool.tool_id) || false}
isInstalled={getValues('tools')?.includes(tool.tool_id) || false}
onAddTool={() => onAddTool(tool.tool_id)}
onRemoveTool={() => onRemoveTool(tool.tool_id)}
/>

View File

@@ -1,3 +1,4 @@
export * from './Accordion';
export * from './AnimatedTabs';
export * from './AlertDialog';
export * from './Breadcrumb';

View File

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

View File

@@ -0,0 +1,73 @@
import { dataService, QueryKeys } from 'librechat-data-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { UseMutationResult } from '@tanstack/react-query';
import type * as t from 'librechat-data-provider';
export const useCreateMCPMutation = (
options?: t.CreateMCPMutationOptions,
): UseMutationResult<t.MCP, Error, t.MCP> => {
const queryClient = useQueryClient();
return useMutation(
(mcp: t.MCP) => {
return dataService.createMCP(mcp);
},
{
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (data, variables, context) => {
queryClient.setQueryData<t.MCP[]>([QueryKeys.mcpServers], (prev) => {
return prev ? [...prev, data] : [data];
});
return options?.onSuccess?.(data, variables, context);
},
},
);
};
export const useUpdateMCPMutation = (
options?: t.UpdateMCPMutationOptions,
): UseMutationResult<t.MCP, Error, { mcp_id: string; data: t.MCP }> => {
const queryClient = useQueryClient();
return useMutation(
({ mcp_id, data }: { mcp_id: string; data: t.MCP }) => {
return dataService.updateMCP({ mcp_id, data });
},
{
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (data, variables, context) => {
queryClient.setQueryData<t.MCP[]>([QueryKeys.mcpServers], (prev) => {
if (!prev) return prev;
return prev.map((mcp) => (mcp.mcp_id === variables.mcp_id ? data : mcp));
});
return options?.onSuccess?.(data, variables, context);
},
},
);
};
export const useDeleteMCPMutation = (
options?: t.DeleteMCPMutationOptions,
): UseMutationResult<Record<string, unknown>, Error, { mcp_id: string }> => {
const queryClient = useQueryClient();
return useMutation(
({ mcp_id }: { mcp_id: string }) => {
return dataService.deleteMCP({ mcp_id });
},
{
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (data, variables, context) => {
queryClient.setQueryData<t.MCP[]>([QueryKeys.mcpServers], (prev) => {
if (!prev) return prev;
return prev.filter((mcp) => mcp.mcp_id !== variables.mcp_id);
});
return options?.onSuccess?.(data, variables, context);
},
},
);
};

View File

@@ -7,6 +7,7 @@ export * from './Memories';
export * from './Messages';
export * from './Misc';
export * from './Tools';
export * from './MCPs';
export * from './connection';
export * from './mutations';
export * from './prompts';

View File

@@ -1,33 +1,34 @@
import { useQueryClient } from '@tanstack/react-query';
import type { TEndpointsConfig, TError } from 'librechat-data-provider';
import {
defaultAssistantsVersion,
fileConfig as defaultFileConfig,
EModelEndpoint,
isAgentsEndpoint,
isAssistantsEndpoint,
mergeFileConfig,
QueryKeys,
} from 'librechat-data-provider';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { v4 } from 'uuid';
import { useQueryClient } from '@tanstack/react-query';
import {
QueryKeys,
EModelEndpoint,
mergeFileConfig,
isAgentsEndpoint,
isAssistantsEndpoint,
defaultAssistantsVersion,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import debounce from 'lodash/debounce';
import type { EndpointFileConfig, TEndpointsConfig, TError } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter } from '~/common';
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
import { useChatContext } from '~/Providers/ChatContext';
import { useDelayedUploadToast } from './useDelayedUploadToast';
import { processFileForUpload } from '~/utils/heicConverter';
import { useToastContext } from '~/Providers/ToastContext';
import { useChatContext } from '~/Providers/ChatContext';
import { logger, validateFiles } from '~/utils';
import useClientResize from './useClientResize';
import { processFileForUpload } from '~/utils/heicConverter';
import { useDelayedUploadToast } from './useDelayedUploadToast';
import useUpdateFiles from './useUpdateFiles';
type UseFileHandling = {
overrideEndpoint?: EModelEndpoint;
fileSetter?: FileSetter;
fileFilter?: (file: File) => boolean;
additionalMetadata?: Record<string, string | undefined>;
overrideEndpoint?: EModelEndpoint;
overrideEndpointFileConfig?: EndpointFileConfig;
};
const useFileHandling = (params?: UseFileHandling) => {
@@ -246,8 +247,9 @@ const useFileHandling = (params?: UseFileHandling) => {
fileList,
setError,
endpointFileConfig:
fileConfig?.endpoints[endpoint] ??
fileConfig?.endpoints.default ??
params?.overrideEndpointFileConfig ??
fileConfig?.endpoints?.[endpoint] ??
fileConfig?.endpoints?.default ??
defaultFileConfig.endpoints[endpoint] ??
defaultFileConfig.endpoints.default,
});

View File

@@ -19,7 +19,6 @@ import { Blocks, MCPIcon, AttachmentIcon } from '~/components/svg';
import Parameters from '~/components/SidePanel/Parameters/Panel';
import FilesPanel from '~/components/SidePanel/Files/Panel';
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
import { useGetStartupConfig } from '~/data-provider';
import { useHasAccess } from '~/hooks';
export default function useSideNavLinks({
@@ -61,7 +60,6 @@ export default function useSideNavLinks({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.CREATE,
});
const { data: startupConfig } = useGetStartupConfig();
const Links = useMemo(() => {
const links: NavLink[] = [];
@@ -79,7 +77,7 @@ export default function useSideNavLinks({
title: 'com_sidepanel_assistant_builder',
label: '',
icon: Blocks,
id: 'assistants',
id: EModelEndpoint.assistants,
Component: PanelSwitch,
});
}
@@ -94,7 +92,7 @@ export default function useSideNavLinks({
title: 'com_sidepanel_agent_builder',
label: '',
icon: Blocks,
id: 'agents',
id: EModelEndpoint.agents,
Component: AgentPanelSwitch,
});
}
@@ -152,20 +150,13 @@ export default function useSideNavLinks({
});
}
if (
startupConfig?.mcpServers &&
Object.values(startupConfig.mcpServers).some(
(server) => server.customUserVars && Object.keys(server.customUserVars).length > 0,
)
) {
links.push({
title: 'com_nav_setting_mcp',
label: '',
icon: MCPIcon,
id: 'mcp-settings',
Component: MCPPanel,
});
}
links.push({
title: 'com_nav_mcp_panel',
label: '',
icon: MCPIcon,
id: 'mcp-settings',
Component: MCPPanel,
});
links.push({
title: 'com_sidepanel_hide_panel',
@@ -189,7 +180,6 @@ export default function useSideNavLinks({
hasAccessToBookmarks,
hasAccessToCreateAgents,
hidePanel,
startupConfig,
]);
return Links;

View File

@@ -20,6 +20,7 @@
"com_agents_mcp_description_placeholder": "Explain what it does in a few words",
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px",
"com_agents_mcp_info": "Add MCP servers to your agent to enable it to perform tasks and interact with external services",
"com_agents_mcp_info_chat": "Add MCP servers to enable chat to perform tasks and interact with external services",
"com_agents_mcp_name_placeholder": "Custom Tool",
"com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat",
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",
@@ -205,10 +206,10 @@
"com_endpoint_google_custom_name_placeholder": "Set a custom name for Google",
"com_endpoint_google_maxoutputtokens": "Maximum number of tokens that can be generated in the response. Specify a lower value for shorter responses and a higher value for longer responses. Note: models may stop before reaching this maximum.",
"com_endpoint_google_temp": "Higher values = more random, while lower values = more focused and deterministic. We recommend altering this or Top P but not both.",
"com_endpoint_google_topk": "Top-k changes how the model selects tokens for output. A top-k of 1 means the selected token is the most probable among all tokens in the model's vocabulary (also called greedy decoding), while a top-k of 3 means that the next token is selected from among the 3 most probable tokens (using temperature).",
"com_endpoint_google_topp": "Top-p changes how the model selects tokens for output. Tokens are selected from most K (see topK parameter) probable to least until the sum of their probabilities equals the top-p value.",
"com_endpoint_google_thinking": "Enables or disables reasoning. This setting is only supported by certain models (2.5 series). For older models, this setting may have no effect.",
"com_endpoint_google_thinking_budget": "Guides the number of thinking tokens the model uses. The actual amount may exceed or fall below this value depending on the prompt.\n\nThis setting is only supported by certain models (2.5 series). Gemini 2.5 Pro supports 128-32,768 tokens. Gemini 2.5 Flash supports 0-24,576 tokens. Gemini 2.5 Flash Lite supports 512-24,576 tokens.\n\nLeave blank or set to \"-1\" to let the model automatically decide when and how much to think. By default, Gemini 2.5 Flash Lite does not think.",
"com_endpoint_google_topk": "Top-k changes how the model selects tokens for output. A top-k of 1 means the selected token is the most probable among all tokens in the model's vocabulary (also called greedy decoding), while a top-k of 3 means that the next token is selected from among the 3 most probable tokens (using temperature).",
"com_endpoint_google_topp": "Top-p changes how the model selects tokens for output. Tokens are selected from most K (see topK parameter) probable to least until the sum of their probabilities equals the top-p value.",
"com_endpoint_instructions_assistants": "Override Instructions",
"com_endpoint_instructions_assistants_placeholder": "Overrides the instructions of the assistant. This is useful for modifying the behavior on a per-run basis.",
"com_endpoint_max_output_tokens": "Max Output Tokens",
@@ -431,6 +432,7 @@
"com_nav_log_out": "Log out",
"com_nav_long_audio_warning": "Longer texts will take longer to process.",
"com_nav_maximize_chat_space": "Maximize chat space",
"com_nav_mcp_panel": "MCP Servers",
"com_nav_mcp_vars_update_error": "Error updating MCP custom user variables: {{0}}",
"com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.",
"com_nav_modular_chat": "Enable switching Endpoints mid-conversation",
@@ -457,7 +459,6 @@
"com_nav_setting_chat": "Chat",
"com_nav_setting_data": "Data controls",
"com_nav_setting_general": "General",
"com_nav_setting_mcp": "MCP Settings",
"com_nav_setting_personalization": "Personalization",
"com_nav_setting_speech": "Speech",
"com_nav_settings": "Settings",
@@ -827,6 +828,17 @@
"com_ui_mcp_server_not_found": "Server not found.",
"com_ui_mcp_servers": "MCP Servers",
"com_ui_mcp_url": "MCP Server URL",
"com_ui_mcp_custom_headers": "Custom Headers",
"com_ui_mcp_headers": "Headers",
"com_ui_mcp_no_custom_headers": "No custom headers configured",
"com_ui_mcp_add_header": "Add Header",
"com_ui_mcp_header_name": "Header Name",
"com_ui_mcp_header_value": "Header Value",
"com_ui_mcp_configuration": "Configuration",
"com_ui_mcp_request_timeout": "Request Timeout (ms)",
"com_ui_mcp_request_timeout_description": "Maximum time in milliseconds to wait for a response from the MCP server",
"com_ui_mcp_connection_timeout": "Connection Timeout (ms)",
"com_ui_mcp_connection_timeout_description": "Maximum time in milliseconds to establish connection to the MCP server",
"com_ui_memories": "Memories",
"com_ui_memories_allow_create": "Allow creating Memories",
"com_ui_memories_allow_opt_out": "Allow users to opt out of Memories",
@@ -916,6 +928,7 @@
"com_ui_rename_prompt": "Rename Prompt",
"com_ui_requires_auth": "Requires Authentication",
"com_ui_reset_var": "Reset {{0}}",
"com_ui_reset_zoom": "Reset Zoom",
"com_ui_result": "Result",
"com_ui_revoke": "Revoke",
"com_ui_revoke_info": "Revoke all user provided credentials",
@@ -1057,7 +1070,6 @@
"com_ui_x_selected": "{{0}} selected",
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_ui_reset_zoom": "Reset Zoom",
"com_user_message": "You",
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
}

82
package-lock.json generated
View File

@@ -38719,21 +38719,57 @@
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/pbkdf2": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
"integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.3.tgz",
"integrity": "sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"create-hash": "^1.1.2",
"create-hmac": "^1.1.4",
"ripemd160": "^2.0.1",
"safe-buffer": "^5.0.1",
"sha.js": "^2.4.8"
"create-hash": "~1.1.3",
"create-hmac": "^1.1.7",
"ripemd160": "=2.0.1",
"safe-buffer": "^5.2.1",
"sha.js": "^2.4.11",
"to-buffer": "^1.2.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/pbkdf2/node_modules/create-hash": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.1.3.tgz",
"integrity": "sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cipher-base": "^1.0.1",
"inherits": "^2.0.1",
"ripemd160": "^2.0.0",
"sha.js": "^2.4.0"
}
},
"node_modules/pbkdf2/node_modules/hash-base": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hash-base/-/hash-base-2.0.2.tgz",
"integrity": "sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.1"
}
},
"node_modules/pbkdf2/node_modules/ripemd160": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.1.tgz",
"integrity": "sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hash-base": "^2.0.0",
"inherits": "^2.0.1"
}
},
"node_modules/peek-readable": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz",
@@ -39919,9 +39955,9 @@
}
},
"node_modules/prettier-eslint/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -43961,6 +43997,28 @@
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
"dev": true
},
"node_modules/to-buffer": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz",
"integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"isarray": "^2.0.5",
"safe-buffer": "^5.2.1",
"typed-array-buffer": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/to-buffer/node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true,
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -46659,7 +46717,7 @@
},
"packages/data-provider": {
"name": "librechat-data-provider",
"version": "0.7.88",
"version": "0.7.89",
"license": "ISC",
"dependencies": {
"axios": "^1.8.2",

View File

@@ -32,6 +32,13 @@ interface AuthConfig {
baseURL: string;
}
/** Helper type for Google service account */
interface GoogleServiceAccount {
client_email?: string;
private_key?: string;
project_id?: string;
}
/** Helper type for OCR request context */
interface OCRContext {
req: Pick<ServerRequest, 'user' | 'app'> & {
@@ -424,3 +431,219 @@ export const uploadAzureMistralOCR = async (
throw createOCRError(error, 'Error uploading document to Azure Mistral OCR API:');
}
};
/**
* Loads Google service account configuration
*/
async function loadGoogleAuthConfig(): Promise<{
serviceAccount: GoogleServiceAccount;
accessToken: string;
}> {
/** Path from environment variable or default location */
const serviceKeyPath =
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
path.join(__dirname, '..', '..', '..', 'api', 'data', 'auth.json');
const absolutePath = path.isAbsolute(serviceKeyPath)
? serviceKeyPath
: path.resolve(serviceKeyPath);
let serviceKey: GoogleServiceAccount;
try {
const authJsonContent = fs.readFileSync(absolutePath, 'utf8');
serviceKey = JSON.parse(authJsonContent) as GoogleServiceAccount;
} catch {
throw new Error(`Google service account not found at ${absolutePath}`);
}
if (!serviceKey.client_email || !serviceKey.private_key || !serviceKey.project_id) {
throw new Error('Invalid Google service account configuration');
}
const jwt = await createJWT(serviceKey);
const accessToken = await exchangeJWTForAccessToken(jwt);
return {
serviceAccount: serviceKey,
accessToken,
};
}
/**
* Creates a JWT token manually
*/
async function createJWT(serviceKey: GoogleServiceAccount): Promise<string> {
const crypto = await import('crypto');
const header = {
alg: 'RS256',
typ: 'JWT',
};
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: serviceKey.client_email,
scope: 'https://www.googleapis.com/auth/cloud-platform',
aud: 'https://oauth2.googleapis.com/token',
exp: now + 3600,
iat: now,
};
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signatureInput = `${encodedHeader}.${encodedPayload}`;
const sign = crypto.createSign('RSA-SHA256');
sign.update(signatureInput);
sign.end();
const signature = sign.sign(serviceKey.private_key!, 'base64url');
return `${signatureInput}.${signature}`;
}
/**
* Exchanges JWT for access token
*/
async function exchangeJWTForAccessToken(jwt: string): Promise<string> {
const response = await axios.post(
'https://oauth2.googleapis.com/token',
new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
if (!response.data?.access_token) {
throw new Error('No access token in response');
}
return response.data.access_token;
}
/**
* Performs OCR using Google Vertex AI
*/
async function performGoogleVertexOCR({
url,
accessToken,
projectId,
model,
documentType = 'document_url',
}: {
url: string;
accessToken: string;
projectId: string;
model: string;
documentType?: 'document_url' | 'image_url';
}): Promise<OCRResult> {
const location = process.env.GOOGLE_LOC || 'us-central1';
const modelId = model || 'mistral-ocr-2505';
let baseURL: string;
if (location === 'global') {
baseURL = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/global/publishers/mistralai/models/${modelId}:rawPredict`;
} else {
baseURL = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/mistralai/models/${modelId}:rawPredict`;
}
const documentKey = documentType === 'image_url' ? 'image_url' : 'document_url';
const requestBody = {
model: modelId,
document: {
type: documentType,
[documentKey]: url,
},
include_image_base64: true,
};
logger.debug('Sending request to Google Vertex AI:', {
url: baseURL,
body: {
...requestBody,
document: { ...requestBody.document, [documentKey]: 'base64_data_hidden' },
},
});
return axios
.post(baseURL, requestBody, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
.then((res) => {
logger.debug('Google Vertex AI response received');
return res.data;
})
.catch((error) => {
if (error.response?.data) {
logger.error('Vertex AI error response: ' + JSON.stringify(error.response.data, null, 2));
}
throw new Error(
logAxiosError({
error: error as AxiosError,
message: 'Error calling Google Vertex AI Mistral OCR',
}),
);
});
}
/**
* Use Google Vertex AI Mistral OCR API to process the OCR result.
*
* @param params - The params object.
* @param params.req - The request object from Express. It should have a `user` property with an `id`
* representing the user
* @param params.file - The file object, which is part of the request. The file object should
* have a `mimetype` property that tells us the file type
* @param params.loadAuthValues - Function to load authentication values
* @returns - The result object containing the processed `text` and `images` (not currently used),
* along with the `filename` and `bytes` properties.
*/
export const uploadGoogleVertexMistralOCR = async (
context: OCRContext,
): Promise<MistralOCRUploadResult> => {
try {
const { serviceAccount, accessToken } = await loadGoogleAuthConfig();
const model = getModelConfig(context.req.app.locals?.ocr);
const buffer = fs.readFileSync(context.file.path);
const base64 = buffer.toString('base64');
const base64Prefix = `data:${context.file.mimetype || 'application/pdf'};base64,`;
const documentType = getDocumentType(context.file);
const ocrResult = await performGoogleVertexOCR({
url: `${base64Prefix}${base64}`,
accessToken,
projectId: serviceAccount.project_id!,
model,
documentType,
});
if (!ocrResult || !ocrResult.pages || ocrResult.pages.length === 0) {
throw new Error(
'No OCR result returned from service, may be down or the file is not supported.',
);
}
const { text, images } = processOCRResult(ocrResult);
return {
filename: context.file.originalname,
bytes: text.length * 4,
filepath: FileSources.vertexai_mistral_ocr as string,
text,
images,
};
} catch (error) {
throw createOCRError(error, 'Error uploading document to Google Vertex AI Mistral OCR:');
}
};

View File

@@ -2,6 +2,7 @@
export * from './mcp/manager';
export * from './mcp/oauth';
export * from './mcp/auth';
export * from './mcp/servers';
/* Utilities */
export * from './mcp/utils';
export * from './utils';
@@ -11,6 +12,8 @@ export * from './oauth';
export * from './crypto';
/* Flow */
export * from './flow/manager';
/* Middleware */
export * from './middleware';
/* Agents */
export * from './agents';
/* Endpoints */

View File

@@ -0,0 +1,212 @@
import { Response } from 'express';
import type { TUser } from 'librechat-data-provider';
import { getMCPServers, createMCPServer, updateMCPServer, deleteMCPServer } from './servers';
import type { AuthenticatedRequest, MCPRequest, MCPParamsRequest } from '../types';
describe('MCP Server Functions', () => {
let mockReq: Partial<AuthenticatedRequest>;
let mockRes: Partial<Response>;
let mockUser: TUser;
beforeEach(() => {
mockUser = { id: 'user123' } as TUser;
mockReq = { user: mockUser };
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getMCPServers', () => {
it('should return mock MCP servers', async () => {
await getMCPServers(mockReq as AuthenticatedRequest, mockRes as Response);
expect(mockRes.json).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
mcp_id: 'mcp_weather_001',
metadata: expect.objectContaining({
name: 'Weather Service',
}),
}),
expect.objectContaining({
mcp_id: 'mcp_calendar_002',
metadata: expect.objectContaining({
name: 'Calendar Manager',
}),
}),
]),
);
});
it('should reject unauthenticated requests', async () => {
mockReq.user = undefined;
await getMCPServers(mockReq as AuthenticatedRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'User not authenticated' });
});
});
describe('createMCPServer', () => {
beforeEach(() => {
mockReq.body = {
mcp_id: 'mcp_test_123',
metadata: {
name: 'Test MCP Server',
description: 'A test MCP server',
url: 'http://localhost:3000',
tools: ['test_tool'],
icon: '🔧',
trust: false,
customHeaders: [],
requestTimeout: 30000,
connectionTimeout: 10000,
},
agent_id: '',
};
});
it('should create new MCP server from form data', async () => {
await createMCPServer(mockReq as MCPRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
mcp_id: 'mcp_test_123',
metadata: expect.objectContaining({
name: 'Test MCP Server',
description: 'A test MCP server',
url: 'http://localhost:3000',
tools: ['test_tool'],
icon: '🔧',
trust: false,
}),
}),
);
});
it('should prevent duplicate server names', async () => {
mockReq.body = {
metadata: {
name: 'Weather Service', // This name already exists in mock data
url: 'http://localhost:3000',
},
agent_id: '',
};
await createMCPServer(mockReq as MCPRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(409);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server already exists' });
});
it('should validate required fields', async () => {
mockReq.body = {
metadata: {
description: 'A test MCP server',
// Missing name and url
},
agent_id: '',
};
await createMCPServer(mockReq as MCPRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Missing required fields: name and url are required',
});
});
});
describe('updateMCPServer', () => {
beforeEach(() => {
mockReq.body = {
metadata: {
name: 'Updated MCP Server',
description: 'An updated MCP server',
url: 'http://localhost:3001',
tools: ['updated_tool'],
icon: '⚙️',
trust: true,
customHeaders: [],
requestTimeout: 45000,
connectionTimeout: 15000,
},
agent_id: '',
};
mockReq.params = { mcp_id: 'mcp_weather_001' }; // Use existing mock server ID
});
it('should update existing MCP server', async () => {
await updateMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
mcp_id: 'mcp_weather_001',
metadata: expect.objectContaining({
name: 'Updated MCP Server',
description: 'An updated MCP server',
url: 'http://localhost:3001',
tools: ['updated_tool'],
icon: '⚙️',
trust: true,
}),
}),
);
});
it('should reject updates to non-existent servers', async () => {
mockReq.params = { mcp_id: 'non_existent_id' };
await updateMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server not found' });
});
it('should validate required fields', async () => {
mockReq.body = {
metadata: {
description: 'An updated MCP server',
// Missing name and url
},
agent_id: '',
};
await updateMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Missing required fields: name and url are required',
});
});
});
describe('deleteMCPServer', () => {
beforeEach(() => {
mockReq.params = { mcp_id: 'mcp_weather_001' }; // Use existing mock server ID
});
it('should delete existing MCP server', async () => {
await deleteMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server deleted successfully' });
});
it('should reject deletion of non-existent servers', async () => {
mockReq.params = { mcp_id: 'non_existent_id' };
await deleteMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server not found' });
});
});
});

View File

@@ -0,0 +1,270 @@
import { logger } from '@librechat/data-schemas';
import type { MCP } from 'librechat-data-provider';
import type { Response } from 'express';
import type { AuthenticatedRequest, MCPRequest, MCPParamsRequest } from '../types';
// Mock data for demonstration
const mockMCPServers: MCP[] = [
{
mcp_id: 'mcp_weather_001',
metadata: {
name: 'Weather Service',
description: 'Provides weather information and forecasts',
url: 'https://weather-mcp.example.com',
tools: ['get_current_weather', 'get_forecast', 'get_weather_alerts'],
icon: '',
trust: true,
customHeaders: [],
requestTimeout: 30000,
connectionTimeout: 10000,
},
agent_id: '',
},
{
mcp_id: 'mcp_calendar_002',
metadata: {
name: 'Calendar Manager',
description: 'Manages calendar events and scheduling',
url: 'https://calendar-mcp.example.com',
tools: ['create_event', 'list_events', 'update_event', 'delete_event'],
icon: '',
trust: false,
customHeaders: [{ id: '1', name: 'Authorization', value: 'Bearer {{api_key}}' }],
requestTimeout: 45000,
connectionTimeout: 15000,
},
agent_id: '',
},
];
/**
* Get all MCP servers for the authenticated user
*/
export const getMCPServers = async (req: AuthenticatedRequest, res: Response): Promise<void> => {
try {
const userId = req.user?.id;
if (!userId) {
logger.warn('MCP servers fetch without user ID');
res.status(401).json({ message: 'User not authenticated' });
return;
}
// Return mock MCP servers
res.json(mockMCPServers);
} catch (error) {
logger.error('Error fetching MCP servers:', error);
res.status(500).json({ message: 'Failed to fetch MCP servers' });
}
};
/**
* Get a single MCP server by ID
*/
export const getMCPServer = async (req: AuthenticatedRequest, res: Response): Promise<void> => {
try {
const { mcp_id } = req.params;
const userId = req.user?.id;
if (!userId) {
logger.warn('MCP server fetch without user ID');
res.status(401).json({ message: 'User not authenticated' });
return;
}
if (!mcp_id) {
logger.warn('MCP server fetch with missing mcp_id');
res.status(400).json({ message: 'Missing required parameter: mcp_id' });
return;
}
// Find the MCP server
const server = mockMCPServers.find((s) => s.mcp_id === mcp_id);
if (!server) {
logger.warn(`MCP server ${mcp_id} not found for user ${userId}`);
res.status(404).json({ message: 'MCP server not found' });
return;
}
res.json(server);
} catch (error) {
logger.error('Error fetching MCP server:', error);
res.status(500).json({ message: 'Failed to fetch MCP server' });
}
};
/**
* Create a new MCP server
*/
export const createMCPServer = async (req: MCPRequest, res: Response): Promise<void> => {
try {
const { body: formData } = req;
const userId = req.user?.id;
if (!userId) {
logger.warn('MCP server creation without user ID');
res.status(401).json({ message: 'User not authenticated' });
return;
}
// Validate required fields
if (!formData?.metadata?.name || !formData?.metadata?.url) {
logger.warn('MCP server creation with missing required fields');
res.status(400).json({
message: 'Missing required fields: name and url are required',
});
return;
}
// Check if server already exists
const serverExists = mockMCPServers.some(
(server) => server.metadata.name === formData.metadata.name,
);
if (serverExists) {
logger.warn(`MCP server ${formData.metadata.name} already exists for user ${userId}`);
res.status(409).json({ message: 'MCP server already exists' });
return;
}
// Create new MCP server from form data
const newMCPServer: MCP = {
mcp_id: formData.mcp_id || `mcp_${Date.now()}`,
metadata: {
name: formData.metadata.name,
description: formData.metadata.description || '',
url: formData.metadata.url,
tools: formData.metadata.tools || [],
icon: formData.metadata.icon || '🔧',
trust: formData.metadata.trust || false,
customHeaders: formData.metadata.customHeaders || [],
requestTimeout: formData.metadata.requestTimeout || 30000,
connectionTimeout: formData.metadata.connectionTimeout || 10000,
},
agent_id: formData.agent_id || '',
};
logger.info(`Created MCP server: ${newMCPServer.mcp_id} for user ${userId}`);
res.status(201).json(newMCPServer);
} catch (error) {
logger.error('Error creating MCP server:', error);
res.status(500).json({ message: 'Failed to create MCP server' });
}
};
/**
* Update an existing MCP server
*/
export const updateMCPServer = async (req: MCPParamsRequest, res: Response): Promise<void> => {
try {
const {
body: formData,
params: { mcp_id },
} = req;
const userId = req.user?.id;
if (!userId) {
logger.warn('MCP server update without user ID');
res.status(401).json({ message: 'User not authenticated' });
return;
}
// Validate required fields
if (!formData?.metadata?.name || !formData?.metadata?.url) {
logger.warn('MCP server update with missing required fields');
res.status(400).json({
message: 'Missing required fields: name and url are required',
});
return;
}
if (!mcp_id) {
logger.warn('MCP server update with missing mcp_id');
res.status(400).json({ message: 'Missing required parameter: mcp_id' });
return;
}
// Check if server exists
const existingServer = mockMCPServers.find((server) => server.mcp_id === mcp_id);
if (!existingServer) {
logger.warn(`MCP server ${mcp_id} not found for update for user ${userId}`);
res.status(404).json({ message: 'MCP server not found' });
return;
}
// Create updated MCP server from form data
const updatedMCP: MCP = {
mcp_id,
metadata: {
name: formData.metadata.name,
description: formData.metadata.description || existingServer.metadata.description || '',
url: formData.metadata.url,
tools: formData.metadata.tools || existingServer.metadata.tools || [],
icon: formData.metadata.icon || existingServer.metadata.icon || '🔧',
trust:
formData.metadata.trust !== undefined
? formData.metadata.trust
: existingServer.metadata.trust || false,
customHeaders:
formData.metadata.customHeaders || existingServer.metadata.customHeaders || [],
requestTimeout:
formData.metadata.requestTimeout || existingServer.metadata.requestTimeout || 30000,
connectionTimeout:
formData.metadata.connectionTimeout || existingServer.metadata.connectionTimeout || 10000,
},
agent_id: formData.agent_id || existingServer.agent_id || '',
};
// In a real implementation, you would update this in a database
logger.info(`Updated MCP server: ${mcp_id} for user ${userId}`);
res.json(updatedMCP);
} catch (error) {
logger.error('Error updating MCP server:', error);
res.status(500).json({ message: 'Failed to update MCP server' });
}
};
/**
* Delete an MCP server
*/
export const deleteMCPServer = async (req: MCPParamsRequest, res: Response): Promise<void> => {
try {
const {
params: { mcp_id },
} = req;
const userId = req.user?.id;
if (!userId) {
logger.warn('MCP server deletion without user ID');
res.status(401).json({ message: 'User not authenticated' });
return;
}
if (!mcp_id) {
logger.warn('MCP server deletion with missing mcp_id');
res.status(400).json({ message: 'Missing required parameter: mcp_id' });
return;
}
// Check if server exists
const serverExists = mockMCPServers.some((server) => server.mcp_id === mcp_id);
if (!serverExists) {
logger.warn(`MCP server ${mcp_id} not found for deletion for user ${userId}`);
res.status(404).json({ message: 'MCP server not found' });
return;
}
// In a real implementation, you would delete this from a database
logger.info(`Deleted MCP server: ${mcp_id} for user ${userId}`);
res.json({ message: 'MCP server deleted successfully' });
} catch (error) {
logger.error('Error deleting MCP server:', error);
res.status(500).json({ message: 'Failed to delete MCP server' });
}
};

View File

@@ -0,0 +1,141 @@
import { logger } from '@librechat/data-schemas';
import {
Permissions,
EndpointURLs,
EModelEndpoint,
PermissionTypes,
isAgentsEndpoint,
} from 'librechat-data-provider';
import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express';
import type { IRole, IUser } from '@librechat/data-schemas';
export function skipAgentCheck(req?: ServerRequest): boolean {
if (!req || !req?.body?.endpoint) {
return false;
}
if (req.method !== 'POST') {
return false;
}
if (!req.originalUrl?.includes(EndpointURLs[EModelEndpoint.agents])) {
return false;
}
return !isAgentsEndpoint(req.body.endpoint);
}
/**
* Core function to check if a user has one or more required permissions
* @param user - The user object
* @param permissionType - The type of permission to check
* @param permissions - The list of specific permissions to check
* @param bodyProps - An optional object where keys are permissions and values are arrays of properties to check
* @param checkObject - The object to check properties against
* @param skipCheck - An optional function that takes the checkObject and returns true to skip permission checking
* @returns Whether the user has the required permissions
*/
export const checkAccess = async ({
req,
user,
permissionType,
permissions,
getRoleByName,
bodyProps = {} as Record<Permissions, string[]>,
checkObject = {},
skipCheck,
}: {
user: IUser;
req?: ServerRequest;
permissionType: PermissionTypes;
permissions: Permissions[];
bodyProps?: Record<Permissions, string[]>;
checkObject?: object;
/** If skipCheck function is provided and returns true, skip permission checking */
skipCheck?: (req?: ServerRequest) => boolean;
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
}): Promise<boolean> => {
if (skipCheck && skipCheck(req)) {
return true;
}
if (!user || !user.role) {
return false;
}
const role = await getRoleByName(user.role);
if (role && role.permissions && role.permissions[permissionType]) {
const hasAnyPermission = permissions.some((permission) => {
if (
role.permissions?.[permissionType as keyof typeof role.permissions]?.[
permission as keyof (typeof role.permissions)[typeof permissionType]
]
) {
return true;
}
if (bodyProps[permission] && checkObject) {
return bodyProps[permission].some((prop) =>
Object.prototype.hasOwnProperty.call(checkObject, prop),
);
}
return false;
});
return hasAnyPermission;
}
return false;
};
/**
* Middleware to check if a user has one or more required permissions, optionally based on `req.body` properties.
* @param permissionType - The type of permission to check.
* @param permissions - The list of specific permissions to check.
* @param bodyProps - An optional object where keys are permissions and values are arrays of `req.body` properties to check.
* @param skipCheck - An optional function that takes req.body and returns true to skip permission checking.
* @param getRoleByName - A function to get the role by name.
* @returns Express middleware function.
*/
export const generateCheckAccess = ({
permissionType,
permissions,
bodyProps = {} as Record<Permissions, string[]>,
skipCheck,
getRoleByName,
}: {
permissionType: PermissionTypes;
permissions: Permissions[];
bodyProps?: Record<Permissions, string[]>;
skipCheck?: (req?: ServerRequest) => boolean;
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
}): ((req: ServerRequest, res: ServerResponse, next: NextFunction) => Promise<unknown>) => {
return async (req, res, next) => {
try {
const hasAccess = await checkAccess({
req,
user: req.user as IUser,
permissionType,
permissions,
bodyProps,
checkObject: req.body,
skipCheck,
getRoleByName,
});
if (hasAccess) {
return next();
}
logger.warn(
`[${permissionType}] Forbidden: "${req.originalUrl}" - Insufficient permissions for User ${req.user?.id}: ${permissions.join(', ')}`,
);
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
} catch (error) {
logger.error(error);
return res.status(500).json({
message: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
};
};

View File

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

View File

@@ -2,5 +2,6 @@ export * from './azure';
export * from './events';
export * from './google';
export * from './mistral';
export * from './mcp';
export * from './openai';
export * from './run';

View File

@@ -0,0 +1,17 @@
import type { TUser, MCP } from 'librechat-data-provider';
import type { Request } from 'express';
export interface AuthenticatedRequest extends Request {
user?: TUser;
}
export interface MCPRequest extends AuthenticatedRequest {
body: MCP;
}
export interface MCPParamsRequest extends AuthenticatedRequest {
params: {
mcp_id: string;
};
body: MCP;
}

View File

@@ -1,6 +1,6 @@
{
"name": "librechat-data-provider",
"version": "0.7.88",
"version": "0.7.89",
"description": "data services for librechat apps",
"main": "dist/index.js",
"module": "dist/index.es.js",

View File

@@ -185,6 +185,21 @@ export const agents = ({ path = '', options }: { path?: string; options?: object
return url;
};
export const mcps = ({ path = '', options }: { path?: string; options?: object }) => {
let url = '/api/mcp';
if (path && path !== '') {
url += `/${path}`;
}
if (options && Object.keys(options).length > 0) {
const queryParams = new URLSearchParams(options as Record<string, string>).toString();
url += `?${queryParams}`;
}
return url;
};
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
export const files = () => '/api/files';

View File

@@ -615,6 +615,7 @@ export enum OCRStrategy {
MISTRAL_OCR = 'mistral_ocr',
CUSTOM_OCR = 'custom_ocr',
AZURE_MISTRAL_OCR = 'azure_mistral_ocr',
VERTEXAI_MISTRAL_OCR = 'vertexai_mistral_ocr',
}
export enum SearchCategories {
@@ -949,11 +950,11 @@ export const initialModelsConfig: TModelsConfig = {
[EModelEndpoint.bedrock]: defaultModels[EModelEndpoint.bedrock],
};
export const EndpointURLs: Record<string, string> = {
export const EndpointURLs = {
[EModelEndpoint.assistants]: '/api/assistants/v2/chat',
[EModelEndpoint.azureAssistants]: '/api/assistants/v1/chat',
[EModelEndpoint.agents]: `/api/${EModelEndpoint.agents}/chat`,
};
} as const;
export const modularEndpoints = new Set<EModelEndpoint | string>([
EModelEndpoint.gptPlugins,

View File

@@ -13,16 +13,17 @@ export default function createPayload(submission: t.TSubmission) {
ephemeralAgent,
} = submission;
const { conversationId } = s.tConvoUpdateSchema.parse(conversation);
const { endpoint: _e } = endpointOption as {
const { endpoint: _e, endpointType } = endpointOption as {
endpoint: s.EModelEndpoint;
endpointType?: s.EModelEndpoint;
};
const endpoint = _e as s.EModelEndpoint;
let server = `${EndpointURLs[s.EModelEndpoint.agents]}/${endpoint}`;
if (isEdited && s.isAssistantsEndpoint(endpoint)) {
server += '/modify';
if (s.isAssistantsEndpoint(endpoint)) {
server =
EndpointURLs[(endpointType ?? endpoint) as 'assistants' | 'azureAssistants'] +
(isEdited ? '/modify' : '');
}
const payload: t.TPayload = {

View File

@@ -832,3 +832,41 @@ export const createMemory = (data: {
}): Promise<{ created: boolean; memory: q.TUserMemory }> => {
return request.post(endpoints.memories(), data);
};
export const createMCP = (mcp: ag.MCP): Promise<ag.MCP> => {
return request.post(
endpoints.mcps({
path: 'add',
}),
mcp,
);
};
export const getMCPServers = (): Promise<ag.MCP[]> => {
return request.get(endpoints.mcps({}));
};
export const getMCP = (mcp_id: string): Promise<ag.MCP> => {
return request.get(
endpoints.mcps({
path: mcp_id,
}),
);
};
export const updateMCP = ({ mcp_id, data }: { mcp_id: string; data: ag.MCP }): Promise<ag.MCP> => {
return request.put(
endpoints.mcps({
path: mcp_id,
}),
data,
);
};
export const deleteMCP = ({ mcp_id }: { mcp_id: string }): Promise<Record<string, unknown>> => {
return request.delete(
endpoints.mcps({
path: mcp_id,
}),
);
};

View File

@@ -48,6 +48,7 @@ export enum QueryKeys {
banner = 'banner',
/* Memories */
memories = 'memories',
mcpServers = 'mcpServers',
}
export enum MutationKeys {

View File

@@ -134,7 +134,7 @@ export type EventSubmission = Omit<TSubmission, 'initialResponse'> & { initialRe
export type TPluginAction = {
pluginKey: string;
action: 'install' | 'uninstall';
auth?: Partial<Record<string, string>>;
auth?: Partial<Record<string, string>> | null;
isEntityTool?: boolean;
};
@@ -144,7 +144,7 @@ export type TUpdateUserPlugins = {
isEntityTool?: boolean;
pluginKey: string;
action: string;
auth?: Partial<Record<string, string | null>>;
auth?: Partial<Record<string, string | null>> | null;
};
// TODO `label` needs to be changed to the proper `TranslationKeys`

View File

@@ -337,18 +337,22 @@ export type MCP = {
metadata: MCPMetadata;
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string });
export type MCPMetadata = Omit<ActionMetadata, 'auth'> & {
name?: string;
export type MCPMetadata = {
name: string;
description?: string;
url?: string;
tools?: string[];
auth?: MCPAuth;
url: string;
tools?: TPlugin[];
icon?: string;
trust?: boolean;
customHeaders?: Array<{
id: string;
name: string;
value: string;
}>;
requestTimeout?: number;
connectionTimeout?: number;
};
export type MCPAuth = ActionAuth;
export type AgentToolType = {
tool_id: string;
metadata: ToolMetadata;

View File

@@ -11,6 +11,7 @@ export enum FileSources {
execute_code = 'execute_code',
mistral_ocr = 'mistral_ocr',
azure_mistral_ocr = 'azure_mistral_ocr',
vertexai_mistral_ocr = 'vertexai_mistral_ocr',
text = 'text',
}

View File

@@ -12,7 +12,7 @@ import {
AgentCreateParams,
AgentUpdateParams,
} from './assistants';
import { Action, ActionMetadata } from './agents';
import { Action, ActionMetadata, MCP } from './agents';
export type MutationOptions<
Response,
@@ -319,6 +319,15 @@ export type AcceptTermsMutationOptions = MutationOptions<
/* Tools */
export type UpdatePluginAuthOptions = MutationOptions<types.TUser, types.TUpdateUserPlugins>;
export type CreateMCPMutationOptions = MutationOptions<Record<string, unknown>, MCP>;
export type UpdateMCPMutationOptions = MutationOptions<
Record<string, unknown>,
{ mcp_id: string; data: MCP }
>;
export type DeleteMCPMutationOptions = MutationOptions<Record<string, unknown>, { mcp_id: string }>;
export type ToolParamsMap = {
[Tools.execute_code]: {
lang: string;