🪪 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:
Danny Avila 2026-05-22 20:46:14 -04:00 committed by GitHub
parent d462bf4113
commit bd64251eb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 354 additions and 49 deletions

View file

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

View file

@ -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', () => {

View file

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

View file

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