mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-13 18:37:25 +00:00
* fix: recover missing MCP marketplace catalogs * fix: make MCP catalog recovery passive * test: type MCP catalog recovery fixtures * fix: bound and back off passive MCP catalog recovery Passive recovery runs inline on `GET /api/mcp/tools` and its results are request-local by design, so every list request re-dialed the same cold servers with the default connection timeout. Three limits keep that cost proportional to what recovery can actually recover: - Cap the discovery timeout at 5s instead of inheriting the connection default (`initTimeout ?? 30s`); a server configured to connect faster keeps its own shorter limit. - Skip a server the config tier already marked `inspectionFailed`, leaving it to that tier's retry window rather than re-dialing it per request. - Skip a server whose declared `customUserVars` are unset, matching the gate `reinitMCPServer` applies for issue #10969 — connecting without them fails auth, so the attempt is spent for nothing. Servers that still fail discovery enter a one-minute per-process cooldown, which is what stops an unreachable server from being re-dialed by every subsequent list request. A server that recovers clears its own entry, and expired entries are swept at most once per window so the map stays bounded. Skipped servers render exactly as they did before recovery existed: present in the catalog with an empty tool list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B * fix: bound passive MCP recovery by deadline, key cooldowns by config Both follow-ups address the same mistake: recovery expressed its own request-level constraints in terms borrowed from other layers. `connectionTimeout` bounds one connection attempt, and `MCPConnectionFactory.discoverToolsInternal` spends it twice — once on the authenticated connection, then again in `attemptUnauthenticatedToolListing` — so capping it bounded no total this layer could reason about. Recovery now enforces its own wall-clock deadline per server with `withTimeout`, which holds however many attempts the factory makes; `connectionTimeout` is left to do only its own job, still honouring a shorter operator `initTimeout`. An attempt abandoned by the deadline disposes its own connection when it settles, and `Promise.race` keeps a handler on it, so a late rejection is not unhandled. A per-request budget now caps total recovery regardless of server count. A server is dialed only if the remaining budget can fund a full deadline; never dialing one is not evidence against it, so a skipped server records no cooldown and a later request reaches it once those ahead are cached or cooling down. Cooldown identity now includes the publication generation — the same effective-config identity the tool caches fence on — instead of just user and server name. Correcting a server's URL or transport keys a new entry, so the refetch the client issues on update is no longer skipped for up to a minute by the previous configuration's failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B * refactor: keep passive MCP recovery stateless and bounded by its own work Reverts the cooldown, request budget and deadline race added in257d5cfandfc3e3c9, and keeps only the three stateless limits. The tool cache refuses unfenced writes (`tools.ts`), and a discovery connection owns no publication generation and is disposed, so a recovered catalog cannot be retained by design. Those commits responded by building a cache-shaped memory in front of it — per-process failure state, a scheduling budget, an identity, an eviction sweep — and each round of review found another way that hand-rolled cache differed from a real one: wrong identity for configuration, wrong identity for credentials, no fairness across requests, and a limiter slot released while its network operation was still running. None of that machinery was asked for; all of it was compensation for a result the architecture does not allow keeping. Recovery is now stateless. It skips only what configuration alone proves pointless — a server the config tier already marked `inspectionFailed`, and one whose declared `customUserVars` are unset — and bounds the work itself rather than racing it, so a limiter slot is held for exactly as long as its network operation runs and the concurrency limit of three is real. The attempt timeout is not a compromise: recovery exists for a server that is reachable and authorized but whose catalog cache expired, and such a server answers tools/list well inside 1.5s. Anything slower cannot be rescued here, so failing fast costs nothing. The factory spends that value per attempt, so a server's ceiling is it times the attempts made; the constant documents that rather than hiding it behind a number tuned to today's attempt count. Consequences that were bugs are now gone by construction: every cold server is attempted on every request, so none is starved by those ahead of it, and correcting a server's configuration or credentials takes effect on the next refetch instead of waiting out a stale cooldown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B * fix: correct the inspection-failure skip and bound catalog fan-out Three fixes that belong to this layer; a fourth issue does not, and is described below. The `inspectionFailed` skip was too broad. `MCPServersInitializer` stores a YAML server that was unreachable at startup via `addServerStub`, which stamps `source: 'yaml'`, and only config-tier entries get the timed retry in `ensureSingleConfigServer`. Skipping every failed stub therefore hid a recoverable server from the marketplace permanently — the exact state this recovery exists to escape. It now defers only `source === 'config'`, matching what `reinitMCPServer` already does. Plugin auth is read only when some cold server actually declares `customUserVars`, and only for those servers. The common unauthenticated case no longer pays a MongoDB round trip whose result nothing can consume. Snapshot refreshes are now bounded by the same limiter as discovery. They are not local reads: both connection paths reach `fetchOrderedToolsSnapshot` and issue a real `tools/list`, so a cache reset across many servers previously burst unbounded outbound requests while discovery was capped at three. Not fixed here, because it cannot be: `connectionTimeout` does not bound discovery. It covers `connection.connect()` only, and `fetchToolsSnapshot` then applies its own `TOOLS_LIST_TIMEOUT_MS` (30s) to `tools/list`, so a server that connects fast and stalls while listing still holds its slot for that window. The factory also does not cancel a timed-out connect before starting the unauthenticated fallback. Bounding this end to end needs a deadline threaded through `MCPConnectionFactory` into both `connect()` and `fetchToolsSnapshot()`, which is a change to shared connection machinery rather than to this caller. The constant's comment now states what it does and does not bound instead of implying an end-to-end guarantee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B * fix: thread live-session OBO context into passive catalog discovery The merge of #15334 sources OBO tokens from the live OpenID session via request-boundary closures. Passive catalog recovery is a discovery call site too; without these options an OBO server whose stored token went stale fails recovery — the exact cold-catalog class this PR fixes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B --------- Co-authored-by: Claude <noreply@anthropic.com>
609 lines
20 KiB
JavaScript
609 lines
20 KiB
JavaScript
const { Constants } = require('librechat-data-provider');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
|
|
const mockGetConnection = jest.fn();
|
|
const mockDiscoverServerTools = jest.fn();
|
|
const mockGetGraphApiToken = jest.fn();
|
|
const mockUpdateMCPServerTools = jest.fn();
|
|
const mockGetMCPToolsCacheGeneration = jest.fn().mockResolvedValue('generation-current');
|
|
const mockGetToolPublicationGeneration = jest.fn().mockReturnValue('generation-current');
|
|
const mockLoadCatalogs = jest.fn();
|
|
const mockGetUserMCPAuthMap = jest.fn();
|
|
const mockFormatMCPServerTools = jest.fn();
|
|
const mockGetMCPServerTools = jest.fn();
|
|
const mockCacheMCPServerTools = jest.fn();
|
|
const mockGetServerToolFunctionsSnapshot = jest.fn();
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
...jest.requireActual('@librechat/api'),
|
|
loadMCPServerCatalogs: (...args) => mockLoadCatalogs(...args),
|
|
getUserMCPAuthMap: (...args) => mockGetUserMCPAuthMap(...args),
|
|
formatMCPServerTools: (...args) => mockFormatMCPServerTools(...args),
|
|
}));
|
|
|
|
jest.mock('~/config', () => ({
|
|
getMCPManager: jest.fn(() => ({
|
|
getConnection: mockGetConnection,
|
|
discoverServerTools: mockDiscoverServerTools,
|
|
getServerToolFunctionsSnapshot: mockGetServerToolFunctionsSnapshot,
|
|
getToolPublicationGeneration: mockGetToolPublicationGeneration,
|
|
})),
|
|
getMCPServersRegistry: jest.fn(() => ({ getServerConfig: jest.fn() })),
|
|
getFlowStateManager: jest.fn(() => ({})),
|
|
}));
|
|
jest.mock('~/models', () => ({
|
|
findToken: jest.fn(),
|
|
createToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
findPluginAuthsByKeys: jest.fn(),
|
|
}));
|
|
jest.mock('~/server/services/Config', () => ({
|
|
updateMCPServerTools: mockUpdateMCPServerTools,
|
|
getMCPToolsCacheGeneration: mockGetMCPToolsCacheGeneration,
|
|
getMCPServerTools: mockGetMCPServerTools,
|
|
cacheMCPServerTools: mockCacheMCPServerTools,
|
|
}));
|
|
jest.mock('~/server/services/GraphTokenService', () => ({
|
|
getGraphApiToken: mockGetGraphApiToken,
|
|
}));
|
|
jest.mock('~/cache', () => ({
|
|
getLogStores: jest.fn(() => ({})),
|
|
}));
|
|
|
|
const { reinitMCPServer, loadMCPServerCatalogs } = require('./mcp');
|
|
|
|
describe('loadMCPServerCatalogs', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('wires batched auth and passive discovery without opening a managed connection', async () => {
|
|
const user = { id: 'user-123' };
|
|
const servers = [
|
|
{
|
|
serverName: 'config-only',
|
|
serverConfig: { type: 'sse', url: 'https://config.example.com/sse' },
|
|
},
|
|
{
|
|
serverName: 'user-server',
|
|
serverConfig: { type: 'sse', url: 'https://user.example.com/sse' },
|
|
},
|
|
];
|
|
mockGetUserMCPAuthMap.mockResolvedValue({});
|
|
mockDiscoverServerTools.mockResolvedValue({ tools: [] });
|
|
mockFormatMCPServerTools.mockReturnValue({});
|
|
mockLoadCatalogs.mockImplementation(async (params, deps) => {
|
|
await deps.loadUserMCPAuthMap(
|
|
user.id,
|
|
servers.map(({ serverName }) => serverName),
|
|
);
|
|
await deps.discoverServerTools({
|
|
user,
|
|
serverName: 'config-only',
|
|
configServers: { 'config-only': servers[0].serverConfig },
|
|
});
|
|
deps.formatServerTools('config-only', []);
|
|
await deps.getCachedServerTools(user.id, 'config-only', servers[0].serverConfig);
|
|
await deps.getServerToolFunctionsSnapshot(user.id, 'config-only', servers[0].serverConfig);
|
|
await deps.cacheServerTools({ serverName: 'config-only' });
|
|
return { serverTools: new Map([['config-only', {}]]), serversWithoutTools: [] };
|
|
});
|
|
|
|
const upstreamTokenProvider = jest.fn();
|
|
const oboIdentityContext = { appUserId: 'user-123' };
|
|
const result = await loadMCPServerCatalogs({
|
|
user,
|
|
servers,
|
|
upstreamTokenProvider,
|
|
oboIdentityContext,
|
|
});
|
|
|
|
expect(mockGetUserMCPAuthMap).toHaveBeenCalledTimes(1);
|
|
expect(mockGetUserMCPAuthMap).toHaveBeenCalledWith({
|
|
userId: user.id,
|
|
servers: ['config-only', 'user-server'],
|
|
findPluginAuthsByKeys: require('~/models').findPluginAuthsByKeys,
|
|
});
|
|
expect(mockDiscoverServerTools).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
user,
|
|
serverName: 'config-only',
|
|
configServers: { 'config-only': servers[0].serverConfig },
|
|
flowManager: expect.any(Object),
|
|
tokenMethods: expect.any(Object),
|
|
upstreamTokenProvider,
|
|
oboIdentityContext,
|
|
}),
|
|
);
|
|
expect(mockGetConnection).not.toHaveBeenCalled();
|
|
expect(mockGetMCPServerTools).toHaveBeenCalledWith(
|
|
user.id,
|
|
'config-only',
|
|
servers[0].serverConfig,
|
|
);
|
|
expect(mockGetServerToolFunctionsSnapshot).toHaveBeenCalledWith(
|
|
user.id,
|
|
'config-only',
|
|
servers[0].serverConfig,
|
|
);
|
|
expect(mockCacheMCPServerTools).toHaveBeenCalledWith({ serverName: 'config-only' });
|
|
expect(result).toEqual({
|
|
serverTools: new Map([['config-only', {}]]),
|
|
serversWithoutTools: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('reinitMCPServer — customUserVars gating (issue #10969)', () => {
|
|
const user = { id: 'user-123' };
|
|
const serverName = 'Thingy';
|
|
const serverConfig = {
|
|
type: 'streamable-http',
|
|
url: 'https://thingy.example.com/mcp',
|
|
customUserVars: {
|
|
THINGY_TOKEN: { title: 'Thingy Access Token', description: 'Create this in Thingy' },
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockUpdateMCPServerTools.mockResolvedValue({});
|
|
});
|
|
|
|
it('does not connect and exposes no tools when a required customUserVar is unset', async () => {
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig,
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(mockGetConnection).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({
|
|
availableTools: null,
|
|
success: false,
|
|
tools: null,
|
|
failureReason: 'missing_custom_user_vars',
|
|
missingUserVars: ['THINGY_TOKEN'],
|
|
oauthRequired: false,
|
|
serverName,
|
|
});
|
|
expect(result.message).toContain('THINGY_TOKEN');
|
|
});
|
|
|
|
it('does not connect when the stored value for a required customUserVar is empty', async () => {
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig,
|
|
userMCPAuthMap: { [`${Constants.mcp_prefix}${serverName}`]: { THINGY_TOKEN: '' } },
|
|
});
|
|
|
|
expect(mockGetConnection).not.toHaveBeenCalled();
|
|
expect(result.success).toBe(false);
|
|
expect(result.availableTools).toBeNull();
|
|
});
|
|
|
|
it('proceeds to connect once every required customUserVar is provided', async () => {
|
|
mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) });
|
|
|
|
await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig,
|
|
userMCPAuthMap: {
|
|
[`${Constants.mcp_prefix}${serverName}`]: { THINGY_TOKEN: 'secret-token' },
|
|
},
|
|
});
|
|
|
|
expect(mockGetConnection).toHaveBeenCalledTimes(1);
|
|
expect(mockGetConnection).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverName,
|
|
customUserVars: { THINGY_TOKEN: 'secret-token' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('updates the cache with an empty catalog after a successful connection', async () => {
|
|
mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) });
|
|
|
|
await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(mockUpdateMCPServerTools).toHaveBeenCalledWith({
|
|
userId: user.id,
|
|
serverName,
|
|
tools: [],
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
publicationGeneration: 'generation-current',
|
|
});
|
|
});
|
|
|
|
/** An app-level catalog write is dropped unless it carries the ordering reserved before its
|
|
* own tools/list. When this path forwarded no revision, every publication was discarded and
|
|
* agents were told the server had no tools at all (#14857). */
|
|
it('publishes under the ordering its snapshot was fetched with', async () => {
|
|
mockGetConnection.mockResolvedValue({
|
|
fetchOrderedToolsSnapshot: jest.fn().mockResolvedValue({
|
|
tools: [{ name: 'search', inputSchema: { type: 'object' } }],
|
|
complete: true,
|
|
publicationRevision: '7',
|
|
}),
|
|
});
|
|
|
|
await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
});
|
|
|
|
expect(mockUpdateMCPServerTools).toHaveBeenCalledWith(
|
|
expect.objectContaining({ serverName, publicationRevision: '7' }),
|
|
);
|
|
});
|
|
|
|
it('asks the connection to republish a catalog it could not order', async () => {
|
|
const refreshToolList = jest.fn().mockResolvedValue(undefined);
|
|
mockGetConnection.mockResolvedValue({
|
|
refreshToolList,
|
|
fetchOrderedToolsSnapshot: jest.fn().mockResolvedValue({
|
|
tools: [{ name: 'search', inputSchema: { type: 'object' } }],
|
|
complete: true,
|
|
orderingUnavailable: true,
|
|
}),
|
|
});
|
|
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
});
|
|
|
|
expect(refreshToolList).toHaveBeenCalledTimes(1);
|
|
expect(result.tools).toHaveLength(1);
|
|
});
|
|
|
|
it('preserves cached tools when live recovery returns an incomplete snapshot', async () => {
|
|
const fetchOrderedToolsSnapshot = jest.fn().mockResolvedValue({
|
|
tools: [{ name: 'partial', inputSchema: { type: 'object' } }],
|
|
complete: false,
|
|
});
|
|
mockGetConnection.mockResolvedValue({
|
|
fetchOrderedToolsSnapshot,
|
|
});
|
|
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
});
|
|
|
|
expect(result.tools).toBeNull();
|
|
expect(fetchOrderedToolsSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(mockUpdateMCPServerTools).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('discards a snapshot when another replica rotates its generation during discovery', async () => {
|
|
mockGetMCPToolsCacheGeneration
|
|
.mockResolvedValueOnce('generation-current')
|
|
.mockResolvedValueOnce('generation-replaced');
|
|
mockGetConnection.mockResolvedValue({
|
|
fetchOrderedToolsSnapshot: jest.fn().mockResolvedValue({
|
|
tools: [{ name: 'stale', inputSchema: { type: 'object' } }],
|
|
complete: true,
|
|
}),
|
|
});
|
|
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
});
|
|
|
|
expect(result.tools).toBeNull();
|
|
expect(result.availableTools).toBeNull();
|
|
expect(mockUpdateMCPServerTools).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not return tools when the guarded publication loses its generation race', async () => {
|
|
mockGetConnection.mockResolvedValue({
|
|
fetchOrderedToolsSnapshot: jest.fn().mockResolvedValue({
|
|
tools: [{ name: 'stale', inputSchema: { type: 'object' } }],
|
|
complete: true,
|
|
}),
|
|
});
|
|
mockUpdateMCPServerTools.mockResolvedValue(null);
|
|
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
});
|
|
|
|
expect(result.tools).toBeNull();
|
|
expect(result.availableTools).toBeNull();
|
|
});
|
|
|
|
it('passes request body and Graph resolver into connection creation', async () => {
|
|
mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) });
|
|
const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' };
|
|
|
|
await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
requestBody,
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(mockGetConnection).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
requestBody,
|
|
graphTokenResolver: mockGetGraphApiToken,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('forwards the pre-built upstreamTokenProvider closure into connection creation', async () => {
|
|
mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) });
|
|
const upstreamTokenProvider = jest.fn().mockResolvedValue(null);
|
|
|
|
await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
upstreamTokenProvider,
|
|
});
|
|
|
|
expect(mockGetConnection).toHaveBeenCalledWith(
|
|
expect.objectContaining({ upstreamTokenProvider }),
|
|
);
|
|
});
|
|
|
|
it('passes request body and Graph resolver into OAuth discovery fallback', async () => {
|
|
mockGetConnection.mockRejectedValue(new Error('OAuth authentication required'));
|
|
mockDiscoverServerTools.mockResolvedValue({ tools: [], oauthRequired: true, oauthUrl: null });
|
|
const requestBody = { conversationId: 'conv-456', messageId: 'msg-456' };
|
|
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
requestBody,
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
failureReason: 'oauth_required',
|
|
oauthRequired: true,
|
|
oauthUrl: null,
|
|
});
|
|
expect(mockDiscoverServerTools).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
requestBody,
|
|
graphTokenResolver: mockGetGraphApiToken,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('disposes ephemeral BODY-scoped connections after loading tools', async () => {
|
|
const dispose = jest.fn().mockResolvedValue(undefined);
|
|
const tools = [{ name: 'search', inputSchema: { type: 'object', properties: {} } }];
|
|
const serverConfig = {
|
|
type: 'streamable-http',
|
|
url: 'https://thingy.example.com/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp',
|
|
source: 'yaml',
|
|
};
|
|
mockGetConnection.mockResolvedValue({
|
|
dispose,
|
|
fetchTools: jest.fn().mockResolvedValue(tools),
|
|
});
|
|
|
|
await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig,
|
|
requestBody: { messageId: 'msg-789' },
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(dispose).toHaveBeenCalledTimes(1);
|
|
expect(mockUpdateMCPServerTools).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tools,
|
|
serverConfig,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('proceeds to connect when the server declares no customUserVars', async () => {
|
|
mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) });
|
|
|
|
await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(mockGetConnection).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('reinitMCPServer — runtime BODY placeholder pre-check (issue #14074)', () => {
|
|
const user = { id: 'user-123' };
|
|
const serverName = 'Thingy';
|
|
const serverConfig = {
|
|
type: 'streamable-http',
|
|
url: 'https://thingy.example.com/mcp',
|
|
source: 'yaml',
|
|
headers: { 'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}' },
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockUpdateMCPServerTools.mockResolvedValue({});
|
|
});
|
|
|
|
it('defers connection without failing when body placeholders cannot resolve outside a chat turn', async () => {
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig,
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(mockGetConnection).not.toHaveBeenCalled();
|
|
expect(mockDiscoverServerTools).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({
|
|
availableTools: null,
|
|
success: true,
|
|
connectionDeferred: true,
|
|
tools: null,
|
|
oauthRequired: false,
|
|
serverName,
|
|
});
|
|
expect(result.message).toContain('first use in a chat turn');
|
|
});
|
|
|
|
it('treats an empty-string body field as missing', async () => {
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig,
|
|
requestBody: { conversationId: ' ' },
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(mockGetConnection).not.toHaveBeenCalled();
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('connects normally when the request body provides the placeholder fields', async () => {
|
|
mockGetConnection.mockResolvedValue({
|
|
dispose: jest.fn().mockResolvedValue(undefined),
|
|
fetchTools: jest.fn().mockResolvedValue([]),
|
|
});
|
|
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig,
|
|
requestBody: { conversationId: 'convo-1' },
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(mockGetConnection).toHaveBeenCalledTimes(1);
|
|
expect(result.connectionDeferred).toBeUndefined();
|
|
});
|
|
|
|
it('reports missing customUserVars before deferring on body placeholders', async () => {
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: {
|
|
...serverConfig,
|
|
customUserVars: { THINGY_TOKEN: { title: 'Thingy Access Token' } },
|
|
},
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.message).toContain('THINGY_TOKEN');
|
|
});
|
|
|
|
it('still treats unrelated connection errors as real failures', async () => {
|
|
mockGetConnection.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(mockDiscoverServerTools).not.toHaveBeenCalled();
|
|
expect(result.success).toBe(false);
|
|
expect(result.failureReason).toBe('initialization_failed');
|
|
expect(result.message).toBe(`Failed to reinitialize MCP server '${serverName}'`);
|
|
});
|
|
});
|
|
|
|
describe('reinitMCPServer — OAuth attempt lifetime', () => {
|
|
const user = { id: 'user-123' };
|
|
const serverName = 'Thingy';
|
|
const serverConfig = {
|
|
type: 'streamable-http',
|
|
url: 'https://thingy.example.com/mcp',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockUpdateMCPServerTools.mockResolvedValue({});
|
|
});
|
|
|
|
it('returns the expiry supplied when a pending OAuth URL is replayed', async () => {
|
|
const expiresAt = Date.now() + 45_000;
|
|
mockGetConnection.mockImplementation(async ({ oauthStart }) => {
|
|
await oauthStart('https://oauth.example.com/authorize', { expiresAt });
|
|
await oauthStart('https://oauth.example.com/authorize');
|
|
throw new Error('OAuth flow initiated - return early');
|
|
});
|
|
mockDiscoverServerTools.mockResolvedValue({ tools: [], oauthRequired: true, oauthUrl: null });
|
|
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
oauthRequired: true,
|
|
oauthUrl: 'https://oauth.example.com/authorize',
|
|
oauthExpiresAt: expiresAt,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('reinitMCPServer — log hygiene', () => {
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
it('keeps user-created server and connection details out of discovery logs', async () => {
|
|
const serverName = 'PRIVATE-MCP-SERVER-NAME';
|
|
const privateUrl = 'https://private.example.test/PRIVATE-CONFIG-PATH';
|
|
const privateError = `PRIVATE-CONNECTION-ERROR for ${privateUrl}`;
|
|
const logSpies = ['debug', 'info', 'warn', 'error'].map((level) =>
|
|
jest.spyOn(logger, level).mockImplementation(() => {}),
|
|
);
|
|
mockGetConnection.mockRejectedValue(new Error(privateError));
|
|
|
|
const result = await reinitMCPServer({
|
|
user: { id: 'user-123' },
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: privateUrl },
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
const loggedText = logSpies
|
|
.flatMap((spy) => spy.mock.calls)
|
|
.flat()
|
|
.map((value) => String(value))
|
|
.join('\n');
|
|
|
|
expect(result.message).toContain(serverName);
|
|
expect(loggedText).not.toContain(serverName);
|
|
expect(loggedText).not.toContain(privateUrl);
|
|
expect(loggedText).not.toContain(privateError);
|
|
expect(logger.error).toHaveBeenCalledWith('[MCP Reinitialize] Error initializing MCP server');
|
|
});
|
|
});
|