mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🪪 fix: Prevent MCP Server Name Collisions (#13256)
* fix: prevent MCP server name collisions * chore: address MCP registry review nits * fix: reserve MCP config names from request context * chore: format MCP registry changes * chore: address MCP collision review findings
This commit is contained in:
parent
d462bf4113
commit
bd64251eb9
12 changed files with 354 additions and 49 deletions
|
|
@ -14,7 +14,11 @@ const {
|
|||
isMCPInspectionFailedError,
|
||||
} = require('@librechat/api');
|
||||
const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider');
|
||||
const { resolveConfigServers, resolveAllMcpConfigs } = require('~/server/services/MCP');
|
||||
const {
|
||||
resolveConfigServers,
|
||||
resolveMcpConfigNames,
|
||||
resolveAllMcpConfigs,
|
||||
} = require('~/server/services/MCP');
|
||||
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
|
||||
const { getMCPManager, getMCPServersRegistry } = require('~/config');
|
||||
|
||||
|
|
@ -213,11 +217,13 @@ const createMCPServerController = async (req, res) => {
|
|||
errors: validation.error.errors,
|
||||
});
|
||||
}
|
||||
const reservedServerNames = await resolveMcpConfigNames(req);
|
||||
const result = await getMCPServersRegistry().addServer(
|
||||
'temp_server_name',
|
||||
validation.data,
|
||||
'DB',
|
||||
userId,
|
||||
reservedServerNames,
|
||||
);
|
||||
res.status(201).json({
|
||||
serverName: result.serverName,
|
||||
|
|
|
|||
|
|
@ -108,9 +108,11 @@ jest.mock('~/server/services/Config/mcp', () => ({
|
|||
}));
|
||||
|
||||
const mockResolveAllMcpConfigs = jest.fn().mockResolvedValue({});
|
||||
const mockResolveMcpConfigNames = jest.fn().mockResolvedValue([]);
|
||||
jest.mock('~/server/services/MCP', () => ({
|
||||
getMCPSetupData: jest.fn(),
|
||||
resolveConfigServers: jest.fn().mockResolvedValue({}),
|
||||
resolveMcpConfigNames: (...args) => mockResolveMcpConfigNames(...args),
|
||||
resolveAllMcpConfigs: (...args) => mockResolveAllMcpConfigs(...args),
|
||||
getServerConnectionStatus: jest.fn(),
|
||||
}));
|
||||
|
|
@ -171,6 +173,8 @@ describe('MCP Routes', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockResolveAllMcpConfigs.mockResolvedValue({});
|
||||
mockResolveMcpConfigNames.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('GET /:serverName/oauth/initiate', () => {
|
||||
|
|
@ -2155,6 +2159,35 @@ describe('MCP Routes', () => {
|
|||
}),
|
||||
'DB',
|
||||
'test-user-id',
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('should reserve config-managed server names when creating MCP server', async () => {
|
||||
const validConfig = {
|
||||
type: 'sse',
|
||||
url: 'https://mcp-server.example.com/sse',
|
||||
title: 'Test SSE Server',
|
||||
};
|
||||
|
||||
mockResolveMcpConfigNames.mockResolvedValueOnce(['config_slack']);
|
||||
mockRegistryInstance.addServer.mockResolvedValue({
|
||||
serverName: 'test-sse-server',
|
||||
config: validConfig,
|
||||
});
|
||||
|
||||
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
|
||||
'temp_server_name',
|
||||
expect.objectContaining({
|
||||
type: 'sse',
|
||||
url: 'https://mcp-server.example.com/sse',
|
||||
}),
|
||||
'DB',
|
||||
'test-user-id',
|
||||
['config_slack'],
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -2286,6 +2319,22 @@ describe('MCP Routes', () => {
|
|||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ message: 'Database connection failed' });
|
||||
});
|
||||
|
||||
it('should fail closed when config-managed names cannot be resolved', async () => {
|
||||
const validConfig = {
|
||||
type: 'sse',
|
||||
url: 'https://mcp-server.example.com/sse',
|
||||
title: 'Test Server',
|
||||
};
|
||||
|
||||
mockResolveMcpConfigNames.mockRejectedValueOnce(new Error('Config lookup failed'));
|
||||
|
||||
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ message: 'Config lookup failed' });
|
||||
expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /servers/:serverName', () => {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,15 @@ function evictStale(map, ttl) {
|
|||
const unavailableMsg =
|
||||
"This tool's MCP server is temporarily unavailable. Please try again shortly.";
|
||||
|
||||
async function getAppConfigForRequest(req) {
|
||||
const user = req?.user;
|
||||
return await getAppConfigForUser(user?.id, user);
|
||||
}
|
||||
|
||||
async function getAppConfigForUser(userId, user) {
|
||||
return await getAppConfig({ role: user?.role, tenantId: getTenantId(), userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves config-source MCP servers from admin Config overrides for the current
|
||||
* request context. Returns the parsed configs keyed by server name.
|
||||
|
|
@ -63,12 +72,7 @@ const unavailableMsg =
|
|||
async function resolveConfigServers(req) {
|
||||
try {
|
||||
const registry = getMCPServersRegistry();
|
||||
const user = req?.user;
|
||||
const appConfig = await getAppConfig({
|
||||
role: user?.role,
|
||||
tenantId: getTenantId(),
|
||||
userId: user?.id,
|
||||
});
|
||||
const appConfig = await getAppConfigForRequest(req);
|
||||
return await registry.ensureConfigServers(appConfig?.mcpConfig || {});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
|
|
@ -79,6 +83,18 @@ async function resolveConfigServers(req) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves operator-managed MCP server names from admin Config overrides for the current request.
|
||||
* Returns a request-time snapshot for DB server creation, not a cross-process lock.
|
||||
* @throws Propagates app config lookup errors to keep DB server creation fail-closed.
|
||||
* @param {import('express').Request} req - Express request with user context
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async function resolveMcpConfigNames(req) {
|
||||
const appConfig = await getAppConfigForRequest(req);
|
||||
return Object.keys(appConfig?.mcpConfig || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves config-source servers and merges all server configs (YAML + config + user DB)
|
||||
* for the given user context. Shared helper for controllers needing the full merged config.
|
||||
|
|
@ -88,7 +104,7 @@ async function resolveConfigServers(req) {
|
|||
*/
|
||||
async function resolveAllMcpConfigs(userId, user) {
|
||||
const registry = getMCPServersRegistry();
|
||||
const appConfig = await getAppConfig({ role: user?.role, tenantId: getTenantId(), userId });
|
||||
const appConfig = await getAppConfigForUser(userId, user);
|
||||
let configServers = {};
|
||||
try {
|
||||
configServers = await registry.ensureConfigServers(appConfig?.mcpConfig || {});
|
||||
|
|
@ -874,6 +890,7 @@ module.exports = {
|
|||
createMCPTools,
|
||||
getMCPSetupData,
|
||||
resolveConfigServers,
|
||||
resolveMcpConfigNames,
|
||||
resolveAllMcpConfigs,
|
||||
checkOAuthFlowStatus,
|
||||
getServerConnectionStatus,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ jest.mock('~/server/services/Tools/mcp', () => ({
|
|||
}));
|
||||
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { resolveConfigServers, resolveAllMcpConfigs } = require('../MCP');
|
||||
const { resolveConfigServers, resolveMcpConfigNames, resolveAllMcpConfigs } = require('../MCP');
|
||||
|
||||
describe('resolveConfigServers', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
|
@ -93,6 +93,35 @@ describe('resolveConfigServers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('resolveMcpConfigNames', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('resolves current request config server names', async () => {
|
||||
getAppConfig.mockResolvedValue({ mcpConfig: { cfg_srv: {}, yaml_srv: {} } });
|
||||
|
||||
const result = await resolveMcpConfigNames({ user: { id: 'u1', role: 'admin' } });
|
||||
|
||||
expect(result).toEqual(['cfg_srv', 'yaml_srv']);
|
||||
expect(getAppConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ role: 'admin', userId: 'u1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns [] when mcpConfig is absent', async () => {
|
||||
getAppConfig.mockResolvedValue({});
|
||||
|
||||
const result = await resolveMcpConfigNames({ user: { id: 'u1' } });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('propagates getAppConfig failures for write-path callers', async () => {
|
||||
getAppConfig.mockRejectedValue(new Error('db timeout'));
|
||||
|
||||
await expect(resolveMcpConfigNames({ user: { id: 'u1' } })).rejects.toThrow('db timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAllMcpConfigs', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue