mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-26 17:31:27 +00:00
* fix: Resolve MCP Runtime User Placeholders * fix: Harden MCP Runtime Placeholder Connections * fix: Update MCP Source Tag Test Expectations * fix: Complete MCP Runtime Placeholder Reinit * fix: Harden MCP Request Scoped Runtime Configs * fix: Align MCP OAuth Tests With Domain Policy * fix: Harden MCP Runtime Resolution Edges * fix: Avoid MCP Runtime Reprocessing Pitfalls * fix: Reuse MCP Request Scoped Tool Discovery * fix: Validate MCP Body Runtime Fields * 🛡️ refactor: Harden runtime placeholder edges from review - Warn at inspection when a trusted server URL contains runtime placeholders but no domain allowlist restricts the resolved target - Document the three resolution sites that must stay in sync so the validated config always matches the connected one - Note the per-call connect cost of ephemeral GRAPH/BODY connections - Drop the no-op removeUserConnection in callTool's ephemeral cleanup; ephemeral connections are never stored, and removing the entry could orphan a still-connected cached connection after a config change * 🪪 fix: Cover oauth_headers, Graph URL gating, and request-scoped reconnects Address Codex review: - Resolve runtime placeholders in oauth_headers (processMCPEnv + Graph pre-pass) and include the field in placeholder detection, so OAuth discovery/token requests no longer send literals; consolidate the detection field lists into one helper - Defer the early domain gate when the URL still carries a Graph placeholder (resolved async later); the authoritative assertResolvedRuntimeConfigAllowed check still enforces policy - Bypass the 10s reconnect throttle for request-scoped servers, which re-fetch tool definitions on every message by design
185 lines
5.6 KiB
JavaScript
185 lines
5.6 KiB
JavaScript
const { Constants } = require('librechat-data-provider');
|
|
|
|
const mockGetConnection = jest.fn();
|
|
const mockDiscoverServerTools = jest.fn();
|
|
const mockGetGraphApiToken = jest.fn();
|
|
const mockUpdateMCPServerTools = jest.fn();
|
|
|
|
jest.mock('~/config', () => ({
|
|
getMCPManager: jest.fn(() => ({
|
|
getConnection: mockGetConnection,
|
|
discoverServerTools: mockDiscoverServerTools,
|
|
})),
|
|
getMCPServersRegistry: jest.fn(() => ({ getServerConfig: jest.fn() })),
|
|
getFlowStateManager: jest.fn(() => ({})),
|
|
}));
|
|
jest.mock('~/models', () => ({
|
|
findToken: jest.fn(),
|
|
createToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
}));
|
|
jest.mock('~/server/services/Config', () => ({
|
|
updateMCPServerTools: mockUpdateMCPServerTools,
|
|
}));
|
|
jest.mock('~/server/services/GraphTokenService', () => ({
|
|
getGraphApiToken: mockGetGraphApiToken,
|
|
}));
|
|
jest.mock('~/cache', () => ({
|
|
getLogStores: jest.fn(() => ({})),
|
|
}));
|
|
|
|
const { reinitMCPServer } = require('./mcp');
|
|
|
|
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,
|
|
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('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('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' };
|
|
|
|
await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
|
|
requestBody,
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(mockDiscoverServerTools).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
requestBody,
|
|
graphTokenResolver: mockGetGraphApiToken,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('disconnects ephemeral BODY-scoped connections after loading tools', async () => {
|
|
const disconnect = jest.fn().mockResolvedValue(undefined);
|
|
const tools = [{ name: 'search', inputSchema: { type: 'object', properties: {} } }];
|
|
mockGetConnection.mockResolvedValue({
|
|
disconnect,
|
|
fetchTools: jest.fn().mockResolvedValue(tools),
|
|
});
|
|
|
|
await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
serverConfig: {
|
|
type: 'streamable-http',
|
|
url: 'https://thingy.example.com/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp',
|
|
source: 'yaml',
|
|
},
|
|
requestBody: { messageId: 'msg-789' },
|
|
userMCPAuthMap: undefined,
|
|
});
|
|
|
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
|
expect(mockUpdateMCPServerTools).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tools,
|
|
skipCache: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|