@1mcp/agent 0.2.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -11
- package/build/capabilities/capabilityManager.js +17 -9
- package/build/clients/clientManager.js +64 -10
- package/build/clients/clientManager.test.js +172 -0
- package/build/config/configManager.js +1 -1
- package/build/config/configManager.test.js +92 -0
- package/build/config/transportConfig.js +17 -38
- package/build/constants.js +2 -1
- package/build/handlers/notificationHandlers.js +10 -10
- package/build/handlers/requestHandlers.js +106 -63
- package/build/index.js +30 -2
- package/build/logger/logger.js +12 -25
- package/build/logger/mcpTransport.js +1 -1
- package/build/server/expressServer.js +21 -6
- package/build/serverManager.js +21 -14
- package/build/serverManager.test.js +158 -0
- package/build/services/configReloadService.js +10 -8
- package/build/test-utils/fixtures/commonFixtures.js +44 -0
- package/build/test-utils/helpers/testHelpers.js +23 -0
- package/build/test-utils/mocks/commonMocks.js +65 -0
- package/build/types.js +31 -0
- package/build/utils/clientFiltering.js +101 -0
- package/build/utils/clientFiltering.test.js +124 -0
- package/build/utils/errorHandling.js +66 -43
- package/build/utils/errorTypes.js +47 -0
- package/package.json +41 -4
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { ServerManager } from './serverManager.js';
|
|
3
|
+
import logger from './logger/logger.js';
|
|
4
|
+
import configReloadService from './services/configReloadService.js';
|
|
5
|
+
import { setupCapabilities } from './capabilities/capabilityManager.js';
|
|
6
|
+
import { enhanceServerWithLogging } from './middleware/loggingMiddleware.js';
|
|
7
|
+
// Mock dependencies
|
|
8
|
+
jest.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
|
|
9
|
+
Server: jest.fn(),
|
|
10
|
+
}));
|
|
11
|
+
jest.mock('@modelcontextprotocol/sdk/shared/transport.js', () => ({
|
|
12
|
+
Transport: jest.fn(),
|
|
13
|
+
}));
|
|
14
|
+
jest.mock('./logger/logger.js', () => {
|
|
15
|
+
const mockLogger = {
|
|
16
|
+
info: jest.fn(),
|
|
17
|
+
error: jest.fn(),
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
__esModule: true,
|
|
21
|
+
default: mockLogger,
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
jest.mock('./services/configReloadService.js', () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
default: {
|
|
27
|
+
initialize: jest.fn(),
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
jest.mock('./capabilities/capabilityManager.js', () => ({
|
|
31
|
+
setupCapabilities: jest.fn(),
|
|
32
|
+
}));
|
|
33
|
+
jest.mock('./middleware/loggingMiddleware.js', () => ({
|
|
34
|
+
enhanceServerWithLogging: jest.fn(),
|
|
35
|
+
}));
|
|
36
|
+
describe('ServerManager', () => {
|
|
37
|
+
let mockConfig;
|
|
38
|
+
let mockCapabilities;
|
|
39
|
+
let mockClients;
|
|
40
|
+
let mockTransports;
|
|
41
|
+
let mockTransport;
|
|
42
|
+
let mockServer;
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
// Reset all mocks
|
|
45
|
+
jest.clearAllMocks();
|
|
46
|
+
// Setup test data
|
|
47
|
+
mockConfig = { name: 'test-server', version: '1.0.0' };
|
|
48
|
+
mockCapabilities = { capabilities: { test: true } };
|
|
49
|
+
mockClients = {};
|
|
50
|
+
mockTransports = {};
|
|
51
|
+
mockTransport = {
|
|
52
|
+
// Add any required Transport properties here
|
|
53
|
+
};
|
|
54
|
+
mockServer = {
|
|
55
|
+
connect: jest.fn().mockResolvedValue(undefined),
|
|
56
|
+
transport: mockTransport,
|
|
57
|
+
};
|
|
58
|
+
// Setup mocks
|
|
59
|
+
Server.mockImplementation(() => mockServer);
|
|
60
|
+
setupCapabilities.mockResolvedValue(undefined);
|
|
61
|
+
enhanceServerWithLogging.mockReturnValue(undefined);
|
|
62
|
+
configReloadService.initialize.mockImplementation(() => undefined);
|
|
63
|
+
});
|
|
64
|
+
describe('getInstance', () => {
|
|
65
|
+
it('should create a singleton instance', () => {
|
|
66
|
+
const instance1 = ServerManager.getInstance(mockConfig, mockCapabilities, mockClients, mockTransports);
|
|
67
|
+
const instance2 = ServerManager.getInstance(mockConfig, mockCapabilities, mockClients, mockTransports);
|
|
68
|
+
expect(instance1).toBe(instance2);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('connectTransport', () => {
|
|
72
|
+
let serverManager;
|
|
73
|
+
const sessionId = 'test-session';
|
|
74
|
+
const tags = ['tag1', 'tag2'];
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
serverManager = ServerManager.getInstance(mockConfig, mockCapabilities, mockClients, mockTransports);
|
|
77
|
+
});
|
|
78
|
+
it('should successfully connect a transport', async () => {
|
|
79
|
+
await serverManager.connectTransport(mockTransport, sessionId, tags);
|
|
80
|
+
expect(Server).toHaveBeenCalledWith(mockConfig, mockCapabilities);
|
|
81
|
+
expect(enhanceServerWithLogging).toHaveBeenCalledWith(mockServer);
|
|
82
|
+
expect(setupCapabilities).toHaveBeenCalled();
|
|
83
|
+
expect(configReloadService.initialize).toHaveBeenCalled();
|
|
84
|
+
expect(mockServer.connect).toHaveBeenCalledWith(mockTransport);
|
|
85
|
+
expect(logger.info).toHaveBeenCalledWith(`Connected transport for session ${sessionId}`);
|
|
86
|
+
});
|
|
87
|
+
it('should handle connection errors', async () => {
|
|
88
|
+
const error = new Error('Connection failed');
|
|
89
|
+
mockServer.connect.mockRejectedValueOnce(error);
|
|
90
|
+
await expect(serverManager.connectTransport(mockTransport, sessionId, tags)).rejects.toThrow('Connection failed');
|
|
91
|
+
expect(logger.error).toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('disconnectTransport', () => {
|
|
95
|
+
let serverManager;
|
|
96
|
+
const sessionId = 'test-session';
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
serverManager = ServerManager.getInstance(mockConfig, mockCapabilities, mockClients, mockTransports);
|
|
99
|
+
});
|
|
100
|
+
it('should successfully disconnect a transport', async () => {
|
|
101
|
+
await serverManager.connectTransport(mockTransport, sessionId);
|
|
102
|
+
jest.clearAllMocks(); // Clear the logs from connectTransport
|
|
103
|
+
serverManager.disconnectTransport(sessionId);
|
|
104
|
+
expect(logger.info).toHaveBeenCalledWith(`Disconnected transport for session ${sessionId}`);
|
|
105
|
+
});
|
|
106
|
+
it('should handle non-existent session gracefully', () => {
|
|
107
|
+
serverManager.disconnectTransport('non-existent');
|
|
108
|
+
expect(logger.info).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('transport management methods', () => {
|
|
112
|
+
let serverManager;
|
|
113
|
+
const sessionId = 'test-session';
|
|
114
|
+
beforeEach(async () => {
|
|
115
|
+
serverManager = ServerManager.getInstance(mockConfig, mockCapabilities, mockClients, mockTransports);
|
|
116
|
+
await serverManager.connectTransport(mockTransport, sessionId);
|
|
117
|
+
});
|
|
118
|
+
it('should get transport by session id', () => {
|
|
119
|
+
const transport = serverManager.getTransport(sessionId);
|
|
120
|
+
expect(transport).toBe(mockTransport);
|
|
121
|
+
});
|
|
122
|
+
it('should return undefined for non-existent session', () => {
|
|
123
|
+
const transport = serverManager.getTransport('non-existent');
|
|
124
|
+
expect(transport).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
it('should get all transports', () => {
|
|
127
|
+
const transports = serverManager.getTransports();
|
|
128
|
+
expect(transports.size).toBe(1);
|
|
129
|
+
expect(transports.get(sessionId)).toBe(mockTransport);
|
|
130
|
+
});
|
|
131
|
+
it('should get client transports', () => {
|
|
132
|
+
const clientTransports = serverManager.getClientTransports();
|
|
133
|
+
expect(clientTransports).toEqual(mockTransports);
|
|
134
|
+
});
|
|
135
|
+
it('should get active transports count', () => {
|
|
136
|
+
expect(serverManager.getActiveTransportsCount()).toBe(1);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe('getServer', () => {
|
|
140
|
+
let serverManager;
|
|
141
|
+
const sessionId = 'test-session';
|
|
142
|
+
const tags = ['tag1', 'tag2'];
|
|
143
|
+
beforeEach(async () => {
|
|
144
|
+
serverManager = ServerManager.getInstance(mockConfig, mockCapabilities, mockClients, mockTransports);
|
|
145
|
+
await serverManager.connectTransport(mockTransport, sessionId, tags);
|
|
146
|
+
});
|
|
147
|
+
it('should return server info for existing session', () => {
|
|
148
|
+
const serverInfo = serverManager.getServer(sessionId);
|
|
149
|
+
expect(serverInfo).toBeDefined();
|
|
150
|
+
expect(serverInfo?.server).toBe(mockServer);
|
|
151
|
+
expect(serverInfo?.tags).toEqual(tags);
|
|
152
|
+
});
|
|
153
|
+
it('should return undefined for non-existent session', () => {
|
|
154
|
+
const serverInfo = serverManager.getServer('non-existent');
|
|
155
|
+
expect(serverInfo).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import logger from '../logger/logger.js';
|
|
2
|
-
import
|
|
3
|
-
import { createTransports } from '../config/transportConfig.js';
|
|
2
|
+
import { ConfigChangeEvent, ConfigManager } from '../config/configManager.js';
|
|
4
3
|
import { createClients } from '../clients/clientManager.js';
|
|
5
4
|
import { setupCapabilities } from '../capabilities/capabilityManager.js';
|
|
5
|
+
import { createTransports } from '../config/transportConfig.js';
|
|
6
6
|
/**
|
|
7
7
|
* Service to handle dynamic configuration reloading
|
|
8
8
|
*/
|
|
9
9
|
export class ConfigReloadService {
|
|
10
10
|
static instance;
|
|
11
|
-
|
|
11
|
+
serverInfo = null;
|
|
12
12
|
currentTransports = {};
|
|
13
13
|
isReloading = false;
|
|
14
14
|
/**
|
|
@@ -27,12 +27,13 @@ export class ConfigReloadService {
|
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
29
|
* Initialize the service with the server instance
|
|
30
|
-
* @param
|
|
30
|
+
* @param serverInfo The MCP server instance
|
|
31
31
|
* @param initialTransports The initial transports
|
|
32
32
|
*/
|
|
33
|
-
initialize(
|
|
34
|
-
this.
|
|
33
|
+
initialize(serverInfo, initialTransports) {
|
|
34
|
+
this.serverInfo = serverInfo;
|
|
35
35
|
this.currentTransports = initialTransports;
|
|
36
|
+
const configManager = ConfigManager.getInstance();
|
|
36
37
|
// Set up configuration change listener
|
|
37
38
|
configManager.on(ConfigChangeEvent.TRANSPORT_CONFIG_CHANGED, this.handleConfigChange.bind(this));
|
|
38
39
|
// Start watching for configuration changes
|
|
@@ -44,7 +45,7 @@ export class ConfigReloadService {
|
|
|
44
45
|
* @param newConfig The new transport configuration
|
|
45
46
|
*/
|
|
46
47
|
async handleConfigChange(newConfig) {
|
|
47
|
-
if (!this.
|
|
48
|
+
if (!this.serverInfo || this.isReloading) {
|
|
48
49
|
return;
|
|
49
50
|
}
|
|
50
51
|
this.isReloading = true;
|
|
@@ -67,7 +68,7 @@ export class ConfigReloadService {
|
|
|
67
68
|
// Create clients for the new transports
|
|
68
69
|
const newClients = await createClients(newTransports);
|
|
69
70
|
// Register new capabilities with the server
|
|
70
|
-
await setupCapabilities(newClients, this.
|
|
71
|
+
await setupCapabilities(newClients, this.serverInfo);
|
|
71
72
|
// Update current transports
|
|
72
73
|
this.currentTransports = newTransports;
|
|
73
74
|
logger.info('Configuration reload completed successfully');
|
|
@@ -83,6 +84,7 @@ export class ConfigReloadService {
|
|
|
83
84
|
* Stop the service and clean up resources
|
|
84
85
|
*/
|
|
85
86
|
stop() {
|
|
87
|
+
const configManager = ConfigManager.getInstance();
|
|
86
88
|
configManager.stopWatching();
|
|
87
89
|
configManager.removeAllListeners(ConfigChangeEvent.TRANSPORT_CONFIG_CHANGED);
|
|
88
90
|
logger.info('Config reload service stopped');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createMockTransport } from '../mocks/commonMocks.js';
|
|
2
|
+
// Common test clients fixture
|
|
3
|
+
export const createTestClients = () => ({
|
|
4
|
+
client1: {
|
|
5
|
+
capabilities: {
|
|
6
|
+
resources: {},
|
|
7
|
+
tools: {},
|
|
8
|
+
},
|
|
9
|
+
transport: createMockTransport(),
|
|
10
|
+
},
|
|
11
|
+
client2: {
|
|
12
|
+
capabilities: {
|
|
13
|
+
resources: {},
|
|
14
|
+
},
|
|
15
|
+
transport: createMockTransport(),
|
|
16
|
+
},
|
|
17
|
+
client3: {
|
|
18
|
+
capabilities: {
|
|
19
|
+
tools: {},
|
|
20
|
+
},
|
|
21
|
+
transport: createMockTransport(),
|
|
22
|
+
},
|
|
23
|
+
clientNoCapabilities: {
|
|
24
|
+
transport: createMockTransport(),
|
|
25
|
+
},
|
|
26
|
+
clientNoTags: {
|
|
27
|
+
capabilities: {
|
|
28
|
+
resources: {},
|
|
29
|
+
},
|
|
30
|
+
transport: createMockTransport(),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
// Common test config fixture
|
|
34
|
+
export const testConfig = {
|
|
35
|
+
mcpServers: {
|
|
36
|
+
test: { url: 'test-url' },
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
// Common test capabilities fixture
|
|
40
|
+
export const testCapabilities = {
|
|
41
|
+
resources: {},
|
|
42
|
+
tools: {},
|
|
43
|
+
test: true,
|
|
44
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Helper to clear all mocks in a test
|
|
2
|
+
export const clearAllMocks = () => {
|
|
3
|
+
jest.clearAllMocks();
|
|
4
|
+
jest.useRealTimers();
|
|
5
|
+
};
|
|
6
|
+
// Helper to setup fake timers
|
|
7
|
+
export const setupFakeTimers = () => {
|
|
8
|
+
jest.useFakeTimers();
|
|
9
|
+
};
|
|
10
|
+
// Helper to advance timers by time
|
|
11
|
+
export const advanceTimersByTime = async (ms) => {
|
|
12
|
+
await jest.advanceTimersByTimeAsync(ms);
|
|
13
|
+
};
|
|
14
|
+
// Helper to run all timers
|
|
15
|
+
export const runAllTimers = async () => {
|
|
16
|
+
await jest.runAllTimersAsync();
|
|
17
|
+
};
|
|
18
|
+
// Helper to create a mock event emitter spy
|
|
19
|
+
export const createEventEmitterSpy = (instance) => {
|
|
20
|
+
return jest.spyOn(instance, 'emit');
|
|
21
|
+
};
|
|
22
|
+
// Helper to wait for next tick
|
|
23
|
+
export const waitForNextTick = () => new Promise((resolve) => process.nextTick(resolve));
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Mock Logger
|
|
2
|
+
export const mockLogger = {
|
|
3
|
+
info: jest.fn(),
|
|
4
|
+
error: jest.fn(),
|
|
5
|
+
warn: jest.fn(),
|
|
6
|
+
debug: jest.fn(),
|
|
7
|
+
level: 'info',
|
|
8
|
+
transports: [],
|
|
9
|
+
};
|
|
10
|
+
// Mock Transport
|
|
11
|
+
export const createMockTransport = () => ({
|
|
12
|
+
start: jest.fn(),
|
|
13
|
+
send: jest.fn(),
|
|
14
|
+
close: jest.fn(),
|
|
15
|
+
});
|
|
16
|
+
// Mock Server
|
|
17
|
+
export const createMockServer = (overrides = {}) => ({
|
|
18
|
+
connect: jest.fn().mockResolvedValue(undefined),
|
|
19
|
+
transport: createMockTransport(),
|
|
20
|
+
...overrides,
|
|
21
|
+
});
|
|
22
|
+
// Mock Client
|
|
23
|
+
export const createMockClient = (overrides = {}) => ({
|
|
24
|
+
connect: jest.fn(),
|
|
25
|
+
getServerVersion: jest.fn(),
|
|
26
|
+
...overrides,
|
|
27
|
+
});
|
|
28
|
+
// Mock File System
|
|
29
|
+
export const createMockFs = () => ({
|
|
30
|
+
existsSync: jest.fn(),
|
|
31
|
+
mkdirSync: jest.fn(),
|
|
32
|
+
writeFileSync: jest.fn(),
|
|
33
|
+
readFileSync: jest.fn(),
|
|
34
|
+
watch: jest.fn(() => ({ close: jest.fn() })),
|
|
35
|
+
});
|
|
36
|
+
// Mock Config
|
|
37
|
+
export const createMockConfig = (overrides = {}) => ({
|
|
38
|
+
name: 'test-server',
|
|
39
|
+
version: '1.0.0',
|
|
40
|
+
...overrides,
|
|
41
|
+
});
|
|
42
|
+
// Mock Capabilities
|
|
43
|
+
export const createMockCapabilities = (overrides = {}) => ({
|
|
44
|
+
capabilities: {
|
|
45
|
+
resources: {},
|
|
46
|
+
tools: {},
|
|
47
|
+
...overrides,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
// Jest module mocks setup helper
|
|
51
|
+
export const setupCommonJestMocks = () => {
|
|
52
|
+
jest.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
|
|
53
|
+
Server: jest.fn(),
|
|
54
|
+
}));
|
|
55
|
+
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
|
56
|
+
Client: jest.fn(),
|
|
57
|
+
}));
|
|
58
|
+
jest.mock('@modelcontextprotocol/sdk/shared/transport.js', () => ({
|
|
59
|
+
Transport: jest.fn(),
|
|
60
|
+
}));
|
|
61
|
+
jest.mock('../../logger/logger.js', () => ({
|
|
62
|
+
__esModule: true,
|
|
63
|
+
default: mockLogger,
|
|
64
|
+
}));
|
|
65
|
+
};
|
package/build/types.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Zod schema for transport configuration
|
|
4
|
+
*/
|
|
5
|
+
export const transportConfigSchema = z.object({
|
|
6
|
+
type: z.enum(['stdio', 'sse', 'http']).optional(),
|
|
7
|
+
disabled: z.boolean().optional(),
|
|
8
|
+
timeout: z.number().optional(),
|
|
9
|
+
tags: z.array(z.string()).optional(),
|
|
10
|
+
// HTTP/SSE Parameters
|
|
11
|
+
url: z.string().url().optional(),
|
|
12
|
+
headers: z.record(z.string()).optional(),
|
|
13
|
+
// StdioServerParameters fields
|
|
14
|
+
command: z.string().optional(),
|
|
15
|
+
args: z.array(z.string()).optional(),
|
|
16
|
+
stderr: z.union([z.string(), z.number()]).optional(),
|
|
17
|
+
cwd: z.string().optional(),
|
|
18
|
+
env: z.record(z.string()).optional(),
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Enum representing possible client connection states
|
|
22
|
+
*/
|
|
23
|
+
export var ClientStatus;
|
|
24
|
+
(function (ClientStatus) {
|
|
25
|
+
/** Client is successfully connected */
|
|
26
|
+
ClientStatus["Connected"] = "connected";
|
|
27
|
+
/** Client is disconnected */
|
|
28
|
+
ClientStatus["Disconnected"] = "disconnected";
|
|
29
|
+
/** Client encountered an error */
|
|
30
|
+
ClientStatus["Error"] = "error";
|
|
31
|
+
})(ClientStatus || (ClientStatus = {}));
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import logger from '../logger/logger.js';
|
|
2
|
+
/**
|
|
3
|
+
* Filters clients by tags
|
|
4
|
+
* @param clients Record of client instances
|
|
5
|
+
* @param tags Array of tags to filter by
|
|
6
|
+
* @returns Filtered record of client instances
|
|
7
|
+
*/
|
|
8
|
+
export function filterClientsByTags(clients, tags) {
|
|
9
|
+
if (!tags || tags.length === 0) {
|
|
10
|
+
return clients;
|
|
11
|
+
}
|
|
12
|
+
const filteredClients = {};
|
|
13
|
+
let matchedClients = 0;
|
|
14
|
+
for (const [name, clientInfo] of Object.entries(clients)) {
|
|
15
|
+
const clientTags = clientInfo.transport.tags || [];
|
|
16
|
+
const hasMatchingTags = tags.every((tag) => clientTags.includes(tag));
|
|
17
|
+
if (hasMatchingTags) {
|
|
18
|
+
filteredClients[name] = clientInfo;
|
|
19
|
+
matchedClients++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (matchedClients === 0) {
|
|
23
|
+
logger.warn(`No clients found matching tags: ${tags.join(', ')}`);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
logger.debug(`Found ${matchedClients} clients matching tags: ${tags.join(', ')}`);
|
|
27
|
+
}
|
|
28
|
+
return Object.freeze(filteredClients);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Filters clients by capabilities
|
|
32
|
+
* @param clients Record of client instances
|
|
33
|
+
* @param capabilities Object containing capabilities to filter by
|
|
34
|
+
* @returns Filtered record of client instances
|
|
35
|
+
*/
|
|
36
|
+
export function filterClientsByCapabilities(clients, capabilities) {
|
|
37
|
+
const filteredClients = {};
|
|
38
|
+
let matchedClients = 0;
|
|
39
|
+
for (const [name, clientInfo] of Object.entries(clients)) {
|
|
40
|
+
const clientCapabilities = clientInfo.capabilities || {};
|
|
41
|
+
const hasMatchingCapabilities = Object.keys(capabilities).every((capability) => {
|
|
42
|
+
const clientCapability = clientCapabilities[capability];
|
|
43
|
+
return clientCapability !== undefined;
|
|
44
|
+
});
|
|
45
|
+
if (hasMatchingCapabilities) {
|
|
46
|
+
filteredClients[name] = clientInfo;
|
|
47
|
+
matchedClients++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (matchedClients === 0) {
|
|
51
|
+
logger.warn(`No clients found matching capabilities: ${JSON.stringify(capabilities)}`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
logger.debug(`Found ${matchedClients} clients matching capabilities: ${JSON.stringify(capabilities)}`);
|
|
55
|
+
}
|
|
56
|
+
return Object.freeze(filteredClients);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Filters clients by multiple criteria
|
|
60
|
+
* @param filters Array of client filters
|
|
61
|
+
* @returns Filtered record of client instances
|
|
62
|
+
*/
|
|
63
|
+
export function filterClients(...filters) {
|
|
64
|
+
return (clients) => {
|
|
65
|
+
return filters.reduce((filteredClients, filter) => filter(filteredClients), clients);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Filters clients by capabilities
|
|
70
|
+
* @param requiredCapabilities Object containing capabilities to filter by
|
|
71
|
+
* @returns Filtered record of client instances
|
|
72
|
+
*/
|
|
73
|
+
export function byCapabilities(requiredCapabilities) {
|
|
74
|
+
return (clients) => {
|
|
75
|
+
return Object.entries(clients).reduce((filtered, [name, clientInfo]) => {
|
|
76
|
+
const hasCapabilities = Object.keys(requiredCapabilities).every((cap) => clientInfo.capabilities && cap in clientInfo.capabilities);
|
|
77
|
+
if (hasCapabilities) {
|
|
78
|
+
filtered[name] = clientInfo;
|
|
79
|
+
}
|
|
80
|
+
return filtered;
|
|
81
|
+
}, {});
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Filters clients by tags
|
|
86
|
+
* @param tags Array of tags to filter by
|
|
87
|
+
* @returns Filtered record of client instances
|
|
88
|
+
*/
|
|
89
|
+
export function byTags(tags) {
|
|
90
|
+
return (clients) => {
|
|
91
|
+
if (!tags || tags.length === 0)
|
|
92
|
+
return clients;
|
|
93
|
+
return Object.entries(clients).reduce((filtered, [name, clientInfo]) => {
|
|
94
|
+
const hasMatchingTags = tags.every((tag) => clientInfo.transport.tags?.includes(tag));
|
|
95
|
+
if (hasMatchingTags) {
|
|
96
|
+
filtered[name] = clientInfo;
|
|
97
|
+
}
|
|
98
|
+
return filtered;
|
|
99
|
+
}, {});
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { filterClients, byCapabilities, byTags, filterClientsByCapabilities, filterClientsByTags, } from './clientFiltering.js';
|
|
2
|
+
describe('Client Filtering Utils', () => {
|
|
3
|
+
const mockClients = {
|
|
4
|
+
client1: {
|
|
5
|
+
capabilities: {
|
|
6
|
+
resources: {},
|
|
7
|
+
tools: {},
|
|
8
|
+
},
|
|
9
|
+
transport: {
|
|
10
|
+
tags: ['tag1', 'tag2'],
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
client2: {
|
|
14
|
+
capabilities: {
|
|
15
|
+
resources: {},
|
|
16
|
+
},
|
|
17
|
+
transport: {
|
|
18
|
+
tags: ['tag1'],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
client3: {
|
|
22
|
+
capabilities: {
|
|
23
|
+
tools: {},
|
|
24
|
+
},
|
|
25
|
+
transport: {
|
|
26
|
+
tags: ['tag3'],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
clientNoCapabilities: {
|
|
30
|
+
transport: {
|
|
31
|
+
tags: ['tag1'],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
clientNoTags: {
|
|
35
|
+
capabilities: {
|
|
36
|
+
resources: {},
|
|
37
|
+
},
|
|
38
|
+
transport: {},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
describe('filterClientsByCapabilities', () => {
|
|
42
|
+
it('should filter clients by single capability', () => {
|
|
43
|
+
const filtered = filterClientsByCapabilities(mockClients, { resources: {} });
|
|
44
|
+
expect(Object.keys(filtered)).toEqual(['client1', 'client2', 'clientNoTags']);
|
|
45
|
+
});
|
|
46
|
+
it('should filter clients by multiple capabilities', () => {
|
|
47
|
+
const filtered = filterClientsByCapabilities(mockClients, { resources: {}, tools: {} });
|
|
48
|
+
expect(Object.keys(filtered)).toEqual(['client1']);
|
|
49
|
+
});
|
|
50
|
+
it('should handle clients with no capabilities', () => {
|
|
51
|
+
const filtered = filterClientsByCapabilities(mockClients, { resources: {} });
|
|
52
|
+
expect(filtered).not.toHaveProperty('clientNoCapabilities');
|
|
53
|
+
});
|
|
54
|
+
it('should return empty object when no matches found', () => {
|
|
55
|
+
const filtered = filterClientsByCapabilities(mockClients, { nonexistent: {} });
|
|
56
|
+
expect(Object.keys(filtered)).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('filterClientsByTags', () => {
|
|
60
|
+
it('should return all clients when no tags provided', () => {
|
|
61
|
+
const filtered = filterClientsByTags(mockClients, undefined);
|
|
62
|
+
expect(Object.keys(filtered)).toEqual(Object.keys(mockClients));
|
|
63
|
+
});
|
|
64
|
+
it('should filter clients by single tag', () => {
|
|
65
|
+
const filtered = filterClientsByTags(mockClients, ['tag1']);
|
|
66
|
+
expect(Object.keys(filtered)).toEqual(['client1', 'client2', 'clientNoCapabilities']);
|
|
67
|
+
});
|
|
68
|
+
it('should filter clients by multiple tags', () => {
|
|
69
|
+
const filtered = filterClientsByTags(mockClients, ['tag1', 'tag2']);
|
|
70
|
+
expect(Object.keys(filtered)).toEqual(['client1']);
|
|
71
|
+
});
|
|
72
|
+
it('should handle clients with no tags', () => {
|
|
73
|
+
const filtered = filterClientsByTags(mockClients, ['tag1']);
|
|
74
|
+
expect(filtered).not.toHaveProperty('clientNoTags');
|
|
75
|
+
});
|
|
76
|
+
it('should return empty object when no matches found', () => {
|
|
77
|
+
const filtered = filterClientsByTags(mockClients, ['nonexistent']);
|
|
78
|
+
expect(Object.keys(filtered)).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe('filterClients (composed filters)', () => {
|
|
82
|
+
it('should chain multiple filters together', () => {
|
|
83
|
+
const filtered = filterClients(byCapabilities({ resources: {} }), byTags(['tag1']))(mockClients);
|
|
84
|
+
expect(Object.keys(filtered)).toEqual(['client1', 'client2']);
|
|
85
|
+
});
|
|
86
|
+
it('should handle empty filters array', () => {
|
|
87
|
+
const filtered = filterClients()(mockClients);
|
|
88
|
+
expect(Object.keys(filtered)).toEqual(Object.keys(mockClients));
|
|
89
|
+
});
|
|
90
|
+
it('should handle no matching results', () => {
|
|
91
|
+
const filtered = filterClients(byCapabilities({ resources: {} }), byTags(['tag3']))(mockClients);
|
|
92
|
+
expect(Object.keys(filtered)).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('byCapabilities', () => {
|
|
96
|
+
it('should create a filter function for capabilities', () => {
|
|
97
|
+
const filter = byCapabilities({ resources: {} });
|
|
98
|
+
const filtered = filter(mockClients);
|
|
99
|
+
expect(Object.keys(filtered)).toEqual(['client1', 'client2', 'clientNoTags']);
|
|
100
|
+
});
|
|
101
|
+
it('should handle undefined capabilities safely', () => {
|
|
102
|
+
const filter = byCapabilities({ resources: {} });
|
|
103
|
+
const filtered = filter({ ...mockClients, unsafeClient: { transport: {} } });
|
|
104
|
+
expect(filtered).not.toHaveProperty('unsafeClient');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe('byTags', () => {
|
|
108
|
+
it('should create a filter function for tags', () => {
|
|
109
|
+
const filter = byTags(['tag1']);
|
|
110
|
+
const filtered = filter(mockClients);
|
|
111
|
+
expect(Object.keys(filtered)).toEqual(['client1', 'client2', 'clientNoCapabilities']);
|
|
112
|
+
});
|
|
113
|
+
it('should handle undefined tags array', () => {
|
|
114
|
+
const filter = byTags(undefined);
|
|
115
|
+
const filtered = filter(mockClients);
|
|
116
|
+
expect(Object.keys(filtered)).toEqual(Object.keys(mockClients));
|
|
117
|
+
});
|
|
118
|
+
it('should handle empty tags array', () => {
|
|
119
|
+
const filter = byTags([]);
|
|
120
|
+
const filtered = filter(mockClients);
|
|
121
|
+
expect(Object.keys(filtered)).toEqual(Object.keys(mockClients));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|