mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-31 08:56:48 +00:00
🧠 refactor: Memoize MCP Permission Checks Per Request (#13419)
This commit is contained in:
parent
100871c3ec
commit
479e9d59b7
9 changed files with 314 additions and 25 deletions
|
|
@ -38,8 +38,8 @@ const {
|
|||
const {
|
||||
createMCPTool,
|
||||
createMCPTools,
|
||||
createMCPPermissionContext,
|
||||
resolveConfigServers,
|
||||
userCanUseMCPServers,
|
||||
} = require('~/server/services/MCP');
|
||||
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
||||
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
||||
|
|
@ -233,7 +233,11 @@ const loadTools = async ({
|
|||
|
||||
const requestedTools = {};
|
||||
const hasMCPTools = tools.some((toolName) => toolName && mcpToolPattern.test(toolName));
|
||||
const canUseMCP = hasMCPTools ? await userCanUseMCPServers(options.req?.user) : true;
|
||||
const mcpPermissionContext =
|
||||
options.mcpPermissionContext ?? createMCPPermissionContext(options.req);
|
||||
const canUseMCP = hasMCPTools
|
||||
? await mcpPermissionContext.canUseServers(options.req?.user)
|
||||
: true;
|
||||
let loggedMCPDenied = false;
|
||||
|
||||
if (functions === true) {
|
||||
|
|
@ -458,6 +462,7 @@ const loadTools = async ({
|
|||
continue;
|
||||
}
|
||||
const mcpParams = {
|
||||
mcpPermissionContext,
|
||||
index,
|
||||
signal,
|
||||
user: safeUser,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ jest.mock('~/config', () => ({
|
|||
|
||||
jest.mock('~/server/services/MCP', () => ({
|
||||
resolveConfigServers: jest.fn().mockResolvedValue({}),
|
||||
createMCPPermissionContext: jest.fn((req) => ({
|
||||
canUseServers: (user) => mockUserCanUseMCPServers(user, req),
|
||||
})),
|
||||
userCanUseMCPServers: (...args) => mockUserCanUseMCPServers(...args),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
|||
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
|
||||
const { filterFile } = require('~/server/services/Files/process');
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
const { resolveConfigServers, userCanUseMCPServers } = require('~/server/services/MCP');
|
||||
const {
|
||||
createMCPPermissionContext,
|
||||
resolveConfigServers,
|
||||
userCanUseMCPServers,
|
||||
} = require('~/server/services/MCP');
|
||||
const { getMCPServersRegistry } = require('~/config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const db = require('~/models');
|
||||
|
|
@ -192,6 +196,7 @@ const isSubagentsCapabilityEnabled = (req) => {
|
|||
* @param {string} params.userId - Requesting user ID for MCP server access check
|
||||
* @param {string} [params.role] - Requesting user's role for ACL principal resolution
|
||||
* @param {object} [params.user] - Requesting user for MCP server use permission checks
|
||||
* @param {{ canUseServers: (user?: object) => Promise<boolean> }} [params.mcpPermissionContext] - Request-scoped MCP permission context
|
||||
* @param {Record<string, unknown>} params.availableTools - Global non-MCP tool cache
|
||||
* @param {string[]} [params.existingTools] - Tools already persisted on the agent document
|
||||
* @param {Record<string, unknown>} [params.configServers] - Config-source MCP servers resolved from appConfig overrides
|
||||
|
|
@ -202,6 +207,7 @@ const filterAuthorizedTools = async ({
|
|||
userId,
|
||||
role,
|
||||
user,
|
||||
mcpPermissionContext,
|
||||
availableTools,
|
||||
existingTools,
|
||||
configServers,
|
||||
|
|
@ -211,7 +217,11 @@ const filterAuthorizedTools = async ({
|
|||
let registryUnavailable = false;
|
||||
const existingToolSet = existingTools?.length ? new Set(existingTools) : null;
|
||||
const hasMCPTools = tools.some((tool) => tool?.includes(Constants.mcp_delimiter));
|
||||
const canUseMCP = hasMCPTools ? await userCanUseMCPServers(user) : true;
|
||||
const canUseMCP = hasMCPTools
|
||||
? await (mcpPermissionContext
|
||||
? mcpPermissionContext.canUseServers(user)
|
||||
: userCanUseMCPServers(user))
|
||||
: true;
|
||||
let loggedMCPDenied = false;
|
||||
|
||||
for (const tool of tools) {
|
||||
|
|
@ -403,11 +413,13 @@ const createAgentHandler = async (req, res) => {
|
|||
getCachedTools().then((t) => t ?? {}),
|
||||
hasMCPTools ? resolveConfigServers(req) : Promise.resolve(undefined),
|
||||
]);
|
||||
const mcpPermissionContext = createMCPPermissionContext(req);
|
||||
agentData.tools = await filterAuthorizedTools({
|
||||
tools,
|
||||
userId,
|
||||
role: req.user.role,
|
||||
user: req.user,
|
||||
mcpPermissionContext,
|
||||
availableTools,
|
||||
configServers,
|
||||
});
|
||||
|
|
@ -637,7 +649,8 @@ const updateAgentHandler = async (req, res) => {
|
|||
const existingMCPTools = existingTools.filter(isMCPTool);
|
||||
|
||||
if (requestedMCPTools.length > 0 || (hasToolUpdate && existingMCPTools.length > 0)) {
|
||||
if (!(await userCanUseMCPServers(req.user))) {
|
||||
const mcpPermissionContext = createMCPPermissionContext(req);
|
||||
if (!(await mcpPermissionContext.canUseServers(req.user))) {
|
||||
if (editingOwnAgent) {
|
||||
updateData.tools = effectiveTools.filter((t) => !isMCPTool(t));
|
||||
} else if (hasToolUpdate) {
|
||||
|
|
@ -667,6 +680,7 @@ const updateAgentHandler = async (req, res) => {
|
|||
userId: req.user.id,
|
||||
role: req.user.role,
|
||||
user: req.user,
|
||||
mcpPermissionContext,
|
||||
availableTools,
|
||||
configServers,
|
||||
});
|
||||
|
|
@ -826,11 +840,13 @@ const duplicateAgentHandler = async (req, res) => {
|
|||
getCachedTools().then((t) => t ?? {}),
|
||||
resolveConfigServers(req),
|
||||
]);
|
||||
const mcpPermissionContext = createMCPPermissionContext(req);
|
||||
newAgentData.tools = await filterAuthorizedTools({
|
||||
tools: newAgentData.tools,
|
||||
userId,
|
||||
role: req.user.role,
|
||||
user: req.user,
|
||||
mcpPermissionContext,
|
||||
availableTools,
|
||||
existingTools: newAgentData.tools,
|
||||
configServers,
|
||||
|
|
@ -1202,11 +1218,13 @@ const revertAgentVersionHandler = async (req, res) => {
|
|||
getCachedTools().then((t) => t ?? {}),
|
||||
resolveConfigServers(req),
|
||||
]);
|
||||
const mcpPermissionContext = createMCPPermissionContext(req);
|
||||
const filteredTools = await filterAuthorizedTools({
|
||||
tools: updatedAgent.tools,
|
||||
userId: req.user.id,
|
||||
role: req.user.role,
|
||||
user: req.user,
|
||||
mcpPermissionContext,
|
||||
availableTools,
|
||||
existingTools: updatedAgent.tools,
|
||||
configServers,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const {
|
|||
Constants: AgentConstants,
|
||||
} = require('@librechat/agents');
|
||||
const {
|
||||
checkAccess,
|
||||
sendEvent,
|
||||
MCPOAuthHandler,
|
||||
isMCPDomainAllowed,
|
||||
|
|
@ -16,6 +15,7 @@ const {
|
|||
GenerationJobManager,
|
||||
resolveJsonSchemaRefs,
|
||||
buildOAuthToolCallName,
|
||||
checkAccessWithRequestCache,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
|
|
@ -45,13 +45,14 @@ const RECONNECT_THROTTLE_MS = 10_000;
|
|||
const missingToolCache = new Map();
|
||||
const MISSING_TOOL_TTL_MS = 10_000;
|
||||
|
||||
async function userCanUseMCPServers(user) {
|
||||
async function userCanUseMCPServers(user, req) {
|
||||
if (!user?.id || !user?.role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await checkAccess({
|
||||
return await checkAccessWithRequestCache({
|
||||
req,
|
||||
user,
|
||||
permissionType: PermissionTypes.MCP_SERVERS,
|
||||
permissions: [Permissions.USE],
|
||||
|
|
@ -63,6 +64,12 @@ async function userCanUseMCPServers(user) {
|
|||
}
|
||||
}
|
||||
|
||||
function createMCPPermissionContext(req) {
|
||||
return {
|
||||
canUseServers: (user = req?.user) => userCanUseMCPServers(user, req),
|
||||
};
|
||||
}
|
||||
|
||||
function evictStale(map, ttl) {
|
||||
if (map.size <= MAX_CACHE_SIZE) {
|
||||
return;
|
||||
|
|
@ -436,6 +443,7 @@ async function reconnectServer({
|
|||
*
|
||||
* @param {Object} params
|
||||
* @param {ServerResponse} params.res - The Express response object for sending events.
|
||||
* @param {{ canUseServers: (user?: IUser) => Promise<boolean> }} [params.mcpPermissionContext] - Request-scoped MCP permission context.
|
||||
* @param {IUser} params.user - The user from the request object.
|
||||
* @param {string} params.serverName
|
||||
* @param {string} params.model
|
||||
|
|
@ -449,6 +457,7 @@ async function reconnectServer({
|
|||
*/
|
||||
async function createMCPTools({
|
||||
res,
|
||||
mcpPermissionContext,
|
||||
user,
|
||||
index,
|
||||
signal,
|
||||
|
|
@ -503,6 +512,7 @@ async function createMCPTools({
|
|||
for (const tool of result.tools) {
|
||||
const toolInstance = await createMCPTool({
|
||||
res,
|
||||
mcpPermissionContext,
|
||||
user,
|
||||
provider,
|
||||
userMCPAuthMap,
|
||||
|
|
@ -524,6 +534,7 @@ async function createMCPTools({
|
|||
* Creates a single tool from the specified MCP Server via `toolKey`.
|
||||
* @param {Object} params
|
||||
* @param {ServerResponse} params.res - The Express response object for sending events.
|
||||
* @param {{ canUseServers: (user?: IUser) => Promise<boolean> }} [params.mcpPermissionContext] - Request-scoped MCP permission context.
|
||||
* @param {IUser} params.user - The user from the request object.
|
||||
* @param {string} params.toolKey - The toolKey for the tool.
|
||||
* @param {string} params.model - The model for the tool.
|
||||
|
|
@ -538,6 +549,7 @@ async function createMCPTools({
|
|||
*/
|
||||
async function createMCPTool({
|
||||
res,
|
||||
mcpPermissionContext,
|
||||
user,
|
||||
index,
|
||||
signal,
|
||||
|
|
@ -613,6 +625,7 @@ async function createMCPTool({
|
|||
|
||||
return createToolInstance({
|
||||
res,
|
||||
mcpPermissionContext,
|
||||
user,
|
||||
provider,
|
||||
toolName,
|
||||
|
|
@ -625,6 +638,7 @@ async function createMCPTool({
|
|||
|
||||
function createToolInstance({
|
||||
res,
|
||||
mcpPermissionContext,
|
||||
user: capturedUser = null,
|
||||
toolName,
|
||||
serverName,
|
||||
|
|
@ -663,7 +677,9 @@ function createToolInstance({
|
|||
|
||||
try {
|
||||
const provider = (config?.metadata?.provider || capturedProvider)?.toLowerCase();
|
||||
const canUseMCP = await userCanUseMCPServers(permissionUser);
|
||||
const canUseMCP = mcpPermissionContext
|
||||
? await mcpPermissionContext.canUseServers(permissionUser)
|
||||
: await userCanUseMCPServers(permissionUser);
|
||||
if (!canUseMCP) {
|
||||
throw new Error('Forbidden: Insufficient MCP server permissions');
|
||||
}
|
||||
|
|
@ -923,6 +939,7 @@ async function getServerConnectionStatus(
|
|||
module.exports = {
|
||||
createMCPTool,
|
||||
createMCPTools,
|
||||
createMCPPermissionContext,
|
||||
userCanUseMCPServers,
|
||||
getMCPSetupData,
|
||||
resolveConfigServers,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const D = Constants.mcp_delimiter;
|
|||
const {
|
||||
createMCPTool,
|
||||
createMCPTools,
|
||||
createMCPPermissionContext,
|
||||
getMCPSetupData,
|
||||
checkOAuthFlowStatus,
|
||||
getServerConnectionStatus,
|
||||
|
|
@ -864,6 +865,78 @@ describe('User parameter passing tests', () => {
|
|||
);
|
||||
expect(mockGetMCPManager).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reuse request-scoped MCP permission checks across tool executions', async () => {
|
||||
const mockUser = { id: 'mcp-allowed-user', role: 'USER' };
|
||||
const mockReq = { user: mockUser };
|
||||
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
||||
const { getRoleByName } = require('~/models');
|
||||
getRoleByName.mockResolvedValue({
|
||||
permissions: {
|
||||
[PermissionTypes.MCP_SERVERS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockCallTool = jest.fn().mockResolvedValue(['ok', null]);
|
||||
mockGetMCPManager.mockReturnValue({
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
const availableTools = {
|
||||
[`search${D}test-server`]: {
|
||||
function: {
|
||||
description: 'Search tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
[`fetch${D}test-server`]: {
|
||||
function: {
|
||||
description: 'Fetch tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
};
|
||||
const mcpPermissionContext = createMCPPermissionContext(mockReq);
|
||||
|
||||
const searchTool = await createMCPTool({
|
||||
mcpPermissionContext,
|
||||
res: mockRes,
|
||||
user: mockUser,
|
||||
toolKey: `search${D}test-server`,
|
||||
provider: 'openai',
|
||||
userMCPAuthMap: {},
|
||||
availableTools,
|
||||
});
|
||||
const fetchTool = await createMCPTool({
|
||||
mcpPermissionContext,
|
||||
res: mockRes,
|
||||
user: mockUser,
|
||||
toolKey: `fetch${D}test-server`,
|
||||
provider: 'openai',
|
||||
userMCPAuthMap: {},
|
||||
availableTools,
|
||||
});
|
||||
|
||||
const invocationConfig = {
|
||||
configurable: {
|
||||
user: mockUser,
|
||||
},
|
||||
metadata: {
|
||||
provider: 'openai',
|
||||
thread_id: 'thread-1',
|
||||
run_id: 'run-1',
|
||||
},
|
||||
toolCall: {},
|
||||
};
|
||||
|
||||
await expect(searchTool.invoke({}, invocationConfig)).resolves.toBe('ok');
|
||||
await expect(fetchTool.invoke({}, invocationConfig)).resolves.toBe('ok');
|
||||
|
||||
expect(getRoleByName).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallTool).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reinitMCPServer (via reconnectServer)', () => {
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/pro
|
|||
const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest');
|
||||
const { createOnSearchResults } = require('~/server/services/Tools/search');
|
||||
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
||||
const { resolveConfigServers, userCanUseMCPServers } = require('~/server/services/MCP');
|
||||
const { createMCPPermissionContext, resolveConfigServers } = require('~/server/services/MCP');
|
||||
const { recordUsage } = require('~/server/services/Threads');
|
||||
const { loadTools } = require('~/app/clients/tools/util');
|
||||
const { redactMessage } = require('~/config/parsers');
|
||||
|
|
@ -552,7 +552,8 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
|
|||
agent.tools?.includes(Tools.execute_code) === true &&
|
||||
enabledCapabilities.has(AgentCapabilities.execute_code);
|
||||
const hasMCPTools = agent.tools?.some((tool) => tool?.includes(Constants.mcp_delimiter));
|
||||
const canUseMCP = hasMCPTools ? await userCanUseMCPServers(req.user) : true;
|
||||
const mcpPermissionContext = createMCPPermissionContext(req);
|
||||
const canUseMCP = hasMCPTools ? await mcpPermissionContext.canUseServers(req.user) : true;
|
||||
|
||||
const filteredTools = agent.tools?.filter((tool) => {
|
||||
if (tool === Tools.file_search) {
|
||||
|
|
@ -989,7 +990,8 @@ async function loadAgentTools({
|
|||
const areToolsEnabled = checkCapability(AgentCapabilities.tools);
|
||||
const actionsEnabled = checkCapability(AgentCapabilities.actions);
|
||||
const hasMCPTools = agent.tools?.some((tool) => tool?.includes(Constants.mcp_delimiter));
|
||||
const canUseMCP = hasMCPTools ? await userCanUseMCPServers(req.user) : true;
|
||||
const mcpPermissionContext = createMCPPermissionContext(req);
|
||||
const canUseMCP = hasMCPTools ? await mcpPermissionContext.canUseServers(req.user) : true;
|
||||
|
||||
let includesWebSearch = false;
|
||||
const _agentTools = agent.tools?.filter((tool) => {
|
||||
|
|
@ -1044,6 +1046,7 @@ async function loadAgentTools({
|
|||
processFileURL,
|
||||
uploadImageBuffer,
|
||||
returnMetadata: true,
|
||||
mcpPermissionContext,
|
||||
[Tools.web_search]: webSearchCallbacks,
|
||||
},
|
||||
webSearch: appConfig.webSearch,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const mockDecryptMetadata = jest.fn();
|
|||
const mockCreateActionTool = jest.fn();
|
||||
const mockGetServerConfig = jest.fn();
|
||||
const mockResolveConfigServers = jest.fn();
|
||||
const mockUserCanUseMCPServers = jest.fn().mockResolvedValue(true);
|
||||
jest.mock('~/server/services/Tools/credentials', () => ({
|
||||
loadAuthValues: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
|
@ -77,7 +78,10 @@ jest.mock('~/config', () => ({
|
|||
}));
|
||||
jest.mock('~/server/services/MCP', () => ({
|
||||
resolveConfigServers: (...args) => mockResolveConfigServers(...args),
|
||||
userCanUseMCPServers: jest.fn().mockResolvedValue(true),
|
||||
createMCPPermissionContext: jest.fn((req) => ({
|
||||
canUseServers: (user) => mockUserCanUseMCPServers(user, req),
|
||||
})),
|
||||
userCanUseMCPServers: mockUserCanUseMCPServers,
|
||||
}));
|
||||
jest.mock('~/cache', () => ({
|
||||
getLogStores: jest.fn(() => ({})),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue