LibreChat/api/server/services/initializeMCPs.spec.js
Danny Avila 5c939d129b
🔌 feat: Add Agent Plugins (Experimental) (#14704)
* 🔌 feat: Add Agent Plugins v1.0.0 Support

Implements the Agent Plugins 1.0.0 specification so LibreChat can load
portable plugin packages: a `plugin.json` manifest, `skills/` holding Agent
Skills, `mcp.json` describing MCP servers, and reverse-domain extension
directories.

- Validate the closed `plugin.json` schema, selecting rules from `$schema`
  without retrieving it. Unknown top-level fields and a non-object
  `extensions` field are reported and ignored; every other violation rejects
  the plugin.
- Enforce plugin-root containment through realpath, including for paths whose
  leaf does not exist, and apply the narrowest failure boundary per component.
- Map `mcp.json` onto LibreChat MCP options across stdio, Streamable HTTP, and
  legacy HTTP+SSE, bypassing the config loader's `${VAR}` process-env
  expansion so plugin values never resolve against the server environment.
- Expand only `${PLUGIN_ROOT}` and `${PLUGIN_DATA}`, once and non-recursively,
  in `args`, `env` values, and `cwd`; supply both variables to the subprocess
  after configured `env`, and reject entries that declare them.
- Discover skills from the immediate children of `skills/` only, reusing the
  deployment skill loader so plugin skills are ordinary deployment skills with
  a distinct id namespace.
- Read LibreChat's `ai.librechat` extension directory and hand
  `hooks/hooks.json` to the Claude hook compatibility layer.
- Load operator-installed plugins from `DEPLOYMENT_PLUGINS_DIR` at startup,
  merging their skills into the deployment skill registry and their MCP
  servers into the app config. Plugins never displace a configured server or
  deployment skill.
- Add `cwd` to the stdio MCP transport, which the specification requires and
  LibreChat did not previously support.

Component failures stay isolated: a malformed `mcp.json`, an invalid skill, or
a bad hooks document never prevents the rest of a plugin from loading.

* 🔒 fix: Contain Agent Plugins config at the runtime boundary

Review of #14704 surfaced that every real finding sat where the loader's
output crosses into LibreChat's existing runtime, not in the specification
logic. The loader deliberately left plugin placeholders literal, but
downstream layers re-processed the same fields and undid it.

- Mark plugin MCP configuration with `source: 'plugin'` and return it verbatim
  from `processMCPEnv`. Without this a remote plugin could declare
  `Authorization: Bearer ${OPENAI_API_KEY}` and receive host credentials at its
  own origin. The gate reads the configuration rather than a caller-supplied
  flag, so no future call site can reintroduce the leak by omitting it.
- Skip `preProcessGraphTokens` for plugin configuration as well; it resolves
  placeholders into headers, url, and args on the same path.
- Reject plugin server names that change under `normalizeServerName`. Tool keys
  embed the normalized name while request-time resolution uses the raw name, so
  an unstable name published tools that nothing could resolve.
- Reject `__proto__`, `constructor`, and `prototype` as server names, and merge
  plugin servers with `Object.defineProperty` and an own-property conflict
  check, so a package cannot reach a prototype setter or collide with an
  inherited member.
- Enforce manifest-name uniqueness before components are accepted; two packages
  sharing a name would share one `PLUGIN_DATA` directory.
- Isolate a failed data-directory creation to the single plugin instead of
  rejecting the whole scan.
- Prefix rejected-plugin diagnostics with the directory, which is the only
  identifier a package without a valid manifest has.
- Type extension namespace contents as JSON rather than `unknown`, and correct
  the header field-value comment to name obs-text.

Verified end to end from the built package: a plugin declaring an environment
placeholder in a header reaches the transport with the placeholder intact while
operator-authored configuration still resolves normally.

* 🔇 fix: Report Agent Plugin hooks that will not run

The loader reads `ai.librechat/hooks/hooks.json`, but nothing registers the
resulting plan, and startup supplies no hook capabilities. A package declaring
hooks was therefore accepted in silence, leaving an operator to believe the
hooks ran.

Detect the document when no capabilities are registered and report it as
unsupported, so the limitation is visible in startup diagnostics rather than
inferred from behavior that never happens.

* 🧯 test: Restore MCP startup test mocks

Carries the two mock additions from #14711 so this branch can prove itself
green. `initializeMCPs` now calls `syncStaticTools`, which the server startup
specs do not stub, so they fail on every branch that has not picked this up.
Drops out of the rebase once #14711 lands.

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-09 08:10:22 -04:00

464 lines
16 KiB
JavaScript

/**
* Tests for initializeMCPs.js
*
* These tests verify that MCPServersRegistry and MCPManager are ALWAYS initialized,
* even when no explicitly configured MCP servers exist. This is critical for the
* "Dynamic MCP Server Management" feature (introduced in `0.8.2-rc1` release) which
* allows users to add MCP servers via the UI without requiring explicit configuration.
*
* Bug fixed: Previously, MCPManager was only initialized when mcpServers existed
* in librechat.yaml, causing "MCPManager has not been initialized" errors when
* users tried to create MCP servers via the UI.
*/
// Mock dependencies before imports
jest.mock('mongoose', () => ({
connection: { readyState: 1 },
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
},
}));
// Mock config functions
const mockGetAppConfig = jest.fn();
const mockSyncStaticTools = jest.fn();
const mockMergeAppTools = jest.fn();
jest.mock('./Config', () => ({
get getAppConfig() {
return mockGetAppConfig;
},
get mergeAppTools() {
return mockMergeAppTools;
},
get syncStaticTools() {
return mockSyncStaticTools;
},
}));
// Mock MCP singletons
const mockCreateMCPServersRegistry = jest.fn();
const mockCreateMCPManager = jest.fn();
const mockMCPManagerInstance = {
connectAppServers: jest.fn(),
disconnectAppServers: jest.fn(),
getAppToolFunctions: jest.fn(),
};
jest.mock('~/config', () => ({
get createMCPServersRegistry() {
return mockCreateMCPServersRegistry;
},
get createMCPManager() {
return mockCreateMCPManager;
},
}));
const mockSetMCPToolsChangedHandler = jest.fn();
const mockSetMCPToolsChangedGenerationHandler = jest.fn();
const mockSetMCPToolsChangedGenerationRenewalHandler = jest.fn();
const mockSetMCPToolsChangedRevisionHandler = jest.fn();
const mockRegisterShutdownTask = jest.fn();
const mockUpdateMCPServerTools = jest.fn();
const mockGetMCPToolsCacheGeneration = jest.fn();
const mockRenewMCPToolsCacheGeneration = jest.fn();
const mockGetNextAppToolsPublicationRevision = jest.fn();
const mockGetDeploymentPluginMcpServers = jest.fn(() => ({}));
jest.mock('@librechat/api', () => ({
get registerShutdownTask() {
return mockRegisterShutdownTask;
},
get getDeploymentPluginMcpServers() {
return mockGetDeploymentPluginMcpServers;
},
get setMCPToolsChangedHandler() {
return mockSetMCPToolsChangedHandler;
},
get setMCPToolsChangedGenerationHandler() {
return mockSetMCPToolsChangedGenerationHandler;
},
get setMCPToolsChangedGenerationRenewalHandler() {
return mockSetMCPToolsChangedGenerationRenewalHandler;
},
get setMCPToolsChangedRevisionHandler() {
return mockSetMCPToolsChangedRevisionHandler;
},
}));
jest.mock('./Config/mcp', () => ({
get updateMCPServerTools() {
return mockUpdateMCPServerTools;
},
get getMCPToolsCacheGeneration() {
return mockGetMCPToolsCacheGeneration;
},
get renewMCPToolsCacheGeneration() {
return mockRenewMCPToolsCacheGeneration;
},
get getNextAppToolsPublicationRevision() {
return mockGetNextAppToolsPublicationRevision;
},
}));
const { logger } = require('@librechat/data-schemas');
const initializeMCPs = require('./initializeMCPs');
describe('initializeMCPs', () => {
beforeEach(() => {
jest.clearAllMocks();
// Default: successful initialization
mockCreateMCPServersRegistry.mockReturnValue(undefined);
mockCreateMCPManager.mockResolvedValue(mockMCPManagerInstance);
mockMCPManagerInstance.getAppToolFunctions.mockResolvedValue({});
mockMCPManagerInstance.connectAppServers.mockResolvedValue(undefined);
mockMCPManagerInstance.disconnectAppServers.mockResolvedValue(undefined);
mockSyncStaticTools.mockResolvedValue(undefined);
mockMergeAppTools.mockResolvedValue(undefined);
});
describe('MCPServersRegistry initialization', () => {
it('should ALWAYS initialize MCPServersRegistry even without configured servers', async () => {
mockGetAppConfig.mockResolvedValue({
mcpConfig: null, // No configured servers
mcpSettings: { allowedDomains: ['localhost'] },
});
await initializeMCPs();
expect(mockCreateMCPServersRegistry).toHaveBeenCalledTimes(1);
expect(mockCreateMCPServersRegistry).toHaveBeenCalledWith(
expect.anything(), // mongoose
['localhost'],
undefined,
expect.any(Function), // per-request allowlist resolver
);
});
it('should pass allowedDomains from mcpSettings to registry', async () => {
const allowedDomains = ['localhost', '*.example.com', 'trusted-mcp.com'];
mockGetAppConfig.mockResolvedValue({
mcpConfig: null,
mcpSettings: { allowedDomains },
});
await initializeMCPs();
expect(mockCreateMCPServersRegistry).toHaveBeenCalledWith(
expect.anything(),
allowedDomains,
undefined,
expect.any(Function),
);
});
it('should handle undefined mcpSettings gracefully', async () => {
mockGetAppConfig.mockResolvedValue({
mcpConfig: null,
// mcpSettings is undefined
});
await initializeMCPs();
expect(mockCreateMCPServersRegistry).toHaveBeenCalledWith(
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(() => {
throw registryError;
});
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
await expect(initializeMCPs()).rejects.toThrow('Registry initialization failed');
expect(logger.error).toHaveBeenCalledWith(
'[MCP] Failed to initialize MCPServersRegistry:',
registryError,
);
});
});
describe('MCPManager initialization', () => {
it('should ALWAYS initialize MCPManager even without configured servers', async () => {
mockGetAppConfig.mockResolvedValue({
mcpConfig: null, // No configured servers
});
await initializeMCPs();
// MCPManager should be created with empty object when no configured servers
expect(mockCreateMCPManager).toHaveBeenCalledTimes(1);
expect(mockCreateMCPManager).toHaveBeenCalledWith({});
});
it('should initialize MCPManager with configured servers when provided', async () => {
const mcpServers = {
'test-server': { type: 'sse', url: 'http://localhost:3001/sse' },
'local-server': { type: 'stdio', command: 'node', args: ['server.js'] },
};
mockGetAppConfig.mockResolvedValue({ mcpConfig: mcpServers });
await initializeMCPs();
expect(mockCreateMCPManager).toHaveBeenCalledWith(mcpServers);
});
it('should register app connections for graceful shutdown', async () => {
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
await initializeMCPs();
expect(mockRegisterShutdownTask).toHaveBeenCalledWith(
'MCP app connections',
expect.any(Function),
);
const shutdown = mockRegisterShutdownTask.mock.calls[0][1];
await shutdown();
expect(mockMCPManagerInstance.disconnectAppServers).toHaveBeenCalledTimes(1);
});
it('should wire app publication revision allocation into the cache store', async () => {
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
mockGetNextAppToolsPublicationRevision.mockResolvedValue('9');
await initializeMCPs();
const allocateRevision = mockSetMCPToolsChangedRevisionHandler.mock.calls[0][0];
await expect(
allocateRevision({ serverName: 'dynamic', configGeneration: 'config-generation' }),
).resolves.toBe('9');
expect(mockGetNextAppToolsPublicationRevision).toHaveBeenCalledWith(
'dynamic',
'config-generation',
);
});
it('should throw and log error if MCPManager initialization fails', async () => {
const managerError = new Error('Manager initialization failed');
mockCreateMCPManager.mockRejectedValue(managerError);
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
await expect(initializeMCPs()).rejects.toThrow('Manager initialization failed');
expect(logger.error).toHaveBeenCalledWith(
'[MCP] Failed to initialize MCPManager:',
managerError,
);
});
});
describe('Tool merging behavior', () => {
it('should skip app catalog discovery when no configured servers exist', async () => {
mockGetAppConfig.mockResolvedValue({
mcpConfig: null, // No configured servers
availableTools: { builtin: { type: 'function' } },
});
await initializeMCPs();
expect(mockMCPManagerInstance.getAppToolFunctions).not.toHaveBeenCalled();
expect(mockMergeAppTools).not.toHaveBeenCalled();
expect(mockSyncStaticTools).toHaveBeenCalledWith({ builtin: { type: 'function' } });
expect(mockMCPManagerInstance.connectAppServers).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
'[MCP] No servers configured. MCPManager ready for UI-based servers.',
);
});
it('should skip app catalog discovery when mcpConfig is empty', async () => {
mockGetAppConfig.mockResolvedValue({
mcpConfig: {}, // Empty object
});
await initializeMCPs();
expect(mockMCPManagerInstance.getAppToolFunctions).not.toHaveBeenCalled();
expect(mockMergeAppTools).not.toHaveBeenCalled();
expect(mockSyncStaticTools).toHaveBeenCalledWith({});
expect(mockMCPManagerInstance.connectAppServers).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
'[MCP] No servers configured. MCPManager ready for UI-based servers.',
);
});
it('should merge tools when configured servers exist', async () => {
const mcpServers = {
'test-server': { type: 'sse', url: 'http://localhost:3001/sse' },
};
const mcpTools = {
tool1: jest.fn(),
tool2: jest.fn(),
};
mockGetAppConfig.mockResolvedValue({ mcpConfig: mcpServers });
mockMCPManagerInstance.getAppToolFunctions.mockResolvedValue(mcpTools);
await initializeMCPs();
expect(mockMCPManagerInstance.getAppToolFunctions).toHaveBeenCalledTimes(1);
expect(mockMergeAppTools).toHaveBeenCalledWith(mcpTools, {});
expect(mockMCPManagerInstance.connectAppServers).toHaveBeenCalledTimes(1);
expect(mockMergeAppTools.mock.invocationCallOrder[0]).toBeLessThan(
mockMCPManagerInstance.connectAppServers.mock.invocationCallOrder[0],
);
expect(logger.info).toHaveBeenCalledWith(
'[MCP] Initialized with 1 configured server and 2 tools.',
);
});
it('should handle null return from getAppToolFunctions', async () => {
const mcpServers = { 'test-server': { type: 'sse', url: 'http://localhost:3001' } };
mockGetAppConfig.mockResolvedValue({ mcpConfig: mcpServers });
mockMCPManagerInstance.getAppToolFunctions.mockResolvedValue(null);
await initializeMCPs();
// Should use empty object fallback
expect(mockMergeAppTools).toHaveBeenCalledWith({}, {});
expect(logger.info).toHaveBeenCalledWith(
'[MCP] Initialized with 1 configured server and 0 tools.',
);
});
it('should connect app servers when startup cache synchronization fails', async () => {
const mcpServers = { 'test-server': { type: 'sse', url: 'http://localhost:3001' } };
mockGetAppConfig.mockResolvedValue({ mcpConfig: mcpServers });
mockMergeAppTools.mockRejectedValueOnce(new Error('cache lock timed out'));
await expect(initializeMCPs()).rejects.toThrow('cache lock timed out');
expect(mockMCPManagerInstance.connectAppServers).toHaveBeenCalledTimes(1);
});
});
describe('Initialization order', () => {
it('should initialize Registry before Manager', async () => {
const callOrder = [];
mockCreateMCPServersRegistry.mockImplementation(() => {
callOrder.push('registry');
});
mockCreateMCPManager.mockImplementation(async () => {
callOrder.push('manager');
return mockMCPManagerInstance;
});
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
await initializeMCPs();
expect(callOrder).toEqual(['registry', 'manager']);
});
it('should not attempt MCPManager initialization if Registry fails', async () => {
mockCreateMCPServersRegistry.mockImplementation(() => {
throw new Error('Registry failed');
});
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
await expect(initializeMCPs()).rejects.toThrow('Registry failed');
expect(mockCreateMCPManager).not.toHaveBeenCalled();
});
});
describe('UI-based MCP server management support', () => {
/**
* This test documents the critical fix:
* MCPManager must be initialized even without configured servers to support
* the "Dynamic MCP Server Management" feature where users create
* MCP servers via the UI.
*/
it('should support UI-based server creation without explicit configuration', async () => {
// Scenario: User has no MCP servers in librechat.yaml but wants to
// add servers via the UI
mockGetAppConfig.mockResolvedValue({
mcpConfig: null,
mcpSettings: undefined,
});
await initializeMCPs();
// Both singletons must be initialized for UI-based management to work
expect(mockCreateMCPServersRegistry).toHaveBeenCalledTimes(1);
expect(mockCreateMCPManager).toHaveBeenCalledTimes(1);
// Verify manager was created with empty config (not null/undefined)
expect(mockCreateMCPManager).toHaveBeenCalledWith({});
});
});
});
describe('refreshChangedServerTools', () => {
const { refreshChangedServerTools } = require('./initializeMCPs');
const event = {
serverName: 'dynamic',
serverConfig: { type: 'streamable-http', url: 'https://mcp.example.com' },
tools: [{ name: 'tool', inputSchema: { type: 'object' } }],
};
beforeEach(() => {
jest.clearAllMocks();
});
it('publishes the complete refreshed snapshot in its original cache scope', async () => {
await refreshChangedServerTools({ ...event, userId: 'user-1' });
expect(mockUpdateMCPServerTools).toHaveBeenCalledWith({ ...event, userId: 'user-1' });
expect(logger.info).toHaveBeenCalledWith(
'[MCP][dynamic] Tool list changed; refreshed 1 tool for user user-1',
);
});
it('publishes an empty app-level snapshot so removals take effect', async () => {
await refreshChangedServerTools({ ...event, tools: [] });
expect(mockUpdateMCPServerTools).toHaveBeenCalledWith({ ...event, tools: [] });
});
it('is registered as the tools-changed handler during initialization', async () => {
mockGetAppConfig.mockResolvedValue({ mcpConfig: null, mcpSettings: {} });
await initializeMCPs();
expect(mockSetMCPToolsChangedHandler).toHaveBeenCalledWith(refreshChangedServerTools);
expect(mockSetMCPToolsChangedGenerationHandler).toHaveBeenCalledWith(
mockGetMCPToolsCacheGeneration,
);
expect(mockSetMCPToolsChangedGenerationRenewalHandler).toHaveBeenCalledWith(
mockRenewMCPToolsCacheGeneration,
);
});
});