@wundr.io/mcp-registry 1.0.3
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 +890 -0
- package/dist/aggregator.d.ts +330 -0
- package/dist/aggregator.d.ts.map +1 -0
- package/dist/aggregator.js +708 -0
- package/dist/aggregator.js.map +1 -0
- package/dist/discovery.d.ts +274 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +536 -0
- package/dist/discovery.js.map +1 -0
- package/dist/health-monitor.d.ts +304 -0
- package/dist/health-monitor.d.ts.map +1 -0
- package/dist/health-monitor.js +626 -0
- package/dist/health-monitor.js.map +1 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +185 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +323 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +647 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +663 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +120 -0
- package/dist/types.js.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @wundr.io/mcp-registry - MCP Aggregator (Super MCP)
|
|
4
|
+
*
|
|
5
|
+
* Implements the Super MCP pattern for routing requests to appropriate
|
|
6
|
+
* servers based on capabilities, health, and configured routing strategies.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.MCPAggregator = exports.RetryExhaustedError = exports.CircuitBreakerOpenError = exports.ToolInvocationTimeoutError = exports.NoServerAvailableError = void 0;
|
|
12
|
+
exports.createMCPAggregator = createMCPAggregator;
|
|
13
|
+
const eventemitter3_1 = require("eventemitter3");
|
|
14
|
+
const discovery_1 = require("./discovery");
|
|
15
|
+
const types_1 = require("./types");
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Aggregator Error Types
|
|
18
|
+
// =============================================================================
|
|
19
|
+
/**
|
|
20
|
+
* Error thrown when no server can handle a tool invocation
|
|
21
|
+
*/
|
|
22
|
+
class NoServerAvailableError extends Error {
|
|
23
|
+
toolName;
|
|
24
|
+
constructor(toolName, message) {
|
|
25
|
+
super(message ?? `No server available for tool: ${toolName}`);
|
|
26
|
+
this.toolName = toolName;
|
|
27
|
+
this.name = 'NoServerAvailableError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.NoServerAvailableError = NoServerAvailableError;
|
|
31
|
+
/**
|
|
32
|
+
* Error thrown when a tool invocation times out
|
|
33
|
+
*/
|
|
34
|
+
class ToolInvocationTimeoutError extends Error {
|
|
35
|
+
toolName;
|
|
36
|
+
timeoutMs;
|
|
37
|
+
constructor(toolName, timeoutMs, message) {
|
|
38
|
+
super(message ?? `Tool invocation timed out after ${timeoutMs}ms: ${toolName}`);
|
|
39
|
+
this.toolName = toolName;
|
|
40
|
+
this.timeoutMs = timeoutMs;
|
|
41
|
+
this.name = 'ToolInvocationTimeoutError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.ToolInvocationTimeoutError = ToolInvocationTimeoutError;
|
|
45
|
+
/**
|
|
46
|
+
* Error thrown when circuit breaker is open for a server
|
|
47
|
+
*/
|
|
48
|
+
class CircuitBreakerOpenError extends Error {
|
|
49
|
+
serverId;
|
|
50
|
+
constructor(serverId, message) {
|
|
51
|
+
super(message ?? `Circuit breaker open for server: ${serverId}`);
|
|
52
|
+
this.serverId = serverId;
|
|
53
|
+
this.name = 'CircuitBreakerOpenError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exports.CircuitBreakerOpenError = CircuitBreakerOpenError;
|
|
57
|
+
/**
|
|
58
|
+
* Error thrown when all retry attempts are exhausted
|
|
59
|
+
*/
|
|
60
|
+
class RetryExhaustedError extends Error {
|
|
61
|
+
toolName;
|
|
62
|
+
attempts;
|
|
63
|
+
lastError;
|
|
64
|
+
constructor(toolName, attempts, lastError, message) {
|
|
65
|
+
super(message ??
|
|
66
|
+
`All ${attempts} retry attempts exhausted for tool: ${toolName}`);
|
|
67
|
+
this.toolName = toolName;
|
|
68
|
+
this.attempts = attempts;
|
|
69
|
+
this.lastError = lastError;
|
|
70
|
+
this.name = 'RetryExhaustedError';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
exports.RetryExhaustedError = RetryExhaustedError;
|
|
74
|
+
// =============================================================================
|
|
75
|
+
// MCPAggregator Class
|
|
76
|
+
// =============================================================================
|
|
77
|
+
/**
|
|
78
|
+
* MCP Aggregator (Super MCP Pattern)
|
|
79
|
+
*
|
|
80
|
+
* Routes tool invocation requests to appropriate servers based on
|
|
81
|
+
* capabilities, health status, and configured routing strategies.
|
|
82
|
+
* Implements circuit breaker pattern for fault tolerance.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```typescript
|
|
86
|
+
* const aggregator = new MCPAggregator(registry, {
|
|
87
|
+
* defaultStrategy: 'health-aware',
|
|
88
|
+
* enableRetries: true,
|
|
89
|
+
* maxRetries: 3,
|
|
90
|
+
* enableCircuitBreaker: true,
|
|
91
|
+
* });
|
|
92
|
+
*
|
|
93
|
+
* // Invoke a tool
|
|
94
|
+
* const response = await aggregator.invoke({
|
|
95
|
+
* name: 'drift_detection',
|
|
96
|
+
* arguments: { action: 'detect' },
|
|
97
|
+
* });
|
|
98
|
+
*
|
|
99
|
+
* // Use specific routing strategy
|
|
100
|
+
* const response = await aggregator.invokeWithStrategy(
|
|
101
|
+
* { name: 'my-tool' },
|
|
102
|
+
* 'least-latency',
|
|
103
|
+
* );
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
class MCPAggregator extends eventemitter3_1.EventEmitter {
|
|
107
|
+
registry;
|
|
108
|
+
/** Configuration */
|
|
109
|
+
config;
|
|
110
|
+
/** Discovery service */
|
|
111
|
+
discovery;
|
|
112
|
+
/** Circuit breaker states by server ID */
|
|
113
|
+
circuitBreakers;
|
|
114
|
+
/** Round-robin index by tool name */
|
|
115
|
+
roundRobinIndex;
|
|
116
|
+
/** Request counter for generating IDs */
|
|
117
|
+
requestCounter;
|
|
118
|
+
/** Tool handler registry for direct invocation */
|
|
119
|
+
toolHandlers;
|
|
120
|
+
/**
|
|
121
|
+
* Creates a new MCPAggregator
|
|
122
|
+
*
|
|
123
|
+
* @param registry - The server registry
|
|
124
|
+
* @param config - Aggregator configuration
|
|
125
|
+
*/
|
|
126
|
+
constructor(registry, config = {}) {
|
|
127
|
+
super();
|
|
128
|
+
this.registry = registry;
|
|
129
|
+
// Validate and merge config with defaults
|
|
130
|
+
const validation = types_1.AggregatorConfigSchema.safeParse(config);
|
|
131
|
+
if (!validation.success) {
|
|
132
|
+
throw new Error(`Invalid aggregator config: ${validation.error.message}`);
|
|
133
|
+
}
|
|
134
|
+
this.config = {
|
|
135
|
+
defaultStrategy: config.defaultStrategy ?? 'health-aware',
|
|
136
|
+
requestTimeout: config.requestTimeout ?? 30000,
|
|
137
|
+
enableRetries: config.enableRetries ?? true,
|
|
138
|
+
maxRetries: config.maxRetries ?? 3,
|
|
139
|
+
retryDelay: config.retryDelay ?? 1000,
|
|
140
|
+
enableCircuitBreaker: config.enableCircuitBreaker ?? true,
|
|
141
|
+
circuitBreakerThreshold: config.circuitBreakerThreshold ?? 5,
|
|
142
|
+
circuitBreakerResetTimeout: config.circuitBreakerResetTimeout ?? 60000,
|
|
143
|
+
healthMonitor: config.healthMonitor ?? {},
|
|
144
|
+
};
|
|
145
|
+
this.discovery = (0, discovery_1.createServerDiscoveryService)(registry);
|
|
146
|
+
this.circuitBreakers = new Map();
|
|
147
|
+
this.roundRobinIndex = new Map();
|
|
148
|
+
this.requestCounter = 0;
|
|
149
|
+
this.toolHandlers = new Map();
|
|
150
|
+
}
|
|
151
|
+
// ===========================================================================
|
|
152
|
+
// Tool Invocation Methods
|
|
153
|
+
// ===========================================================================
|
|
154
|
+
/**
|
|
155
|
+
* Invoke a tool using the default routing strategy
|
|
156
|
+
*
|
|
157
|
+
* @param request - Tool invocation request
|
|
158
|
+
* @returns Tool invocation response
|
|
159
|
+
* @throws {NoServerAvailableError} If no server can handle the tool
|
|
160
|
+
* @throws {ToolInvocationTimeoutError} If the invocation times out
|
|
161
|
+
* @throws {RetryExhaustedError} If all retries are exhausted
|
|
162
|
+
*/
|
|
163
|
+
async invoke(request) {
|
|
164
|
+
return this.invokeWithStrategy(request, this.config.defaultStrategy);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Invoke a tool with a specific routing strategy
|
|
168
|
+
*
|
|
169
|
+
* @param request - Tool invocation request
|
|
170
|
+
* @param strategy - Routing strategy to use
|
|
171
|
+
* @returns Tool invocation response
|
|
172
|
+
*/
|
|
173
|
+
async invokeWithStrategy(request, strategy) {
|
|
174
|
+
const requestId = this.generateRequestId();
|
|
175
|
+
const startTime = Date.now();
|
|
176
|
+
let retryAttempts = 0;
|
|
177
|
+
let lastError;
|
|
178
|
+
// Emit request started event
|
|
179
|
+
this.emitRequestEvent('request:started', requestId, request.name);
|
|
180
|
+
while (retryAttempts <= (this.config.enableRetries ? this.config.maxRetries : 0)) {
|
|
181
|
+
try {
|
|
182
|
+
// Select server based on strategy
|
|
183
|
+
const server = await this.selectServer(request.name, strategy, request.preferredServer);
|
|
184
|
+
if (!server) {
|
|
185
|
+
throw new NoServerAvailableError(request.name);
|
|
186
|
+
}
|
|
187
|
+
// Check circuit breaker
|
|
188
|
+
if (this.config.enableCircuitBreaker && this.isCircuitOpen(server.id)) {
|
|
189
|
+
// Try next server
|
|
190
|
+
throw new CircuitBreakerOpenError(server.id);
|
|
191
|
+
}
|
|
192
|
+
// Execute the tool
|
|
193
|
+
const result = await this.executeWithTimeout(request, server, request.timeout ?? this.config.requestTimeout);
|
|
194
|
+
const durationMs = Date.now() - startTime;
|
|
195
|
+
// Record success
|
|
196
|
+
this.recordSuccess(server.id);
|
|
197
|
+
// Emit completion event
|
|
198
|
+
this.emitRequestEvent('request:completed', requestId, request.name, server.id, durationMs);
|
|
199
|
+
return {
|
|
200
|
+
result,
|
|
201
|
+
serverId: server.id,
|
|
202
|
+
latencyMs: durationMs,
|
|
203
|
+
retried: retryAttempts > 0,
|
|
204
|
+
retryAttempts,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
209
|
+
// Record failure for circuit breaker
|
|
210
|
+
const failedServerId = this.getLastAttemptedServerId();
|
|
211
|
+
if (failedServerId) {
|
|
212
|
+
this.recordFailure(failedServerId);
|
|
213
|
+
}
|
|
214
|
+
// Check if we should retry
|
|
215
|
+
if (this.config.enableRetries &&
|
|
216
|
+
retryAttempts < this.config.maxRetries) {
|
|
217
|
+
retryAttempts++;
|
|
218
|
+
// Emit retry event
|
|
219
|
+
this.emitRequestEvent('request:retried', requestId, request.name, undefined, undefined, lastError, retryAttempts);
|
|
220
|
+
// Wait before retry
|
|
221
|
+
await this.delay(this.config.retryDelay * retryAttempts);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// All retries exhausted
|
|
229
|
+
const durationMs = Date.now() - startTime;
|
|
230
|
+
this.emitRequestEvent('request:failed', requestId, request.name, undefined, durationMs, lastError);
|
|
231
|
+
if (retryAttempts > 0) {
|
|
232
|
+
throw new RetryExhaustedError(request.name, retryAttempts, lastError ?? new Error('Unknown error'));
|
|
233
|
+
}
|
|
234
|
+
throw lastError ?? new NoServerAvailableError(request.name);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Invoke multiple tools in parallel
|
|
238
|
+
*
|
|
239
|
+
* @param requests - Array of tool invocation requests
|
|
240
|
+
* @returns Array of tool invocation responses
|
|
241
|
+
*/
|
|
242
|
+
async invokeParallel(requests) {
|
|
243
|
+
return Promise.all(requests.map(req => this.invoke(req)));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Invoke tools in sequence
|
|
247
|
+
*
|
|
248
|
+
* @param requests - Array of tool invocation requests
|
|
249
|
+
* @returns Array of tool invocation responses
|
|
250
|
+
*/
|
|
251
|
+
async invokeSequential(requests) {
|
|
252
|
+
const results = [];
|
|
253
|
+
for (const request of requests) {
|
|
254
|
+
const response = await this.invoke(request);
|
|
255
|
+
results.push(response);
|
|
256
|
+
}
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
// ===========================================================================
|
|
260
|
+
// Tool Handler Registration
|
|
261
|
+
// ===========================================================================
|
|
262
|
+
/**
|
|
263
|
+
* Register a direct tool handler (for local execution)
|
|
264
|
+
*
|
|
265
|
+
* @param toolName - Tool name
|
|
266
|
+
* @param handler - Tool handler function
|
|
267
|
+
*/
|
|
268
|
+
registerToolHandler(toolName, handler) {
|
|
269
|
+
this.toolHandlers.set(toolName, handler);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Unregister a tool handler
|
|
273
|
+
*
|
|
274
|
+
* @param toolName - Tool name
|
|
275
|
+
*/
|
|
276
|
+
unregisterToolHandler(toolName) {
|
|
277
|
+
this.toolHandlers.delete(toolName);
|
|
278
|
+
}
|
|
279
|
+
// ===========================================================================
|
|
280
|
+
// Server Selection Methods
|
|
281
|
+
// ===========================================================================
|
|
282
|
+
/**
|
|
283
|
+
* Select a server for a tool using the specified strategy
|
|
284
|
+
*
|
|
285
|
+
* @param toolName - Tool name
|
|
286
|
+
* @param strategy - Routing strategy
|
|
287
|
+
* @param preferredServer - Optional preferred server ID
|
|
288
|
+
* @returns Selected server or undefined
|
|
289
|
+
*/
|
|
290
|
+
async selectServer(toolName, strategy, preferredServer) {
|
|
291
|
+
// Check preferred server first
|
|
292
|
+
if (preferredServer) {
|
|
293
|
+
const server = this.registry.get(preferredServer);
|
|
294
|
+
if (server && this.serverProvidesTool(server, toolName)) {
|
|
295
|
+
const health = this.registry.getHealthStatus(server.id);
|
|
296
|
+
if (health?.status !== 'unhealthy' && !this.isCircuitOpen(server.id)) {
|
|
297
|
+
return server;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// Get all servers that provide the tool
|
|
302
|
+
const candidates = this.registry.findByTool(toolName);
|
|
303
|
+
if (candidates.length === 0) {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
// Filter out unhealthy servers and open circuits
|
|
307
|
+
const availableServers = candidates.filter(server => {
|
|
308
|
+
const health = this.registry.getHealthStatus(server.id);
|
|
309
|
+
const isHealthy = health?.status !== 'unhealthy';
|
|
310
|
+
const circuitClosed = !this.isCircuitOpen(server.id);
|
|
311
|
+
return isHealthy && circuitClosed;
|
|
312
|
+
});
|
|
313
|
+
if (availableServers.length === 0) {
|
|
314
|
+
// Fall back to any server if all are unhealthy
|
|
315
|
+
return candidates[0];
|
|
316
|
+
}
|
|
317
|
+
// Apply routing strategy
|
|
318
|
+
switch (strategy) {
|
|
319
|
+
case 'priority':
|
|
320
|
+
return this.selectByPriority(availableServers);
|
|
321
|
+
case 'round-robin':
|
|
322
|
+
return this.selectRoundRobin(toolName, availableServers);
|
|
323
|
+
case 'least-latency':
|
|
324
|
+
return this.selectByLeastLatency(availableServers);
|
|
325
|
+
case 'random':
|
|
326
|
+
return this.selectRandom(availableServers);
|
|
327
|
+
case 'health-aware':
|
|
328
|
+
return this.selectHealthAware(availableServers);
|
|
329
|
+
default:
|
|
330
|
+
return availableServers[0];
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Select server by highest priority
|
|
335
|
+
*/
|
|
336
|
+
selectByPriority(servers) {
|
|
337
|
+
return [...servers].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))[0];
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Select server using round-robin
|
|
341
|
+
*/
|
|
342
|
+
selectRoundRobin(toolName, servers) {
|
|
343
|
+
const currentIndex = this.roundRobinIndex.get(toolName) ?? 0;
|
|
344
|
+
const server = servers[currentIndex % servers.length];
|
|
345
|
+
this.roundRobinIndex.set(toolName, (currentIndex + 1) % servers.length);
|
|
346
|
+
return server;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Select server with lowest latency
|
|
350
|
+
*/
|
|
351
|
+
selectByLeastLatency(servers) {
|
|
352
|
+
return [...servers].sort((a, b) => {
|
|
353
|
+
const aHealth = this.registry.getHealthStatus(a.id);
|
|
354
|
+
const bHealth = this.registry.getHealthStatus(b.id);
|
|
355
|
+
const aLatency = aHealth?.avgLatencyMs ?? Number.MAX_VALUE;
|
|
356
|
+
const bLatency = bHealth?.avgLatencyMs ?? Number.MAX_VALUE;
|
|
357
|
+
return aLatency - bLatency;
|
|
358
|
+
})[0];
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Select random server
|
|
362
|
+
*/
|
|
363
|
+
selectRandom(servers) {
|
|
364
|
+
const index = Math.floor(Math.random() * servers.length);
|
|
365
|
+
return servers[index];
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Select server using health-aware strategy
|
|
369
|
+
*/
|
|
370
|
+
selectHealthAware(servers) {
|
|
371
|
+
// Score each server based on health metrics
|
|
372
|
+
const scored = servers.map(server => {
|
|
373
|
+
const health = this.registry.getHealthStatus(server.id);
|
|
374
|
+
let score = 0;
|
|
375
|
+
// Health status score
|
|
376
|
+
switch (health?.status) {
|
|
377
|
+
case 'healthy':
|
|
378
|
+
score += 100;
|
|
379
|
+
break;
|
|
380
|
+
case 'degraded':
|
|
381
|
+
score += 50;
|
|
382
|
+
break;
|
|
383
|
+
case 'unhealthy':
|
|
384
|
+
score += 0;
|
|
385
|
+
break;
|
|
386
|
+
default:
|
|
387
|
+
score += 25;
|
|
388
|
+
}
|
|
389
|
+
// Latency score (lower is better)
|
|
390
|
+
if (health?.avgLatencyMs !== undefined) {
|
|
391
|
+
score += Math.max(0, 50 - health.avgLatencyMs / 20);
|
|
392
|
+
}
|
|
393
|
+
// Error rate penalty
|
|
394
|
+
if (health?.errorRate !== undefined) {
|
|
395
|
+
score -= health.errorRate * 100;
|
|
396
|
+
}
|
|
397
|
+
// Priority bonus
|
|
398
|
+
score += (server.priority ?? 0) * 5;
|
|
399
|
+
return { server, score };
|
|
400
|
+
});
|
|
401
|
+
// Sort by score descending and return best
|
|
402
|
+
scored.sort((a, b) => b.score - a.score);
|
|
403
|
+
return scored[0].server;
|
|
404
|
+
}
|
|
405
|
+
// ===========================================================================
|
|
406
|
+
// Circuit Breaker Methods
|
|
407
|
+
// ===========================================================================
|
|
408
|
+
/**
|
|
409
|
+
* Check if circuit is open for a server
|
|
410
|
+
*
|
|
411
|
+
* @param serverId - Server ID
|
|
412
|
+
* @returns True if circuit is open
|
|
413
|
+
*/
|
|
414
|
+
isCircuitOpen(serverId) {
|
|
415
|
+
const state = this.circuitBreakers.get(serverId);
|
|
416
|
+
if (!state) {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
if (state.state === 'open') {
|
|
420
|
+
// Check if reset timeout has passed
|
|
421
|
+
if (state.openedAt) {
|
|
422
|
+
const elapsed = Date.now() - state.openedAt.getTime();
|
|
423
|
+
if (elapsed >= this.config.circuitBreakerResetTimeout) {
|
|
424
|
+
// Transition to half-open
|
|
425
|
+
this.transitionCircuit(serverId, 'half-open');
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Get circuit breaker status for a server
|
|
435
|
+
*
|
|
436
|
+
* @param serverId - Server ID
|
|
437
|
+
* @returns Circuit breaker status
|
|
438
|
+
*/
|
|
439
|
+
getCircuitStatus(serverId) {
|
|
440
|
+
const state = this.circuitBreakers.get(serverId);
|
|
441
|
+
if (!state) {
|
|
442
|
+
return {
|
|
443
|
+
serverId,
|
|
444
|
+
state: 'closed',
|
|
445
|
+
failureCount: 0,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
let timeUntilClose;
|
|
449
|
+
if (state.state === 'open' && state.openedAt) {
|
|
450
|
+
const elapsed = Date.now() - state.openedAt.getTime();
|
|
451
|
+
timeUntilClose = Math.max(0, this.config.circuitBreakerResetTimeout - elapsed);
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
serverId,
|
|
455
|
+
state: state.state,
|
|
456
|
+
failureCount: state.failureCount,
|
|
457
|
+
lastFailure: state.lastFailure,
|
|
458
|
+
timeUntilClose,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Get all circuit breaker statuses
|
|
463
|
+
*
|
|
464
|
+
* @returns Array of circuit breaker statuses
|
|
465
|
+
*/
|
|
466
|
+
getAllCircuitStatuses() {
|
|
467
|
+
const statuses = [];
|
|
468
|
+
for (const serverId of this.registry.getAll().map(s => s.id)) {
|
|
469
|
+
statuses.push(this.getCircuitStatus(serverId));
|
|
470
|
+
}
|
|
471
|
+
return statuses;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Manually reset a circuit breaker
|
|
475
|
+
*
|
|
476
|
+
* @param serverId - Server ID
|
|
477
|
+
*/
|
|
478
|
+
resetCircuit(serverId) {
|
|
479
|
+
this.circuitBreakers.delete(serverId);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Manually open a circuit breaker
|
|
483
|
+
*
|
|
484
|
+
* @param serverId - Server ID
|
|
485
|
+
*/
|
|
486
|
+
openCircuit(serverId) {
|
|
487
|
+
this.transitionCircuit(serverId, 'open');
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Record a successful request
|
|
491
|
+
*/
|
|
492
|
+
recordSuccess(serverId) {
|
|
493
|
+
const state = this.getOrCreateCircuitState(serverId);
|
|
494
|
+
state.successCount++;
|
|
495
|
+
state.lastSuccess = new Date();
|
|
496
|
+
if (state.state === 'half-open') {
|
|
497
|
+
// Success in half-open state closes the circuit
|
|
498
|
+
this.transitionCircuit(serverId, 'closed');
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Record a failed request
|
|
503
|
+
*/
|
|
504
|
+
recordFailure(serverId) {
|
|
505
|
+
const state = this.getOrCreateCircuitState(serverId);
|
|
506
|
+
state.failureCount++;
|
|
507
|
+
state.lastFailure = new Date();
|
|
508
|
+
if (state.state === 'half-open') {
|
|
509
|
+
// Failure in half-open state re-opens the circuit
|
|
510
|
+
this.transitionCircuit(serverId, 'open');
|
|
511
|
+
}
|
|
512
|
+
else if (state.failureCount >= this.config.circuitBreakerThreshold) {
|
|
513
|
+
// Threshold exceeded, open the circuit
|
|
514
|
+
this.transitionCircuit(serverId, 'open');
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Transition circuit to a new state
|
|
519
|
+
*/
|
|
520
|
+
transitionCircuit(serverId, newState) {
|
|
521
|
+
const state = this.getOrCreateCircuitState(serverId);
|
|
522
|
+
const previousState = state.state;
|
|
523
|
+
state.state = newState;
|
|
524
|
+
if (newState === 'open') {
|
|
525
|
+
state.openedAt = new Date();
|
|
526
|
+
}
|
|
527
|
+
else if (newState === 'closed') {
|
|
528
|
+
state.failureCount = 0;
|
|
529
|
+
state.openedAt = undefined;
|
|
530
|
+
}
|
|
531
|
+
// Emit circuit event
|
|
532
|
+
const event = {
|
|
533
|
+
serverId,
|
|
534
|
+
previousState,
|
|
535
|
+
newState,
|
|
536
|
+
timestamp: new Date(),
|
|
537
|
+
};
|
|
538
|
+
switch (newState) {
|
|
539
|
+
case 'open':
|
|
540
|
+
this.emit('circuit:opened', event);
|
|
541
|
+
break;
|
|
542
|
+
case 'closed':
|
|
543
|
+
this.emit('circuit:closed', event);
|
|
544
|
+
break;
|
|
545
|
+
case 'half-open':
|
|
546
|
+
this.emit('circuit:half-open', event);
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Get or create circuit breaker state
|
|
552
|
+
*/
|
|
553
|
+
getOrCreateCircuitState(serverId) {
|
|
554
|
+
let state = this.circuitBreakers.get(serverId);
|
|
555
|
+
if (!state) {
|
|
556
|
+
state = {
|
|
557
|
+
state: 'closed',
|
|
558
|
+
failureCount: 0,
|
|
559
|
+
successCount: 0,
|
|
560
|
+
};
|
|
561
|
+
this.circuitBreakers.set(serverId, state);
|
|
562
|
+
}
|
|
563
|
+
return state;
|
|
564
|
+
}
|
|
565
|
+
// ===========================================================================
|
|
566
|
+
// Private Helper Methods
|
|
567
|
+
// ===========================================================================
|
|
568
|
+
/** Track last attempted server for failure recording */
|
|
569
|
+
lastAttemptedServerId;
|
|
570
|
+
/**
|
|
571
|
+
* Execute tool with timeout
|
|
572
|
+
*/
|
|
573
|
+
async executeWithTimeout(request, server, timeoutMs) {
|
|
574
|
+
this.lastAttemptedServerId = server.id;
|
|
575
|
+
// Check for local handler first
|
|
576
|
+
const handler = this.toolHandlers.get(request.name);
|
|
577
|
+
if (handler) {
|
|
578
|
+
return this.executeWithTimeoutInternal(() => handler(request.arguments ?? {}), timeoutMs, request.name);
|
|
579
|
+
}
|
|
580
|
+
// Default implementation returns a placeholder result
|
|
581
|
+
// In real implementation, this would use transport to call the server
|
|
582
|
+
return this.executeWithTimeoutInternal(async () => this.createPlaceholderResult(request, server), timeoutMs, request.name);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Execute a function with timeout
|
|
586
|
+
*/
|
|
587
|
+
async executeWithTimeoutInternal(fn, timeoutMs, toolName) {
|
|
588
|
+
return new Promise((resolve, reject) => {
|
|
589
|
+
const timer = setTimeout(() => {
|
|
590
|
+
reject(new ToolInvocationTimeoutError(toolName, timeoutMs));
|
|
591
|
+
}, timeoutMs);
|
|
592
|
+
fn()
|
|
593
|
+
.then(result => {
|
|
594
|
+
clearTimeout(timer);
|
|
595
|
+
resolve(result);
|
|
596
|
+
})
|
|
597
|
+
.catch(error => {
|
|
598
|
+
clearTimeout(timer);
|
|
599
|
+
reject(error);
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Create a placeholder result for demonstration
|
|
605
|
+
*/
|
|
606
|
+
createPlaceholderResult(request, server) {
|
|
607
|
+
return {
|
|
608
|
+
content: [
|
|
609
|
+
{
|
|
610
|
+
type: 'text',
|
|
611
|
+
text: `Tool "${request.name}" would be executed on server "${server.name}"`,
|
|
612
|
+
},
|
|
613
|
+
],
|
|
614
|
+
isError: false,
|
|
615
|
+
serverId: server.id,
|
|
616
|
+
toolName: request.name,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Get the last attempted server ID
|
|
621
|
+
*/
|
|
622
|
+
getLastAttemptedServerId() {
|
|
623
|
+
return this.lastAttemptedServerId;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Check if a server provides a tool
|
|
627
|
+
*/
|
|
628
|
+
serverProvidesTool(server, toolName) {
|
|
629
|
+
return server.tools.some(tool => tool.name === toolName);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Generate a unique request ID
|
|
633
|
+
*/
|
|
634
|
+
generateRequestId() {
|
|
635
|
+
return `req-${++this.requestCounter}-${Date.now()}`;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Delay for specified milliseconds
|
|
639
|
+
*/
|
|
640
|
+
delay(ms) {
|
|
641
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Emit a request event
|
|
645
|
+
*/
|
|
646
|
+
emitRequestEvent(type, requestId, toolName, serverId, durationMs, error, retryAttempt) {
|
|
647
|
+
const event = {
|
|
648
|
+
requestId,
|
|
649
|
+
toolName,
|
|
650
|
+
serverId,
|
|
651
|
+
durationMs,
|
|
652
|
+
error,
|
|
653
|
+
retryAttempt,
|
|
654
|
+
timestamp: new Date(),
|
|
655
|
+
};
|
|
656
|
+
this.emit(type, event);
|
|
657
|
+
}
|
|
658
|
+
// ===========================================================================
|
|
659
|
+
// Utility Methods
|
|
660
|
+
// ===========================================================================
|
|
661
|
+
/**
|
|
662
|
+
* Get aggregator statistics
|
|
663
|
+
*
|
|
664
|
+
* @returns Aggregator statistics
|
|
665
|
+
*/
|
|
666
|
+
getStats() {
|
|
667
|
+
const circuitStatuses = this.getAllCircuitStatuses();
|
|
668
|
+
return {
|
|
669
|
+
openCircuits: circuitStatuses.filter(s => s.state === 'open').length,
|
|
670
|
+
halfOpenCircuits: circuitStatuses.filter(s => s.state === 'half-open')
|
|
671
|
+
.length,
|
|
672
|
+
closedCircuits: circuitStatuses.filter(s => s.state === 'closed').length,
|
|
673
|
+
totalRequests: this.requestCounter,
|
|
674
|
+
registeredHandlers: this.toolHandlers.size,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Get the current configuration
|
|
679
|
+
*
|
|
680
|
+
* @returns Current aggregator configuration
|
|
681
|
+
*/
|
|
682
|
+
getConfig() {
|
|
683
|
+
return { ...this.config };
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
exports.MCPAggregator = MCPAggregator;
|
|
687
|
+
// =============================================================================
|
|
688
|
+
// Factory Function
|
|
689
|
+
// =============================================================================
|
|
690
|
+
/**
|
|
691
|
+
* Create a new MCPAggregator
|
|
692
|
+
*
|
|
693
|
+
* @param registry - The server registry
|
|
694
|
+
* @param config - Optional aggregator configuration
|
|
695
|
+
* @returns New aggregator instance
|
|
696
|
+
*
|
|
697
|
+
* @example
|
|
698
|
+
* ```typescript
|
|
699
|
+
* const aggregator = createMCPAggregator(registry, {
|
|
700
|
+
* defaultStrategy: 'health-aware',
|
|
701
|
+
* enableRetries: true,
|
|
702
|
+
* });
|
|
703
|
+
* ```
|
|
704
|
+
*/
|
|
705
|
+
function createMCPAggregator(registry, config) {
|
|
706
|
+
return new MCPAggregator(registry, config);
|
|
707
|
+
}
|
|
708
|
+
//# sourceMappingURL=aggregator.js.map
|