📡 refactor: MCP Runtime Config Sync with Redis Distributed Locking (#10352)

* 🔄 Refactoring: MCP Runtime Configuration Reload
 - PrivateServerConfigs own cache classes (inMemory and Redis).
 - Connections staleness detection by comparing (connection.createdAt and config.LastUpdatedAt)
 - ConnectionsRepo access Registry instead of in memory config dict and renew stale connections
 - MCPManager: adjusted init of ConnectionsRepo (app level)
 - UserConnectionManager: renew stale connections
 - skipped test, to test "should only clear keys in its own namespace"
 - MCPPrivateServerLoader: new component to manage logic of loading / editing private servers on runtime
 - PrivateServersLoadStatusCache to track private server cache status
 - New unit and integration tests.
Misc:
 - add es lint rule to enforce line between class methods

* Fix cluster mode batch update and delete workarround. Fixed unit tests for cluster mode.

* Fix Keyv redis clear cache namespace  awareness issue + Integration tests fixes

* chore: address copilot comments

* Fixing rebase issue: removed the mcp config fallback in single getServerConfig method:
- to not to interfere with the logic of the right Tier (APP/USER/Private)
- If userId is null, the getServerConfig should not return configs that are a SharedUser tier and not APP tier

* chore: add dev-staging branch to workflow triggers for backend, cache integration, and ESLint checks

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
This commit is contained in:
Atef Bellaaj 2025-11-26 15:11:36 +01:00 committed by Danny Avila
parent 52e6796635
commit ac68e629e6
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
49 changed files with 5244 additions and 257 deletions

View file

@ -6,6 +6,9 @@ import { MCPConnection } from '~/mcp/connection';
import { registryStatusCache } from '~/mcp/registry/cache/RegistryStatusCache';
import { MCPServerInspector } from '~/mcp/registry/MCPServerInspector';
import { mcpServersRegistry as registry } from '~/mcp/registry/MCPServersRegistry';
const FIXED_TIME = 1699564800000;
const originalDateNow = Date.now;
Date.now = jest.fn(() => FIXED_TIME);
// Mock external dependencies
jest.mock('../../MCPConnectionFactory');
@ -31,6 +34,10 @@ const mockInspect = MCPServerInspector.inspect as jest.MockedFunction<
describe('MCPServersInitializer', () => {
let mockConnection: jest.Mocked<MCPConnection>;
afterAll(() => {
Date.now = originalDateNow;
});
const testConfigs: t.MCPServers = {
disabled_server: {
type: 'stdio',
@ -40,7 +47,7 @@ describe('MCPServersInitializer', () => {
},
oauth_server: {
type: 'streamable-http',
url: 'https://api.example.com/mcp',
url: 'https://api.example.com/mcp-oauth',
},
file_tools_server: {
type: 'stdio',
@ -52,6 +59,10 @@ describe('MCPServersInitializer', () => {
command: 'node',
args: ['instructions.js'],
},
remote_no_oauth_server: {
type: 'streamable-http',
url: 'https://api.example.com/mcp-no-auth',
},
};
const testParsedConfigs: Record<string, t.ParsedServerConfig> = {
@ -64,7 +75,7 @@ describe('MCPServersInitializer', () => {
},
oauth_server: {
type: 'streamable-http',
url: 'https://api.example.com/mcp',
url: 'https://api.example.com/mcp-oauth',
requiresOAuth: true,
},
file_tools_server: {
@ -105,6 +116,21 @@ describe('MCPServersInitializer', () => {
},
},
},
remote_no_oauth_server: {
type: 'streamable-http',
url: 'https://api.example.com/mcp-no-auth',
requiresOAuth: false,
},
};
// Helper to determine requiresOAuth based on URL pattern
// URLs ending with '-oauth' require OAuth, others don't
const determineRequiresOAuth = (config: t.MCPOptions): boolean => {
if ('url' in config && config.url) {
// If URL ends with '-oauth', requires OAuth
return config.url.endsWith('-oauth');
}
return false;
};
beforeEach(async () => {
@ -117,9 +143,14 @@ describe('MCPServersInitializer', () => {
(MCPConnectionFactory.create as jest.Mock).mockResolvedValue(mockConnection);
// Mock MCPServerInspector.inspect to return parsed config
mockInspect.mockImplementation(async (serverName: string) => {
// This mock inspects the actual rawConfig to determine requiresOAuth dynamically
mockInspect.mockImplementation(async (serverName: string, rawConfig: t.MCPOptions) => {
const baseConfig = testParsedConfigs[serverName] || {};
return {
...testParsedConfigs[serverName],
...baseConfig,
...rawConfig,
// Override requiresOAuth based on the actual config being inspected
requiresOAuth: determineRequiresOAuth(rawConfig),
_processedByInspector: true,
} as unknown as t.ParsedServerConfig;
});
@ -170,7 +201,7 @@ describe('MCPServersInitializer', () => {
await MCPServersInitializer.initialize(testConfigs);
// Verify all configs were processed by inspector (without connection parameter)
expect(mockInspect).toHaveBeenCalledTimes(4);
expect(mockInspect).toHaveBeenCalledTimes(5);
expect(mockInspect).toHaveBeenCalledWith('disabled_server', testConfigs.disabled_server);
expect(mockInspect).toHaveBeenCalledWith('oauth_server', testConfigs.oauth_server);
expect(mockInspect).toHaveBeenCalledWith('file_tools_server', testConfigs.file_tools_server);
@ -178,6 +209,10 @@ describe('MCPServersInitializer', () => {
'search_tools_server',
testConfigs.search_tools_server,
);
expect(mockInspect).toHaveBeenCalledWith(
'remote_no_oauth_server',
testConfigs.remote_no_oauth_server,
);
});
it('should add disabled servers to sharedUserServers', async () => {
@ -232,12 +267,15 @@ describe('MCPServersInitializer', () => {
it('should handle inspection failures gracefully', async () => {
// Mock inspection failure for one server
mockInspect.mockImplementation(async (serverName: string) => {
mockInspect.mockImplementation(async (serverName: string, rawConfig: t.MCPOptions) => {
if (serverName === 'file_tools_server') {
throw new Error('Inspection failed');
}
const baseConfig = testParsedConfigs[serverName] || {};
return {
...testParsedConfigs[serverName],
...rawConfig,
...baseConfig,
requiresOAuth: determineRequiresOAuth(rawConfig),
_processedByInspector: true,
} as unknown as t.ParsedServerConfig;
});