@aiwerk/mcp-bridge 1.8.0 → 2.0.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 CHANGED
@@ -139,6 +139,7 @@ Config: `~/.mcp-bridge/config.json` | Secrets: `~/.mcp-bridge/.env`
139
139
  "toolPrefix": true,
140
140
  "connectionTimeoutMs": 5000,
141
141
  "requestTimeoutMs": 60000,
142
+ "maxBatchSize": 10,
142
143
  "schemaCompression": {
143
144
  "enabled": true,
144
145
  "maxDescriptionLength": 80
@@ -170,6 +171,27 @@ mcp(server="todoist", action="schema", tool="find-tasks")
170
171
 
171
172
  Set `"enabled": false` to disable compression and return full descriptions.
172
173
 
174
+ ### Result Caching
175
+
176
+ Router mode can cache successful `action=call` tool results in memory using an LRU policy.
177
+
178
+ - Disabled by default (`resultCache.enabled: false`)
179
+ - No external dependencies (Map-based implementation)
180
+ - Defaults: `maxEntries: 100`, `defaultTtlMs: 300000` (5 minutes)
181
+ - Cache key: `server:tool:stableJson(params)`
182
+ - Per-tool TTL override via `resultCache.cacheTtl` (for example `"todoist:find-tasks": 60000`)
183
+ - `action=refresh` clears the result cache
184
+ - Error responses are never cached
185
+
186
+ ```json
187
+ "resultCache": {
188
+ "enabled": true,
189
+ "maxEntries": 100,
190
+ "defaultTtlMs": 300000,
191
+ "cacheTtl": { "todoist:find-tasks": 60000 }
192
+ }
193
+ ```
194
+
173
195
  ### Intent Routing
174
196
 
175
197
  Instead of specifying the exact server and tool, describe what you need:
@@ -200,6 +222,20 @@ The bridge uses vector embeddings to match your intent to the right server and t
200
222
  - `minScore`: minimum confidence to return a match (0-1, default: 0.3)
201
223
  - Index is built lazily on first `action=intent` call
202
224
 
225
+ ### Batch Calls
226
+
227
+ Run multiple tool calls in one round-trip with `action="batch"` (parallel execution):
228
+
229
+ ```json
230
+ {"action":"batch","calls":[{"server":"todoist","tool":"find-tasks","params":{"query":"today"}},{"server":"github","tool":"list_repos","params":{}}]}
231
+ ```
232
+
233
+ ```json
234
+ {"action":"batch","results":[{"server":"todoist","tool":"find-tasks","result":{"tasks":[]}}, {"server":"github","tool":"list_repos","error":{"error":"mcp_error","message":"..."}}]}
235
+ ```
236
+
237
+ Use `maxBatchSize` in config to cap requests (default: `10`). Failed calls return per-slot `error` while successful calls still return `result`.
238
+
203
239
  ### Security
204
240
 
205
241
  Three layers of protection for tool results:
@@ -304,6 +340,19 @@ Returns promoted tools (sorted by frequency) and full usage stats. All tracking
304
340
 
305
341
  **Router mode** — the agent calls `mcp(server="todoist", action="list")` to discover, then `mcp(server="todoist", tool="find-tasks", params={...})` to execute.
306
342
 
343
+ ### Multi-Server Tool Resolution
344
+
345
+ When `action="call"` is used without `server=`, mcp-bridge can resolve collisions automatically.
346
+
347
+ - Tool exists on exactly one server → direct dispatch.
348
+ - Tool exists on multiple servers + explicit `server=` → explicit target wins.
349
+ - Tool exists on multiple servers + no `server=` → score each candidate:
350
+ - **base_priority**: reverse config order (`last=1.0`, then `0.9`, `0.8`, floor `0.1`)
351
+ - **recency_boost**: `+0.3` if server used in last 5 successful calls
352
+ - **param_match**: up to `+0.2` based on parameter-name overlap with input schema
353
+ - If top score gap is `>= 0.15` → auto-dispatch to the winner.
354
+ - If top score gap is `< 0.15` → return normal `{ ambiguous: true, candidates: [...] }` response.
355
+
307
356
  **Direct mode** — tools are registered as `todoist_find_tasks`, `github_list_repos`, etc.
308
357
 
309
358
  ### Transports
@@ -160,6 +160,7 @@ export function initConfigDir(logger) {
160
160
  requestTimeoutMs: 60000,
161
161
  routerIdleTimeoutMs: 600000,
162
162
  routerMaxConcurrent: 5,
163
+ maxBatchSize: 10,
163
164
  http: {
164
165
  auth: {
165
166
  type: "bearer",
@@ -1,13 +1,17 @@
1
- export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, warnIfNonTlsRemoteUrl } from "./transport-base.js";
1
+ export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveServerHeaders, warnIfNonTlsRemoteUrl } from "./transport-base.js";
2
2
  export { StdioTransport } from "./transport-stdio.js";
3
3
  export { SseTransport } from "./transport-sse.js";
4
4
  export { StreamableHttpTransport } from "./transport-streamable-http.js";
5
5
  export { McpRouter } from "./mcp-router.js";
6
6
  export type { RouterToolHint, RouterServerStatus, RouterDispatchResponse, RouterTransportRefs } from "./mcp-router.js";
7
+ export { ResultCache, createResultCacheKey, stableStringify } from "./result-cache.js";
8
+ export type { ResultCacheConfig, ResultCacheStats } from "./result-cache.js";
9
+ export { ToolResolver } from "./tool-resolution.js";
10
+ export type { ToolResolutionResult, ToolResolutionCandidate } from "./tool-resolution.js";
7
11
  export { convertJsonSchemaToTypeBox, createToolParameters, setTypeBoxLoader, setSchemaLogger } from "./schema-convert.js";
8
12
  export { initializeProtocol, fetchToolsList, PACKAGE_VERSION } from "./protocol.js";
9
13
  export { loadConfig, parseEnvFile, initConfigDir, getConfigDir } from "./config.js";
10
- export type { Logger, McpServerConfig, McpClientConfig, McpTool, McpRequest, McpCallRequest, McpResponse, JsonRpcMessage, McpTransport, McpServerConnection, BridgeConfig, } from "./types.js";
14
+ export type { Logger, McpServerConfig, McpClientConfig, HttpAuthConfig, RetryConfig, McpTool, McpRequest, McpCallRequest, McpResponse, JsonRpcMessage, McpTransport, McpServerConnection, BridgeConfig, } from "./types.js";
11
15
  export { nextRequestId } from "./types.js";
12
16
  export { pickRegisteredToolName } from "./tool-naming.js";
13
17
  export { StandaloneServer } from "./standalone-server.js";
package/dist/src/index.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // Core exports for @aiwerk/mcp-bridge
2
2
  // Transport classes
3
- export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, warnIfNonTlsRemoteUrl } from "./transport-base.js";
3
+ export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveServerHeaders, warnIfNonTlsRemoteUrl } from "./transport-base.js";
4
4
  export { StdioTransport } from "./transport-stdio.js";
5
5
  export { SseTransport } from "./transport-sse.js";
6
6
  export { StreamableHttpTransport } from "./transport-streamable-http.js";
7
7
  // Router
8
8
  export { McpRouter } from "./mcp-router.js";
9
+ // Result cache
10
+ export { ResultCache, createResultCacheKey, stableStringify } from "./result-cache.js";
11
+ export { ToolResolver } from "./tool-resolution.js";
9
12
  // Schema conversion
10
13
  export { convertJsonSchemaToTypeBox, createToolParameters, setTypeBoxLoader, setSchemaLogger } from "./schema-convert.js";
11
14
  // Protocol helpers
@@ -1,5 +1,16 @@
1
1
  import { McpClientConfig, McpServerConfig, McpTransport, Logger } from "./types.js";
2
2
  type RouterErrorCode = "unknown_server" | "unknown_tool" | "connection_failed" | "mcp_error" | "invalid_params";
3
+ interface RouterBatchResult {
4
+ server: string;
5
+ tool: string;
6
+ result?: any;
7
+ error?: {
8
+ error: RouterErrorCode;
9
+ message: string;
10
+ available?: string[];
11
+ code?: number;
12
+ };
13
+ }
3
14
  export interface RouterToolHint {
4
15
  name: string;
5
16
  description: string;
@@ -26,6 +37,7 @@ export type RouterDispatchResponse = {
26
37
  action: "call";
27
38
  tool: string;
28
39
  result: any;
40
+ retries?: number;
29
41
  } | {
30
42
  server: string;
31
43
  action: "schema";
@@ -35,6 +47,9 @@ export type RouterDispatchResponse = {
35
47
  } | {
36
48
  action: "status";
37
49
  servers: RouterServerStatus[];
50
+ } | {
51
+ action: "batch";
52
+ results: RouterBatchResult[];
38
53
  } | {
39
54
  action: "promotions";
40
55
  promoted: Array<{
@@ -61,6 +76,15 @@ export type RouterDispatchResponse = {
61
76
  tool: string;
62
77
  score: number;
63
78
  }>;
79
+ } | {
80
+ ambiguous: true;
81
+ message: string;
82
+ candidates: Array<{
83
+ server: string;
84
+ tool: string;
85
+ score: number;
86
+ suggested?: true;
87
+ }>;
64
88
  } | {
65
89
  error: RouterErrorCode;
66
90
  message: string;
@@ -79,13 +103,17 @@ export declare class McpRouter {
79
103
  private readonly transportRefs;
80
104
  private readonly idleTimeoutMs;
81
105
  private readonly maxConcurrent;
106
+ private readonly resultCache;
107
+ private readonly maxBatchSize;
82
108
  private readonly states;
109
+ private readonly toolResolver;
83
110
  private intentRouter;
84
111
  private promotion;
85
112
  constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
86
113
  static generateDescription(servers: Record<string, McpServerConfig>): string;
87
114
  dispatch(server?: string, action?: string, tool?: string, params?: any): Promise<RouterDispatchResponse>;
88
115
  getToolList(server: string): Promise<RouterToolHint[]>;
116
+ private primeToolResolutionIndex;
89
117
  private resolveIntent;
90
118
  private getStatus;
91
119
  getPromotedTools(): Array<{
@@ -95,7 +123,11 @@ export declare class McpRouter {
95
123
  inputSchema: any;
96
124
  }>;
97
125
  private getPromotionStats;
126
+ private getRetryPolicy;
127
+ private classifyTransientError;
128
+ private callToolWithRetry;
98
129
  disconnectAll(): Promise<void>;
130
+ shutdown(timeoutMs?: number): Promise<void>;
99
131
  private ensureConnected;
100
132
  private enforceMaxConcurrent;
101
133
  private disconnectServer;
@@ -7,8 +7,12 @@ import { IntentRouter } from "./intent-router.js";
7
7
  import { createEmbeddingProvider } from "./embeddings.js";
8
8
  import { isToolAllowed, processResult } from "./security.js";
9
9
  import { AdaptivePromotion } from "./adaptive-promotion.js";
10
+ import { ResultCache, createResultCacheKey } from "./result-cache.js";
11
+ import { ToolResolver } from "./tool-resolution.js";
10
12
  const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
11
13
  const DEFAULT_MAX_CONCURRENT = 5;
14
+ const DEFAULT_MAX_BATCH_SIZE = 10;
15
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000;
12
16
  export class McpRouter {
13
17
  servers;
14
18
  clientConfig;
@@ -16,7 +20,10 @@ export class McpRouter {
16
20
  transportRefs;
17
21
  idleTimeoutMs;
18
22
  maxConcurrent;
23
+ resultCache;
24
+ maxBatchSize;
19
25
  states = new Map();
26
+ toolResolver;
20
27
  intentRouter = null;
21
28
  promotion = null;
22
29
  constructor(servers, clientConfig, logger, transportRefs) {
@@ -30,6 +37,15 @@ export class McpRouter {
30
37
  };
31
38
  this.idleTimeoutMs = clientConfig.routerIdleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
32
39
  this.maxConcurrent = clientConfig.routerMaxConcurrent ?? DEFAULT_MAX_CONCURRENT;
40
+ this.resultCache = clientConfig.resultCache?.enabled
41
+ ? new ResultCache({
42
+ maxEntries: clientConfig.resultCache.maxEntries,
43
+ defaultTtlMs: clientConfig.resultCache.defaultTtlMs,
44
+ cacheTtl: clientConfig.resultCache.cacheTtl
45
+ })
46
+ : null;
47
+ this.maxBatchSize = clientConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
48
+ this.toolResolver = new ToolResolver(Object.keys(servers));
33
49
  if (clientConfig.adaptivePromotion?.enabled) {
34
50
  this.promotion = new AdaptivePromotion(clientConfig.adaptivePromotion, logger);
35
51
  }
@@ -45,7 +61,7 @@ export class McpRouter {
45
61
  return desc ? `${name} (${desc})` : name;
46
62
  })
47
63
  .join(", ");
48
- return `Call any MCP server tool. Servers: ${serverList}. Use action='list' to discover tools and required parameters, action='call' to execute a tool, action='refresh' to clear cache and re-discover tools, and action='status' to check server connection states. If the user mentions a specific tool by name, the call action auto-connects and works without listing first.`;
64
+ return `Call any MCP server tool. Servers: ${serverList}. Use action='list' to discover tools and required parameters, action='call' to execute a tool, action='batch' to execute multiple calls in one round-trip, action='refresh' to clear cache and re-discover tools, and action='status' to check server connection states. If the user mentions a specific tool by name, the call action auto-connects and works without listing first.`;
49
65
  }
50
66
  async dispatch(server, action = "call", tool, params) {
51
67
  try {
@@ -66,13 +82,45 @@ export class McpRouter {
66
82
  }
67
83
  return this.resolveIntent(intent);
68
84
  }
69
- if (!server) {
70
- return this.error("invalid_params", "server is required");
71
- }
72
- if (!this.servers[server]) {
73
- return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
85
+ if (normalizedAction === "batch") {
86
+ const calls = params?.calls;
87
+ if (!Array.isArray(calls) || calls.length === 0) {
88
+ return this.error("invalid_params", "calls must be a non-empty array for action=batch");
89
+ }
90
+ if (calls.length > this.maxBatchSize) {
91
+ return this.error("invalid_params", `batch size exceeds maxBatchSize (${this.maxBatchSize})`);
92
+ }
93
+ const results = await Promise.all(calls.map(async (call) => {
94
+ const callServer = typeof call?.server === "string" ? call.server : "";
95
+ const callTool = typeof call?.tool === "string" ? call.tool : "";
96
+ const response = await this.dispatch(callServer, "call", callTool, call?.params);
97
+ if ("error" in response) {
98
+ return {
99
+ server: callServer,
100
+ tool: callTool,
101
+ error: {
102
+ error: response.error,
103
+ message: response.message,
104
+ ...(response.available ? { available: response.available } : {}),
105
+ ...(typeof response.code === "number" ? { code: response.code } : {})
106
+ }
107
+ };
108
+ }
109
+ return {
110
+ server: callServer,
111
+ tool: callTool,
112
+ result: "result" in response ? response.result : response
113
+ };
114
+ }));
115
+ return { action: "batch", results };
74
116
  }
75
117
  if (normalizedAction === "list") {
118
+ if (!server) {
119
+ return this.error("invalid_params", "server is required for action=list");
120
+ }
121
+ if (!this.servers[server]) {
122
+ return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
123
+ }
76
124
  try {
77
125
  const tools = await this.getToolList(server);
78
126
  return { server, action: "list", tools };
@@ -82,6 +130,12 @@ export class McpRouter {
82
130
  }
83
131
  }
84
132
  if (normalizedAction === "schema") {
133
+ if (!server) {
134
+ return this.error("invalid_params", "server is required for action=schema");
135
+ }
136
+ if (!this.servers[server]) {
137
+ return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
138
+ }
85
139
  if (!tool) {
86
140
  return this.error("invalid_params", "tool is required for action=schema");
87
141
  }
@@ -99,11 +153,19 @@ export class McpRouter {
99
153
  return { server, action: "schema", tool, schema: fullTool.inputSchema, description: fullTool.description };
100
154
  }
101
155
  if (normalizedAction === "refresh") {
156
+ if (!server) {
157
+ return this.error("invalid_params", "server is required for action=refresh");
158
+ }
159
+ if (!this.servers[server]) {
160
+ return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
161
+ }
162
+ this.resultCache?.invalidate();
102
163
  try {
103
164
  const state = await this.ensureConnected(server);
104
165
  state.toolsCache = undefined;
105
166
  state.fullToolsMap = undefined;
106
167
  state.toolNames = [];
168
+ this.toolResolver.removeServer(server);
107
169
  // Clear intent index so it re-indexes on next intent query
108
170
  if (this.intentRouter) {
109
171
  this.intentRouter.clearIndex();
@@ -116,35 +178,58 @@ export class McpRouter {
116
178
  }
117
179
  }
118
180
  if (normalizedAction !== "call") {
119
- return this.error("invalid_params", `action must be one of: list, call, refresh, schema, intent`);
181
+ return this.error("invalid_params", `action must be one of: list, call, batch, refresh, schema, intent`);
120
182
  }
121
183
  if (!tool) {
122
184
  return this.error("invalid_params", "tool is required for action=call");
123
185
  }
186
+ let targetServer = server;
187
+ if (!targetServer) {
188
+ await this.primeToolResolutionIndex();
189
+ const resolution = this.toolResolver.resolve(tool, params ?? {});
190
+ if (!resolution) {
191
+ return this.error("unknown_tool", `Tool '${tool}' was not found on any connected server`, this.toolResolver.getKnownToolNames());
192
+ }
193
+ if ("ambiguous" in resolution) {
194
+ return resolution;
195
+ }
196
+ targetServer = resolution.server;
197
+ }
198
+ if (!this.servers[targetServer]) {
199
+ return this.error("unknown_server", `Server '${targetServer}' not found`, Object.keys(this.servers));
200
+ }
124
201
  try {
125
- await this.getToolList(server);
202
+ await this.getToolList(targetServer);
126
203
  }
127
204
  catch (error) {
128
- return this.error("connection_failed", `Failed to connect to ${server}: ${error instanceof Error ? error.message : String(error)}`);
205
+ return this.error("connection_failed", `Failed to connect to ${targetServer}: ${error instanceof Error ? error.message : String(error)}`);
129
206
  }
130
- const state = this.states.get(server);
207
+ const state = this.states.get(targetServer);
131
208
  if (!state.toolNames.includes(tool)) {
132
- return this.error("unknown_tool", `Tool '${tool}' not found on server '${server}'`, state.toolNames);
209
+ return this.error("unknown_tool", `Tool '${tool}' not found on server '${targetServer}'`, state.toolNames);
133
210
  }
134
211
  // Defense in depth: double-check tool filter
135
- const serverConfig = this.servers[server];
212
+ const serverConfig = this.servers[targetServer];
136
213
  if (!isToolAllowed(tool, serverConfig)) {
137
- return this.error("unknown_tool", `Tool '${tool}' is not allowed on server '${server}'`, state.toolNames);
214
+ return this.error("unknown_tool", `Tool '${tool}' is not allowed on server '${targetServer}'`, state.toolNames);
138
215
  }
139
- this.markUsed(server);
140
- const response = await state.transport.sendRequest({
141
- jsonrpc: "2.0",
142
- method: "tools/call",
143
- params: {
144
- name: tool,
145
- arguments: params ?? {}
216
+ server = targetServer;
217
+ const cacheKey = this.resultCache
218
+ ? createResultCacheKey(server, tool, params ?? {})
219
+ : null;
220
+ if (this.resultCache && cacheKey) {
221
+ const cachedResult = this.resultCache.get(cacheKey);
222
+ if (cachedResult !== undefined) {
223
+ if (this.promotion) {
224
+ this.promotion.recordCall(server, tool);
225
+ }
226
+ this.toolResolver.recordCall(server, tool);
227
+ return { server, action: "call", tool, result: cachedResult };
146
228
  }
147
- });
229
+ }
230
+ this.markUsed(server);
231
+ const callOutcome = await this.callToolWithRetry(server, tool, params ?? {}, state.transport);
232
+ const response = callOutcome.response;
148
233
  if (response.error) {
149
234
  return this.error("mcp_error", response.error.message, undefined, response.error.code);
150
235
  }
@@ -152,9 +237,19 @@ export class McpRouter {
152
237
  if (this.promotion) {
153
238
  this.promotion.recordCall(server, tool);
154
239
  }
240
+ this.toolResolver.recordCall(server, tool);
155
241
  // Security pipeline: truncate → sanitize → trust-tag
156
242
  const result = processResult(response.result, server, serverConfig, this.clientConfig);
157
- return { server, action: "call", tool, result };
243
+ if (this.resultCache && cacheKey) {
244
+ this.resultCache.set(cacheKey, result);
245
+ }
246
+ return {
247
+ server,
248
+ action: "call",
249
+ tool,
250
+ result,
251
+ ...(callOutcome.retries > 0 ? { retries: callOutcome.retries } : {})
252
+ };
158
253
  }
159
254
  catch (error) {
160
255
  return this.error("mcp_error", error instanceof Error ? error.message : String(error));
@@ -175,6 +270,10 @@ export class McpRouter {
175
270
  state.toolNames = tools.map((tool) => tool.name);
176
271
  // Store full tool metadata for action=schema
177
272
  state.fullToolsMap = new Map(tools.map((tool) => [tool.name, { description: tool.description || "", inputSchema: tool.inputSchema }]));
273
+ this.toolResolver.registerServerTools(server, tools.map((tool) => ({
274
+ name: tool.name,
275
+ inputSchema: tool.inputSchema
276
+ })));
178
277
  const compressionEnabled = this.clientConfig.schemaCompression?.enabled ?? true;
179
278
  const maxLen = this.clientConfig.schemaCompression?.maxDescriptionLength ?? 80;
180
279
  state.toolsCache = tools.map((tool) => ({
@@ -187,6 +286,17 @@ export class McpRouter {
187
286
  this.markUsed(server);
188
287
  return state.toolsCache;
189
288
  }
289
+ async primeToolResolutionIndex() {
290
+ for (const serverName of Object.keys(this.servers)) {
291
+ try {
292
+ await this.getToolList(serverName);
293
+ }
294
+ catch (error) {
295
+ this.toolResolver.removeServer(serverName);
296
+ this.logger.warn(`[mcp-bridge] Tool resolution: failed to load tools from ${serverName}:`, error);
297
+ }
298
+ }
299
+ }
190
300
  async resolveIntent(intent) {
191
301
  try {
192
302
  // Lazily create the intent router
@@ -281,11 +391,103 @@ export class McpRouter {
281
391
  }));
282
392
  return { action: "promotions", promoted, stats };
283
393
  }
394
+ getRetryPolicy(server) {
395
+ const globalRetry = this.clientConfig.retry ?? {};
396
+ const serverRetry = this.servers[server].retry ?? {};
397
+ const maxAttemptsRaw = serverRetry.maxAttempts ?? globalRetry.maxAttempts ?? 1;
398
+ const delayMsRaw = serverRetry.delayMs ?? globalRetry.delayMs ?? 1000;
399
+ const backoffMultiplierRaw = serverRetry.backoffMultiplier ?? globalRetry.backoffMultiplier ?? 2;
400
+ const retryOn = serverRetry.retryOn ?? globalRetry.retryOn ?? ["timeout", "connection_error"];
401
+ return {
402
+ maxAttempts: Math.max(1, Math.floor(maxAttemptsRaw)),
403
+ delayMs: Math.max(0, Math.floor(delayMsRaw)),
404
+ backoffMultiplier: Math.max(1, backoffMultiplierRaw),
405
+ retryOn: new Set(retryOn)
406
+ };
407
+ }
408
+ classifyTransientError(error) {
409
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
410
+ if (message.includes("timeout") ||
411
+ message.includes("timed out") ||
412
+ message.includes("abort")) {
413
+ return "timeout";
414
+ }
415
+ if (message.includes("connection") ||
416
+ message.includes("econnreset") ||
417
+ message.includes("socket hang up") ||
418
+ message.includes("network") ||
419
+ message.includes("fetch failed") ||
420
+ message.includes("econnrefused") ||
421
+ message.includes("enotfound")) {
422
+ return "connection_error";
423
+ }
424
+ return null;
425
+ }
426
+ async callToolWithRetry(server, tool, args, transport) {
427
+ const retryPolicy = this.getRetryPolicy(server);
428
+ let retries = 0;
429
+ let lastError;
430
+ for (let attempt = 0; attempt < retryPolicy.maxAttempts; attempt++) {
431
+ try {
432
+ const response = await transport.sendRequest({
433
+ jsonrpc: "2.0",
434
+ method: "tools/call",
435
+ params: {
436
+ name: tool,
437
+ arguments: args
438
+ }
439
+ });
440
+ return { response, retries };
441
+ }
442
+ catch (error) {
443
+ lastError = error;
444
+ const category = this.classifyTransientError(error);
445
+ const shouldRetry = category !== null &&
446
+ retryPolicy.retryOn.has(category) &&
447
+ attempt < retryPolicy.maxAttempts - 1;
448
+ if (!shouldRetry) {
449
+ throw error;
450
+ }
451
+ retries += 1;
452
+ const delay = retryPolicy.delayMs * Math.pow(retryPolicy.backoffMultiplier, attempt);
453
+ if (delay > 0) {
454
+ await new Promise((resolve) => setTimeout(resolve, delay));
455
+ }
456
+ }
457
+ }
458
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
459
+ }
284
460
  async disconnectAll() {
285
461
  for (const serverName of Object.keys(this.servers)) {
286
462
  await this.disconnectServer(serverName);
287
463
  }
288
464
  }
465
+ async shutdown(timeoutMs = this.clientConfig.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS) {
466
+ const effectiveTimeout = Math.max(0, timeoutMs);
467
+ for (const [serverName, state] of this.states) {
468
+ if (state.idleTimer) {
469
+ clearTimeout(state.idleTimer);
470
+ state.idleTimer = null;
471
+ }
472
+ try {
473
+ if (state.transport.shutdown) {
474
+ await state.transport.shutdown(effectiveTimeout);
475
+ }
476
+ else {
477
+ await state.transport.disconnect();
478
+ }
479
+ }
480
+ catch (error) {
481
+ this.logger.warn(`[mcp-bridge] Router shutdown: failed to close ${serverName}:`, error);
482
+ }
483
+ }
484
+ this.states.clear();
485
+ this.toolResolver.clear();
486
+ if (this.intentRouter) {
487
+ this.intentRouter.clearIndex();
488
+ }
489
+ this.resultCache?.invalidate();
490
+ }
289
491
  async ensureConnected(server) {
290
492
  let state = this.states.get(server);
291
493
  if (!state) {
@@ -354,6 +556,8 @@ export class McpRouter {
354
556
  state.toolsCache = undefined;
355
557
  state.fullToolsMap = undefined;
356
558
  state.toolNames = [];
559
+ this.toolResolver.removeServer(server);
560
+ this.resultCache?.invalidate(`${server}:`);
357
561
  }
358
562
  markUsed(server) {
359
563
  const state = this.states.get(server);
@@ -382,6 +586,8 @@ export class McpRouter {
382
586
  state.toolsCache = undefined;
383
587
  state.fullToolsMap = undefined;
384
588
  state.toolNames = [];
589
+ this.toolResolver.removeServer(serverName);
590
+ this.resultCache?.invalidate(`${serverName}:`);
385
591
  };
386
592
  if (serverConfig.transport === "sse") {
387
593
  return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected);
@@ -0,0 +1,29 @@
1
+ export interface ResultCacheConfig {
2
+ maxEntries?: number;
3
+ defaultTtlMs?: number;
4
+ cacheTtl?: Record<string, number>;
5
+ }
6
+ export interface ResultCacheStats {
7
+ hits: number;
8
+ misses: number;
9
+ evictions: number;
10
+ size: number;
11
+ }
12
+ export declare function stableStringify(value: unknown): string;
13
+ export declare function createResultCacheKey(server: string, tool: string, params: unknown): string;
14
+ export declare class ResultCache {
15
+ private readonly maxEntries;
16
+ private readonly defaultTtlMs;
17
+ private readonly cacheTtl;
18
+ private readonly entries;
19
+ private hits;
20
+ private misses;
21
+ private evictions;
22
+ constructor(config?: ResultCacheConfig);
23
+ get(key: string): unknown;
24
+ set(key: string, value: unknown, ttlMs?: number): void;
25
+ invalidate(pattern?: string | RegExp): number;
26
+ stats(): ResultCacheStats;
27
+ private resolveTtlMsForKey;
28
+ private trimToCapacity;
29
+ }