🔐 fix: Honor Admin-Panel MCP Allowlist Overrides Without Restart (#13814)

* 🔐 fix: Honor Admin-Panel MCP Allowlist Overrides Without Restart

MCPServersRegistry was built once at boot from getAppConfig({ baseOnly:
true }), freezing allowedDomains/allowedAddresses to YAML. Admin-panel
mcpSettings overrides were ignored by both inspection (addServer/
reinspectServer/updateServer/lazyInitConfigServer) and runtime connection
enforcement (assertResolvedRuntimeConfigAllowed), so a domain allowed only
via the panel failed inspection and never connected.

Make the registry's effective allowlists mutable and refresh them from the
merged admin-panel config: seed at boot, and re-apply on every config
mutation via invalidateConfigCaches -> clearMcpConfigCache. Both inspection
and connection paths read the same getters, so both honor overrides without
a restart. Fail-safe: current allowlists are preserved when the merged read
fails.

* 🛡️ fix: Scope MCP allowlist refresh to global config, fail-safe on DB error

Address Codex P1 review findings on the allowlist-refresh path:

- Tenant-scoped config mutations no longer push one tenant's merged
  mcpSettings into the process-wide registry singleton (read by all MCP
  connection paths), which would leak allowlists across tenants. Only
  global (non-tenant) mutations refresh the registry; tenant mutations
  still evict the config-server cache.
- The refresh read now uses strictOverrides:true so a transient DB error
  throws instead of silently returning YAML base config — preserving the
  last-known allowlists rather than overwriting them with fallback values.
  Adds the strictOverrides option to getAppConfig (default off, no behavior
  change for existing callers).

* ♻️ refactor: Resolve MCP allowlists per-request (tenant-scoped) instead of a global singleton

Supersedes the prior global-mutation approach. MCP allowlists live in
mcpSettings, which is tenant/principal-scoped admin config, so a process-wide
singleton value is the wrong model — it caused cross-tenant bleed and stale
reads.

Instead, inject a resolver (from the app layer, where the merged config lives)
that the registry calls per inspection and per connection. It reads the ALS
tenant context via getAppConfig and accepts the acting user so user/role-scoped
overrides resolve; config-source inspection (no user) resolves at tenant scope.
Falls back to the YAML base allowlists when no resolver is set or the lookup
fails, so a transient error fails to the operator baseline rather than
disabling the allowlist.

Removes the now-unnecessary setAllowlists / boot-seed / invalidateConfigCaches
refresh / getAppConfig.strictOverrides machinery.

* 🔒 fix: Scope config-source cache by allowlist; resolve OAuth allowlists per-request

Address Codex review of the per-request resolver:

- Config-source cache key now folds in the resolved allowlists, not just the
  raw-config hash. Inspection results became allowlist-dependent, so without
  this a tenant whose allowlist rejects a URL could poison the shared key with
  an inspectionFailed stub for a tenant that allows it (and vice versa). The
  tenant-scoped allowlist is resolved once per ensureConfigServers pass and
  threaded through the cache key + inspection.
- The two remaining request-time OAuth allowlist reads now use the merged
  config instead of the YAML base getters: the fallback OAuth-initiate path
  (routes/mcp.js) via resolveAllowlists, and OAuth revocation
  (UserController.maybeUninstallOAuthMCP) via the request's already-merged
  appConfig.mcpSettings. Without this, an OAuth endpoint allowed only by an
  admin-panel override was rejected while inspection/connection allowed it.

*  test: Update MCP OAuth registry/config mocks for per-request allowlists

CI fix for the Finding-12 change. The OAuth-initiate route now calls
registry.resolveAllowlists() and the revocation path reads the merged
appConfig.mcpSettings, so the affected specs' mocks were asserting the old
base-getter values:
- routes/__tests__/mcp.spec.js: add resolveAllowlists to the registry mock.
- UserController.mcpOAuth.spec.js: provide mcpSettings on the getAppConfig
  mock so revokeOAuthToken still receives the expected allowlists.

* 🧪 test: e2e proof that admin-panel MCP allowlist override takes effect

Adds a Playwright mock-harness spec for #13809. A URL-based MCP fixture
(e2e-http, streamable-http SDK server) boots inspectionFailed because its
origin is omitted from the YAML mcpSettings.allowedDomains; the spec adds that
origin via an admin config override (PUT /api/admin/config/user/:id) and
asserts the server reinitializes — exercising the real resolver path through
the backend + DB. Before the fix, reinspection used the frozen YAML allowlist
and the server stayed unreachable.

- e2e/setup/fake-mcp-http-server.js: streamable-HTTP MCP fixture (health GET /).
- e2e/playwright.config.mock.ts: boot the fixture as a second webServer.
- e2e/config/librechat.e2e.yaml: mcpSettings.allowedDomains (excludes 127.0.0.1)
  + the e2e-http server.
- e2e/specs/mock/mcp-allowlist-override.spec.ts: login → baseline reinit fails →
  apply override → reinit succeeds.
This commit is contained in:
Danny Avila 2026-06-17 20:14:53 -04:00 committed by GitHub
parent c04bddd304
commit 49f4b659f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 522 additions and 43 deletions

View file

@ -530,9 +530,10 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
clientMetadata.revocation_endpoint_auth_methods_supported;
const oauthHeaders = serverConfig.oauth_headers ?? {};
const registry = getMCPServersRegistry();
const allowedDomains = registry.getAllowedDomains();
const allowedAddresses = registry.getAllowedAddresses();
// Use the request's merged (tenant/principal-scoped) allowlists so admin-panel mcpSettings
// overrides are honored for OAuth revocation, consistent with inspection/connection.
const allowedDomains = appConfig?.mcpSettings?.allowedDomains;
const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses;
if (tokens?.access_token) {
try {

View file

@ -132,7 +132,10 @@ function setupMCPMocks() {
getAllowedAddresses: jest.fn().mockReturnValue(null),
};
mockGetAppConfig.mockResolvedValue({});
// Revocation reads the merged config's mcpSettings allowlists (not the registry getters).
mockGetAppConfig.mockResolvedValue({
mcpSettings: { allowedDomains: [], allowedAddresses: null },
});
mockUpdateUserPlugins.mockResolvedValue();
mockDeleteUserPluginAuth.mockResolvedValue();
mockInvalidateCachedTools.mockResolvedValue();

View file

@ -24,6 +24,11 @@ const mockRegistryInstance = {
removeServer: jest.fn(),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
resolveAllowlists: jest.fn().mockResolvedValue({
allowedDomains: null,
allowedAddresses: null,
useSSRFProtection: true,
}),
};
let mockMCPUseAllowed = true;

View file

@ -182,8 +182,10 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async
const configServers = await resolveConfigServers(req);
const oauthHeaders = await getOAuthHeaders(serverName, userId, configServers);
const registry = getMCPServersRegistry();
const allowedDomains = registry.getAllowedDomains();
const allowedAddresses = registry.getAllowedAddresses();
const { allowedDomains, allowedAddresses } = await registry.resolveAllowlists({
userId,
role: req.user?.role,
});
const {
authorizationUrl,
flowId: oauthFlowId,

View file

@ -3,6 +3,21 @@ const { logger } = require('@librechat/data-schemas');
const { mergeAppTools, getAppConfig } = require('./Config');
const { createMCPServersRegistry, createMCPManager } = require('~/config');
/**
* Resolves the current request's effective MCP allowlists from the merged (tenant-scoped)
* config. The registry calls this per inspection/connection so admin-panel `mcpSettings`
* overrides are honored without a restart. Tenant comes from the ALS context inside
* `getAppConfig`; `userId`/`role` pick up user/role-scoped overrides when an actor exists.
* @param {{ userId?: string, role?: string }} [ctx]
*/
async function resolveMCPAllowlists(ctx) {
const appConfig = await getAppConfig({ role: ctx?.role, userId: ctx?.userId });
return {
allowedDomains: appConfig?.mcpSettings?.allowedDomains,
allowedAddresses: appConfig?.mcpSettings?.allowedAddresses,
};
}
/**
* Initialize MCP servers
*/
@ -15,6 +30,7 @@ async function initializeMCPs() {
mongoose,
appConfig?.mcpSettings?.allowedDomains,
appConfig?.mcpSettings?.allowedAddresses,
resolveMCPAllowlists,
);
} catch (error) {
logger.error('[MCP] Failed to initialize MCPServersRegistry:', error);

View file

@ -82,6 +82,7 @@ describe('initializeMCPs', () => {
expect.anything(), // mongoose
['localhost'],
undefined,
expect.any(Function), // per-request allowlist resolver
);
});
@ -98,6 +99,7 @@ describe('initializeMCPs', () => {
expect.anything(),
allowedDomains,
undefined,
expect.any(Function),
);
});
@ -113,9 +115,34 @@ describe('initializeMCPs', () => {
expect.anything(),
undefined,
undefined,
expect.any(Function),
);
});
it('wires a per-request resolver that reads the merged (non-baseOnly) config', async () => {
mockGetAppConfig.mockResolvedValue({
mcpConfig: null,
mcpSettings: { allowedDomains: ['yaml.com'] },
});
await initializeMCPs();
const resolver = mockCreateMCPServersRegistry.mock.calls[0][3];
expect(typeof resolver).toBe('function');
// The resolver resolves the request's merged allowlists — not the boot YAML base.
mockGetAppConfig.mockResolvedValue({
mcpSettings: { allowedDomains: ['merged.com'], allowedAddresses: ['10.0.0.0/8'] },
});
const resolved = await resolver({ userId: 'u1', role: 'ADMIN' });
expect(mockGetAppConfig).toHaveBeenLastCalledWith({ role: 'ADMIN', userId: 'u1' });
expect(resolved).toEqual({
allowedDomains: ['merged.com'],
allowedAddresses: ['10.0.0.0/8'],
});
});
it('should throw and log error if MCPServersRegistry initialization fails', async () => {
const registryError = new Error('Registry initialization failed');
mockCreateMCPServersRegistry.mockImplementation(() => {

View file

@ -9,6 +9,14 @@ interface:
# Mock models price at the default rate, so synthetic usage yields a value.
contextCost: true
mcpSettings:
# Deliberately excludes 127.0.0.1, so the URL-based `e2e-http` server below is blocked
# at boot (stored as inspectionFailed). mcp-allowlist-override.spec.ts adds that origin
# via an admin-panel config override and asserts the server reinitializes — proving the
# admin override is honored by inspection/connection. stdio servers skip this check.
allowedDomains:
- https://allowed.example.com
mcpServers:
e2e-memory:
type: stdio
@ -18,6 +26,12 @@ mcpServers:
title: E2E Memory
description: Local MCP fixture used by mock end-to-end tests.
timeout: 30000
e2e-http:
type: streamable-http
url: http://127.0.0.1:8765/mcp
title: E2E HTTP
description: Local HTTP MCP fixture for allowlist-override e2e tests.
timeout: 30000
endpoints:
custom:

View file

@ -5,6 +5,9 @@ import { getLocalE2EEnv, getE2EBaseURL } from './setup/env';
const rootPath = path.resolve(__dirname, '..');
const serverPath = path.resolve(rootPath, 'e2e/setup/start-server.js');
const mcpHttpServerPath = path.resolve(rootPath, 'e2e/setup/fake-mcp-http-server.js');
/** Must match the `e2e-http` server URL in e2e/config/librechat.e2e.yaml. */
const MCP_HTTP_PORT = process.env.E2E_MCP_HTTP_PORT || '8765';
const fakeModelHookPath = path.resolve(rootPath, 'e2e/setup/fake-model.js');
const configTemplatePath = path.resolve(rootPath, 'e2e/config/librechat.e2e.yaml');
const configPath = path.resolve(rootPath, 'e2e/.generated/librechat.e2e.yaml');
@ -133,5 +136,15 @@ export default defineConfig({
timeout: 120_000,
reuseExistingServer: false,
},
{
// URL-based MCP fixture for the allowlist-override spec (its health route is GET /).
command: `node ${mcpHttpServerPath}`,
cwd: rootPath,
env: { ...process.env, E2E_MCP_HTTP_PORT: MCP_HTTP_PORT },
url: `http://127.0.0.1:${MCP_HTTP_PORT}/`,
stdout: 'pipe',
timeout: 60_000,
reuseExistingServer: false,
},
],
});

View file

@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* Streamable-HTTP MCP fixture for the allowlist-override e2e spec.
*
* Unlike the stdio `fake-mcp-server.js`, this one is reachable over a URL so it
* exercises the `mcpSettings.allowedDomains` check (stdio transports skip it). The
* e2e config deliberately omits this server's origin from `allowedDomains`, so it
* boots as `inspectionFailed`; `mcp-allowlist-override.spec.ts` then adds the origin
* via an admin-panel config override and asserts the server reinitializes.
*
* Mirrors the stateful streamable-HTTP pattern in
* packages/api/src/mcp/__tests__/helpers/oauthTestServer.ts (without OAuth).
*/
const http = require('http');
const { randomUUID } = require('crypto');
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const {
StreamableHTTPServerTransport,
} = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
const PORT = parseInt(process.env.E2E_MCP_HTTP_PORT || '8765', 10);
const HOST = '127.0.0.1';
function createMcpServer() {
const server = new McpServer({ name: 'e2e-http', version: '1.0.0' });
server.registerTool(
'http_ping',
{
description: 'Returns a deterministic value for LibreChat allowlist-override e2e tests.',
inputSchema: {},
},
async () => ({
content: [{ type: 'text', text: 'E2E HTTP MCP pong' }],
}),
);
return server;
}
/** @type {Map<string, InstanceType<typeof StreamableHTTPServerTransport>>} */
const sessions = new Map();
const httpServer = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
// Health endpoint so Playwright's `webServer` can detect readiness.
if (url.pathname === '/' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('ok');
return;
}
if (url.pathname !== '/mcp') {
res.writeHead(404);
res.end();
return;
}
const sid = req.headers['mcp-session-id'];
let transport = typeof sid === 'string' ? sessions.get(sid) : undefined;
if (!transport) {
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
const mcp = createMcpServer();
await mcp.connect(transport);
}
await transport.handleRequest(req, res);
if (transport.sessionId && !sessions.has(transport.sessionId)) {
sessions.set(transport.sessionId, transport);
transport.onclose = () => sessions.delete(transport.sessionId);
}
});
httpServer.listen(PORT, HOST, () => {
console.log(`[e2e] fake HTTP MCP server listening on http://${HOST}:${PORT}/mcp`);
});

View file

@ -0,0 +1,77 @@
import { expect, test } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import { getPrimaryE2EUser } from '../../setup/users.mock';
/**
* Proves the #13809 fix end to end: an admin-panel `mcpSettings.allowedDomains`
* override is honored by MCP inspection/connection without a restart.
*
* `e2e-http` (a URL-based MCP fixture) boots `inspectionFailed` because its origin
* is absent from the YAML allowlist. Adding that origin via an admin config override
* must let the server reinitialize. Before the fix, reinspection used the frozen
* YAML allowlist and the server stayed unreachable.
*
* Pure-API e2e against the real backend + DB: the JWT comes from the Authorization
* header (`ExtractJwt.fromAuthHeaderAsBearerToken`), so we log in for a token rather
* than relying on the browser storage state.
*/
const SERVER_NAME = 'e2e-http';
/** Must match the `e2e-http` URL origin in e2e/config/librechat.e2e.yaml. */
const FIXTURE_ORIGIN = `http://127.0.0.1:${process.env.E2E_MCP_HTTP_PORT || '8765'}`;
async function reinitialize(
request: APIRequestContext,
headers: Record<string, string>,
): Promise<{ status: number; success: boolean }> {
const res = await request.post(`/api/mcp/${SERVER_NAME}/reinitialize`, { headers });
if (!res.ok()) {
return { status: res.status(), success: false };
}
const body = (await res.json()) as { success?: boolean };
return { status: res.status(), success: body.success === true };
}
test.describe('MCP admin-panel allowlist override', () => {
test('honors an admin mcpSettings.allowedDomains override so a blocked server reinitializes', async ({
request,
}) => {
test.setTimeout(120000);
// The seeded primary user is the first-registered user → ADMIN, so it can write
// config overrides. Log in for a Bearer token + the user id.
const { email, password } = getPrimaryE2EUser();
const loginRes = await request.post('/api/auth/login', { data: { email, password } });
expect(loginRes.ok()).toBeTruthy();
const { token, user } = (await loginRes.json()) as {
token: string;
user: { id?: string; _id?: string };
};
const userId = user.id ?? user._id;
expect(token).toBeTruthy();
expect(userId).toBeTruthy();
const headers = { Authorization: `Bearer ${token}` };
// Baseline: the fixture's origin is not in the YAML allowlist, so reinit fails.
const before = await reinitialize(request, headers);
expect(before.status).toBe(200);
expect(before.success).toBe(false);
// Admin-panel override: allow the fixture's origin for this user.
const put = await request.put(`/api/admin/config/user/${userId}`, {
headers,
data: { overrides: { mcpSettings: { allowedDomains: [FIXTURE_ORIGIN] } } },
});
expect(put.ok()).toBeTruthy();
// The override is honored on reinit: the server now connects. invalidateConfigCaches
// runs asynchronously after the PUT, so poll until the merged allowlist lands.
await expect
.poll(async () => (await reinitialize(request, headers)).success, {
timeout: 30000,
intervals: [1000, 2000, 3000],
})
.toBe(true);
});
});

View file

@ -1,8 +1,8 @@
import { logger } from '@librechat/data-schemas';
import type * as t from './types';
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { isUserSourced, requiresUserScopedConnection } from './utils';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { MCPConnection } from './connection';
const CONNECT_CONCURRENCY = 3;
@ -78,14 +78,16 @@ export class ConnectionsRepository {
}
}
const registry = MCPServersRegistry.getInstance();
const { allowedDomains, allowedAddresses, useSSRFProtection } =
await registry.resolveAllowlists({ userId: this.ownerId });
const connection = await MCPConnectionFactory.create(
{
serverName,
serverConfig,
dbSourced: isUserSourced(serverConfig as t.ParsedServerConfig),
useSSRFProtection: registry.shouldEnableSSRFProtection(),
allowedDomains: registry.getAllowedDomains(),
allowedAddresses: registry.getAllowedAddresses(),
useSSRFProtection,
allowedDomains,
allowedAddresses,
},
this.oauthOpts,
);

View file

@ -157,9 +157,8 @@ export class MCPManager extends UserConnectionManager {
}
const registry = MCPServersRegistry.getInstance();
const useSSRFProtection = registry.shouldEnableSSRFProtection();
const allowedDomains = registry.getAllowedDomains();
const allowedAddresses = registry.getAllowedAddresses();
const { allowedDomains, allowedAddresses, useSSRFProtection } =
await registry.resolveAllowlists({ userId: user?.id, role: user?.role });
await this.assertResolvedRuntimeConfigAllowed({
config: serverConfig,
user,
@ -485,15 +484,17 @@ Please follow these instructions when using tools from the respective MCP server
resolvedHeaders['Authorization'] = `Bearer ${oboTokens.access_token}`;
}
if (userId && user && oauthStart && flowManager && isOAuthServer(currentOptions)) {
const { allowedDomains, allowedAddresses, useSSRFProtection } =
await registry.resolveAllowlists({ userId, role: user?.role });
cleanupRequestOAuthHandler = MCPConnectionFactory.attachRequestOAuthHandler(
{
serverName,
serverConfig: currentOptions,
dbSourced: isDbSourced,
skipEnvProcessing: true,
useSSRFProtection: registry.shouldEnableSSRFProtection(),
allowedDomains: registry.getAllowedDomains(),
allowedAddresses: registry.getAllowedAddresses(),
useSSRFProtection,
allowedDomains,
allowedAddresses,
},
{
useOAuth: true,

View file

@ -453,8 +453,8 @@ export abstract class UserConnectionManager {
graphTokenResolver,
});
const registry = MCPServersRegistry.getInstance();
const allowedDomains = registry.getAllowedDomains();
const allowedAddresses = registry.getAllowedAddresses();
const { allowedDomains, allowedAddresses, useSSRFProtection } =
await registry.resolveAllowlists({ userId: user?.id, role: user?.role });
await this.assertResolvedRuntimeConfigAllowed({
config: runtimeConfig,
user,
@ -469,7 +469,7 @@ export abstract class UserConnectionManager {
serverConfig: runtimeConfig,
serverName: serverName,
dbSourced: isUserSourced(runtimeConfig),
useSSRFProtection: registry.shouldEnableSSRFProtection(),
useSSRFProtection,
allowedDomains,
allowedAddresses,
ephemeralConnection,
@ -665,8 +665,10 @@ export abstract class UserConnectionManager {
}
const registry = MCPServersRegistry.getInstance();
const allowedDomains = registry.getAllowedDomains();
const allowedAddresses = registry.getAllowedAddresses();
const { allowedDomains, allowedAddresses } = await registry.resolveAllowlists({
userId: user?.id,
role: user?.role,
});
const allowed = await isMCPDomainAllowed(resolvedConfig, allowedDomains, allowedAddresses);
if (!allowed) {
throw new McpError(

View file

@ -21,12 +21,20 @@ jest.mock('../MCPConnectionFactory', () => ({
jest.mock('../connection');
// Mock the registry
const mockShouldEnableSSRFProtection = jest.fn().mockReturnValue(false);
const mockGetAllowedDomains = jest.fn().mockReturnValue(null);
const mockGetAllowedAddresses = jest.fn().mockReturnValue(null);
const mockRegistryInstance = {
getServerConfig: jest.fn(),
getAllServerConfigs: jest.fn(),
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
shouldEnableSSRFProtection: mockShouldEnableSSRFProtection,
getAllowedDomains: mockGetAllowedDomains,
getAllowedAddresses: mockGetAllowedAddresses,
resolveAllowlists: jest.fn(async () => ({
allowedDomains: mockGetAllowedDomains(),
allowedAddresses: mockGetAllowedAddresses(),
useSSRFProtection: mockShouldEnableSSRFProtection(),
})),
};
jest.mock('../registry/MCPServersRegistry', () => ({

View file

@ -42,13 +42,23 @@ jest.mock('~/auth/domain', () => ({
isMCPDomainAllowed: jest.fn().mockResolvedValue(true),
}));
const mockShouldEnableSSRFProtection = jest.fn().mockReturnValue(false);
const mockGetAllowedDomains = jest.fn().mockReturnValue(null);
const mockGetAllowedAddresses = jest.fn().mockReturnValue(null);
const mockRegistryInstance = {
getServerConfig: jest.fn(),
getAllServerConfigs: jest.fn(),
getOAuthServers: jest.fn(),
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
shouldEnableSSRFProtection: mockShouldEnableSSRFProtection,
getAllowedDomains: mockGetAllowedDomains,
getAllowedAddresses: mockGetAllowedAddresses,
// Mirrors the real per-request resolver by reading the base-allowlist mocks above, so
// existing tests that override getAllowedDomains/shouldEnableSSRFProtection still apply.
resolveAllowlists: jest.fn(async () => ({
allowedDomains: mockGetAllowedDomains(),
allowedAddresses: mockGetAllowedAddresses(),
useSSRFProtection: mockShouldEnableSSRFProtection(),
})),
};
jest.mock('~/mcp/registry/MCPServersRegistry', () => ({

View file

@ -93,6 +93,11 @@ describe('MCP OAuth Race Condition Fixes', () => {
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
resolveAllowlists: jest.fn().mockResolvedValue({
allowedDomains: null,
allowedAddresses: null,
useSSRFProtection: false,
}),
});
const { MCPConnectionFactory } = await import('~/mcp/MCPConnectionFactory');
@ -165,6 +170,11 @@ describe('MCP OAuth Race Condition Fixes', () => {
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
resolveAllowlists: jest.fn().mockResolvedValue({
allowedDomains: null,
allowedAddresses: null,
useSSRFProtection: false,
}),
});
const { MCPConnectionFactory } = await import('~/mcp/MCPConnectionFactory');
@ -246,6 +256,11 @@ describe('MCP OAuth Race Condition Fixes', () => {
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
resolveAllowlists: jest.fn().mockResolvedValue({
allowedDomains: null,
allowedAddresses: null,
useSSRFProtection: false,
}),
});
let releaseConnection: () => void = () => undefined;
@ -346,6 +361,11 @@ describe('MCP OAuth Race Condition Fixes', () => {
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
resolveAllowlists: jest.fn().mockResolvedValue({
allowedDomains: null,
allowedAddresses: null,
useSSRFProtection: false,
}),
});
const { MCPConnectionFactory } = await import('~/mcp/MCPConnectionFactory');

View file

@ -82,6 +82,28 @@ const CONFIG_SERVER_INIT_TIMEOUT_MS = (() => {
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000;
})();
/** Request context for resolving the effective MCP allowlists. */
export interface MCPAllowlistContext {
userId?: string;
role?: string;
}
/**
* Resolves the effective `mcpSettings` allowlists for a request. Injected from the app
* layer (where the merged, tenant-scoped config lives) so the registry keeps no app-config
* dependency. Reads the ALS tenant context internally; pass the acting user to also pick up
* user/role-scoped overrides.
*/
export type MCPAllowlistResolver = (
ctx?: MCPAllowlistContext,
) => Promise<{ allowedDomains?: string[] | null; allowedAddresses?: string[] | null }>;
/** Effective allowlists resolved for a request. */
interface ResolvedMCPAllowlists {
allowedDomains?: string[] | null;
allowedAddresses?: string[] | null;
}
/**
* Central registry for managing MCP server configurations.
* Authoritative source of truth for all MCP servers provided by LibreChat.
@ -99,8 +121,11 @@ export class MCPServersRegistry {
private readonly dbConfigsRepo: ServerConfigsDB;
private readonly cacheConfigsRepo: IServerConfigsRepositoryInterface;
private readonly configCacheRepo: IServerConfigsRepositoryInterface;
/** YAML-derived base allowlists; used at boot and as the fallback when no resolver is set. */
private readonly allowedDomains?: string[] | null;
private readonly allowedAddresses?: string[] | null;
/** Resolves the per-request (tenant-scoped) merged allowlists; falls back to the base above. */
private readonly allowlistResolver?: MCPAllowlistResolver;
private readonly readThroughCache: Keyv<t.ParsedServerConfig>;
private readonly readThroughCacheAll: Keyv<Record<string, t.ParsedServerConfig>>;
private readonly pendingGetAllPromises = new Map<
@ -122,12 +147,14 @@ export class MCPServersRegistry {
mongoose: typeof import('mongoose'),
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
allowlistResolver?: MCPAllowlistResolver,
) {
this.dbConfigsRepo = new ServerConfigsDB(mongoose);
this.cacheConfigsRepo = ServerConfigsCacheFactory.create(APP_CACHE_NAMESPACE, false);
this.configCacheRepo = ServerConfigsCacheFactory.create(CONFIG_CACHE_NAMESPACE, false);
this.allowedDomains = allowedDomains;
this.allowedAddresses = allowedAddresses;
this.allowlistResolver = allowlistResolver;
const ttl = cacheConfig.MCP_REGISTRY_CACHE_TTL;
@ -147,6 +174,7 @@ export class MCPServersRegistry {
mongoose: typeof import('mongoose'),
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
allowlistResolver?: MCPAllowlistResolver,
): MCPServersRegistry {
if (!mongoose) {
throw new Error(
@ -163,6 +191,7 @@ export class MCPServersRegistry {
mongoose,
allowedDomains,
allowedAddresses,
allowlistResolver,
);
return MCPServersRegistry.instance;
}
@ -175,10 +204,12 @@ export class MCPServersRegistry {
return MCPServersRegistry.instance;
}
/** YAML base allowlist (boot/fallback). For request-time decisions use {@link resolveAllowlists}. */
public getAllowedDomains(): string[] | null | undefined {
return this.allowedDomains;
}
/** YAML base allowlist (boot/fallback). For request-time decisions use {@link resolveAllowlists}. */
public getAllowedAddresses(): string[] | null | undefined {
return this.allowedAddresses;
}
@ -188,6 +219,43 @@ export class MCPServersRegistry {
return !Array.isArray(this.allowedDomains) || this.allowedDomains.length === 0;
}
/**
* Resolves the effective domain/address allowlists for the current request.
*
* MCP allowlists live in `mcpSettings`, which is tenant/principal-scoped admin config, so
* they must be read per-request from the merged config not from a process-global value
* that would leak across tenants. The injected resolver reads the ALS tenant context; pass
* the acting user so user/role-scoped overrides resolve too (config-source inspection has no
* user and resolves at tenant scope). Falls back to the YAML base allowlists when no resolver
* is injected or the resolver fails, so a transient lookup error fails to the operator's
* baseline rather than disabling the allowlist.
*/
public async resolveAllowlists(ctx?: MCPAllowlistContext): Promise<{
allowedDomains?: string[] | null;
allowedAddresses?: string[] | null;
useSSRFProtection: boolean;
}> {
let allowedDomains = this.allowedDomains;
let allowedAddresses = this.allowedAddresses;
if (this.allowlistResolver) {
try {
const resolved = await this.allowlistResolver(ctx);
allowedDomains = resolved.allowedDomains;
allowedAddresses = resolved.allowedAddresses;
} catch (error) {
logger.warn(
'[MCPServersRegistry] Allowlist resolver failed; falling back to YAML base allowlists',
error,
);
}
}
return {
allowedDomains,
allowedAddresses,
useSSRFProtection: !Array.isArray(allowedDomains) || allowedDomains.length === 0,
};
}
/**
* Returns the config for a single server, mirroring the precedence used by
* getAllServerConfigs so list views and single-server lookups agree on
@ -341,14 +409,15 @@ export class MCPServersRegistry {
const configRepo = this.getConfigRepository(storageLocation);
const source = (storageLocation === 'CACHE' ? 'yaml' : 'user') as t.MCPServerSource;
const configForInspection = { ...config, source } as t.ParsedServerConfig;
const { allowedDomains, allowedAddresses } = await this.resolveAllowlists({ userId });
let parsedConfig: t.ParsedServerConfig;
try {
parsedConfig = await MCPServerInspector.inspect(
serverName,
configForInspection,
undefined,
this.allowedDomains,
this.allowedAddresses,
allowedDomains,
allowedAddresses,
);
} catch (error) {
logger.error(`[MCPServersRegistry] Failed to inspect server "${serverName}":`, error);
@ -398,14 +467,15 @@ export class MCPServersRegistry {
}
const { inspectionFailed: _, ...configForInspection } = existing;
const { allowedDomains, allowedAddresses } = await this.resolveAllowlists({ userId });
let parsedConfig: t.ParsedServerConfig;
try {
parsedConfig = await MCPServerInspector.inspect(
serverName,
configForInspection,
undefined,
this.allowedDomains,
this.allowedAddresses,
allowedDomains,
allowedAddresses,
);
} catch (error) {
logger.error(`[MCPServersRegistry] Reinspection failed for server "${serverName}":`, error);
@ -445,14 +515,15 @@ export class MCPServersRegistry {
}
}
const { allowedDomains, allowedAddresses } = await this.resolveAllowlists({ userId });
let parsedConfig: t.ParsedServerConfig;
try {
parsedConfig = await MCPServerInspector.inspect(
serverName,
{ ...configForInspection, source } as t.ParsedServerConfig,
undefined,
this.allowedDomains,
this.allowedAddresses,
allowedDomains,
allowedAddresses,
);
} catch (error) {
logger.error(`[MCPServersRegistry] Failed to inspect server "${serverName}":`, error);
@ -483,6 +554,12 @@ export class MCPServersRegistry {
const result: Record<string, t.ParsedServerConfig> = {};
// Config-source servers are admin-defined with no acting user; resolve the effective
// allowlists once at tenant scope and fold them into each config-cache key so a tenant
// whose allowlist rejects a URL cannot poison the shared key for a tenant that allows it.
const { allowedDomains, allowedAddresses } = await this.resolveAllowlists();
const allowlists: ResolvedMCPAllowlists = { allowedDomains, allowedAddresses };
/** Single snapshot of the YAML cache for the whole pass: in the Redis aggregate-key backend, every per-name get() reads and deserializes the full map, so N concurrent per-server lookups would issue N full-map reads. The snapshot also keeps the unchanged-YAML comparison consistent against one view of YAML across all entries. */
const yamlSnapshot = await this.cacheConfigsRepo.getAll();
@ -491,7 +568,7 @@ export class MCPServersRegistry {
if (this.isUnmodifiedYamlServer(yamlSnapshot, serverName, rawConfig)) {
return;
}
const parsed = await this.ensureSingleConfigServer(serverName, rawConfig);
const parsed = await this.ensureSingleConfigServer(serverName, rawConfig, allowlists);
if (parsed) {
result[serverName] = parsed;
}
@ -540,8 +617,9 @@ export class MCPServersRegistry {
private async ensureSingleConfigServer(
serverName: string,
rawConfig: t.MCPOptions,
allowlists: ResolvedMCPAllowlists,
): Promise<t.ParsedServerConfig | undefined> {
const cacheKey = this.configCacheKey(serverName, rawConfig);
const cacheKey = this.configCacheKey(serverName, rawConfig, allowlists);
const cached = await this.configCacheRepo.get(cacheKey);
if (cached) {
@ -558,7 +636,7 @@ export class MCPServersRegistry {
return pending;
}
const initPromise = this.lazyInitConfigServer(cacheKey, serverName, rawConfig);
const initPromise = this.lazyInitConfigServer(cacheKey, serverName, rawConfig, allowlists);
this.pendingConfigInits.set(cacheKey, initPromise);
try {
@ -576,6 +654,7 @@ export class MCPServersRegistry {
cacheKey: string,
serverName: string,
rawConfig: t.MCPOptions,
allowlists: ResolvedMCPAllowlists,
): Promise<t.ParsedServerConfig | undefined> {
const prefix = `[MCP][config][${serverName}]`;
logger.info(`${prefix} Lazy-initializing config-source server`);
@ -585,13 +664,14 @@ export class MCPServersRegistry {
...rawConfig,
source: 'config' as const,
} as t.ParsedServerConfig;
const { allowedDomains, allowedAddresses } = allowlists;
const inspected = await withTimeout(
MCPServerInspector.inspect(
serverName,
configForInspection,
undefined,
this.allowedDomains,
this.allowedAddresses,
allowedDomains,
allowedAddresses,
),
CONFIG_SERVER_INIT_TIMEOUT_MS,
`${prefix} Server initialization timed out`,
@ -787,12 +867,24 @@ export class MCPServersRegistry {
}
/**
* Produces a config-cache key scoped by server name AND a hash of the raw config.
* Prevents cross-tenant cache poisoning when two tenants define the same server name
* with different configurations.
* Produces a config-cache key scoped by server name AND a hash of the raw config plus the
* effective allowlists. Hashing the raw config prevents cross-tenant poisoning when two
* tenants define the same server name with different configurations; hashing the allowlists
* prevents poisoning when the same config resolves differently because tenants have different
* `mcpSettings.allowedDomains` / `allowedAddresses` (so one tenant's inspection result e.g.
* an `inspectionFailed` stub from a rejected domain never satisfies another tenant's lookup).
*/
private configCacheKey(serverName: string, rawConfig: t.MCPOptions): string {
const sorted = JSON.stringify(rawConfig, (_key, value: unknown) => {
private configCacheKey(
serverName: string,
rawConfig: t.MCPOptions,
allowlists?: ResolvedMCPAllowlists,
): string {
const payload = {
rawConfig,
allowedDomains: allowlists?.allowedDomains ?? null,
allowedAddresses: allowlists?.allowedAddresses ?? null,
};
const sorted = JSON.stringify(payload, (_key, value: unknown) => {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)),

View file

@ -220,6 +220,114 @@ describe('MCPServersRegistry', () => {
});
});
describe('resolveAllowlists (per-request, tenant-scoped)', () => {
const createWith = (
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
resolver?: (ctx?: { userId?: string; role?: string }) => Promise<{
allowedDomains?: string[] | null;
allowedAddresses?: string[] | null;
}>,
): MCPServersRegistry => {
(MCPServersRegistry as unknown as { instance: undefined }).instance = undefined;
MCPServersRegistry.createInstance(mockMongoose, allowedDomains, allowedAddresses, resolver);
return MCPServersRegistry.getInstance();
};
it('returns the YAML base allowlists when no resolver is injected', async () => {
const reg = createWith(['yaml.com'], ['10.0.0.0/8']);
await expect(reg.resolveAllowlists()).resolves.toEqual({
allowedDomains: ['yaml.com'],
allowedAddresses: ['10.0.0.0/8'],
useSSRFProtection: false,
});
});
it('enables SSRF protection when the effective allowlist is empty', async () => {
const reg = createWith(undefined, undefined);
await expect(reg.resolveAllowlists()).resolves.toEqual({
allowedDomains: undefined,
allowedAddresses: undefined,
useSSRFProtection: true,
});
});
it('returns the resolver-provided merged allowlists and forwards the context', async () => {
const resolver = jest.fn().mockResolvedValue({
allowedDomains: ['admin-added.com'],
allowedAddresses: ['172.16.0.0/12'],
});
const reg = createWith(['yaml.com'], null, resolver);
const result = await reg.resolveAllowlists({ userId: 'u1', role: 'ADMIN' });
expect(resolver).toHaveBeenCalledWith({ userId: 'u1', role: 'ADMIN' });
expect(result).toEqual({
allowedDomains: ['admin-added.com'],
allowedAddresses: ['172.16.0.0/12'],
useSSRFProtection: false,
});
});
it('falls back to the YAML base allowlists when the resolver throws', async () => {
const resolver = jest.fn().mockRejectedValue(new Error('DB down'));
const reg = createWith(['yaml.com'], null, resolver);
await expect(reg.resolveAllowlists()).resolves.toEqual({
allowedDomains: ['yaml.com'],
allowedAddresses: null,
useSSRFProtection: false,
});
});
it('inspects against the resolved (admin-panel) allowlist, not the YAML base', async () => {
const resolver = jest.fn().mockResolvedValue({
allowedDomains: ['admin-added.com'],
allowedAddresses: ['10.0.0.0/8'],
});
const reg = createWith(['yaml-only.com'], null, resolver);
const inspectSpy = jest.spyOn(MCPServerInspector, 'inspect');
await reg.reset();
await reg.addServer(
'admin_panel_server',
{ type: 'streamable-http', url: 'https://admin-added.com/mcp' },
'DB',
'user-1',
);
expect(resolver).toHaveBeenCalledWith({ userId: 'user-1' });
expect(inspectSpy).toHaveBeenCalledWith(
'admin_panel_server',
expect.objectContaining({ url: 'https://admin-added.com/mcp' }),
undefined,
['admin-added.com'],
['10.0.0.0/8'],
);
});
it('scopes the config-source cache key by the resolved allowlist (no cross-tenant poison)', async () => {
const resolver = jest
.fn()
.mockResolvedValueOnce({ allowedDomains: ['a.com'], allowedAddresses: null })
.mockResolvedValueOnce({ allowedDomains: ['b.com'], allowedAddresses: null });
const reg = createWith(null, null, resolver);
const inspectSpy = jest.spyOn(MCPServerInspector, 'inspect');
await reg.reset();
inspectSpy.mockClear();
const cfg = {
srv: { type: 'streamable-http' as const, url: 'https://srv.example.com/mcp' },
};
await reg.ensureConfigServers(cfg); // resolver call 1 → allowlist A
await reg.ensureConfigServers(cfg); // resolver call 2 → allowlist B (distinct key)
// Different resolved allowlists ⇒ different cache keys ⇒ the second pass re-inspects
// instead of reusing the first allowlist's cached entry.
expect(inspectSpy).toHaveBeenCalledTimes(2);
});
});
describe('reset', () => {
it('should clear all servers from cache repository', async () => {
// Add servers to cache using the new API