@aiwerk/mcp-bridge 1.8.0 → 1.9.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",
@@ -4,6 +4,10 @@ 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";
package/dist/src/index.js CHANGED
@@ -6,6 +6,9 @@ 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;
@@ -35,6 +46,9 @@ export type RouterDispatchResponse = {
35
46
  } | {
36
47
  action: "status";
37
48
  servers: RouterServerStatus[];
49
+ } | {
50
+ action: "batch";
51
+ results: RouterBatchResult[];
38
52
  } | {
39
53
  action: "promotions";
40
54
  promoted: Array<{
@@ -61,6 +75,15 @@ export type RouterDispatchResponse = {
61
75
  tool: string;
62
76
  score: number;
63
77
  }>;
78
+ } | {
79
+ ambiguous: true;
80
+ message: string;
81
+ candidates: Array<{
82
+ server: string;
83
+ tool: string;
84
+ score: number;
85
+ suggested?: true;
86
+ }>;
64
87
  } | {
65
88
  error: RouterErrorCode;
66
89
  message: string;
@@ -79,13 +102,17 @@ export declare class McpRouter {
79
102
  private readonly transportRefs;
80
103
  private readonly idleTimeoutMs;
81
104
  private readonly maxConcurrent;
105
+ private readonly resultCache;
106
+ private readonly maxBatchSize;
82
107
  private readonly states;
108
+ private readonly toolResolver;
83
109
  private intentRouter;
84
110
  private promotion;
85
111
  constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
86
112
  static generateDescription(servers: Record<string, McpServerConfig>): string;
87
113
  dispatch(server?: string, action?: string, tool?: string, params?: any): Promise<RouterDispatchResponse>;
88
114
  getToolList(server: string): Promise<RouterToolHint[]>;
115
+ private primeToolResolutionIndex;
89
116
  private resolveIntent;
90
117
  private getStatus;
91
118
  getPromotedTools(): Array<{
@@ -7,8 +7,11 @@ 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;
12
15
  export class McpRouter {
13
16
  servers;
14
17
  clientConfig;
@@ -16,7 +19,10 @@ export class McpRouter {
16
19
  transportRefs;
17
20
  idleTimeoutMs;
18
21
  maxConcurrent;
22
+ resultCache;
23
+ maxBatchSize;
19
24
  states = new Map();
25
+ toolResolver;
20
26
  intentRouter = null;
21
27
  promotion = null;
22
28
  constructor(servers, clientConfig, logger, transportRefs) {
@@ -30,6 +36,15 @@ export class McpRouter {
30
36
  };
31
37
  this.idleTimeoutMs = clientConfig.routerIdleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
32
38
  this.maxConcurrent = clientConfig.routerMaxConcurrent ?? DEFAULT_MAX_CONCURRENT;
39
+ this.resultCache = clientConfig.resultCache?.enabled
40
+ ? new ResultCache({
41
+ maxEntries: clientConfig.resultCache.maxEntries,
42
+ defaultTtlMs: clientConfig.resultCache.defaultTtlMs,
43
+ cacheTtl: clientConfig.resultCache.cacheTtl
44
+ })
45
+ : null;
46
+ this.maxBatchSize = clientConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
47
+ this.toolResolver = new ToolResolver(Object.keys(servers));
33
48
  if (clientConfig.adaptivePromotion?.enabled) {
34
49
  this.promotion = new AdaptivePromotion(clientConfig.adaptivePromotion, logger);
35
50
  }
@@ -45,7 +60,7 @@ export class McpRouter {
45
60
  return desc ? `${name} (${desc})` : name;
46
61
  })
47
62
  .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.`;
63
+ 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
64
  }
50
65
  async dispatch(server, action = "call", tool, params) {
51
66
  try {
@@ -66,13 +81,45 @@ export class McpRouter {
66
81
  }
67
82
  return this.resolveIntent(intent);
68
83
  }
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));
84
+ if (normalizedAction === "batch") {
85
+ const calls = params?.calls;
86
+ if (!Array.isArray(calls) || calls.length === 0) {
87
+ return this.error("invalid_params", "calls must be a non-empty array for action=batch");
88
+ }
89
+ if (calls.length > this.maxBatchSize) {
90
+ return this.error("invalid_params", `batch size exceeds maxBatchSize (${this.maxBatchSize})`);
91
+ }
92
+ const results = await Promise.all(calls.map(async (call) => {
93
+ const callServer = typeof call?.server === "string" ? call.server : "";
94
+ const callTool = typeof call?.tool === "string" ? call.tool : "";
95
+ const response = await this.dispatch(callServer, "call", callTool, call?.params);
96
+ if ("error" in response) {
97
+ return {
98
+ server: callServer,
99
+ tool: callTool,
100
+ error: {
101
+ error: response.error,
102
+ message: response.message,
103
+ ...(response.available ? { available: response.available } : {}),
104
+ ...(typeof response.code === "number" ? { code: response.code } : {})
105
+ }
106
+ };
107
+ }
108
+ return {
109
+ server: callServer,
110
+ tool: callTool,
111
+ result: "result" in response ? response.result : response
112
+ };
113
+ }));
114
+ return { action: "batch", results };
74
115
  }
75
116
  if (normalizedAction === "list") {
117
+ if (!server) {
118
+ return this.error("invalid_params", "server is required for action=list");
119
+ }
120
+ if (!this.servers[server]) {
121
+ return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
122
+ }
76
123
  try {
77
124
  const tools = await this.getToolList(server);
78
125
  return { server, action: "list", tools };
@@ -82,6 +129,12 @@ export class McpRouter {
82
129
  }
83
130
  }
84
131
  if (normalizedAction === "schema") {
132
+ if (!server) {
133
+ return this.error("invalid_params", "server is required for action=schema");
134
+ }
135
+ if (!this.servers[server]) {
136
+ return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
137
+ }
85
138
  if (!tool) {
86
139
  return this.error("invalid_params", "tool is required for action=schema");
87
140
  }
@@ -99,11 +152,19 @@ export class McpRouter {
99
152
  return { server, action: "schema", tool, schema: fullTool.inputSchema, description: fullTool.description };
100
153
  }
101
154
  if (normalizedAction === "refresh") {
155
+ if (!server) {
156
+ return this.error("invalid_params", "server is required for action=refresh");
157
+ }
158
+ if (!this.servers[server]) {
159
+ return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
160
+ }
161
+ this.resultCache?.invalidate();
102
162
  try {
103
163
  const state = await this.ensureConnected(server);
104
164
  state.toolsCache = undefined;
105
165
  state.fullToolsMap = undefined;
106
166
  state.toolNames = [];
167
+ this.toolResolver.removeServer(server);
107
168
  // Clear intent index so it re-indexes on next intent query
108
169
  if (this.intentRouter) {
109
170
  this.intentRouter.clearIndex();
@@ -116,25 +177,54 @@ export class McpRouter {
116
177
  }
117
178
  }
118
179
  if (normalizedAction !== "call") {
119
- return this.error("invalid_params", `action must be one of: list, call, refresh, schema, intent`);
180
+ return this.error("invalid_params", `action must be one of: list, call, batch, refresh, schema, intent`);
120
181
  }
121
182
  if (!tool) {
122
183
  return this.error("invalid_params", "tool is required for action=call");
123
184
  }
185
+ let targetServer = server;
186
+ if (!targetServer) {
187
+ await this.primeToolResolutionIndex();
188
+ const resolution = this.toolResolver.resolve(tool, params ?? {});
189
+ if (!resolution) {
190
+ return this.error("unknown_tool", `Tool '${tool}' was not found on any connected server`, this.toolResolver.getKnownToolNames());
191
+ }
192
+ if ("ambiguous" in resolution) {
193
+ return resolution;
194
+ }
195
+ targetServer = resolution.server;
196
+ }
197
+ if (!this.servers[targetServer]) {
198
+ return this.error("unknown_server", `Server '${targetServer}' not found`, Object.keys(this.servers));
199
+ }
124
200
  try {
125
- await this.getToolList(server);
201
+ await this.getToolList(targetServer);
126
202
  }
127
203
  catch (error) {
128
- return this.error("connection_failed", `Failed to connect to ${server}: ${error instanceof Error ? error.message : String(error)}`);
204
+ return this.error("connection_failed", `Failed to connect to ${targetServer}: ${error instanceof Error ? error.message : String(error)}`);
129
205
  }
130
- const state = this.states.get(server);
206
+ const state = this.states.get(targetServer);
131
207
  if (!state.toolNames.includes(tool)) {
132
- return this.error("unknown_tool", `Tool '${tool}' not found on server '${server}'`, state.toolNames);
208
+ return this.error("unknown_tool", `Tool '${tool}' not found on server '${targetServer}'`, state.toolNames);
133
209
  }
134
210
  // Defense in depth: double-check tool filter
135
- const serverConfig = this.servers[server];
211
+ const serverConfig = this.servers[targetServer];
136
212
  if (!isToolAllowed(tool, serverConfig)) {
137
- return this.error("unknown_tool", `Tool '${tool}' is not allowed on server '${server}'`, state.toolNames);
213
+ return this.error("unknown_tool", `Tool '${tool}' is not allowed on server '${targetServer}'`, state.toolNames);
214
+ }
215
+ server = targetServer;
216
+ const cacheKey = this.resultCache
217
+ ? createResultCacheKey(server, tool, params ?? {})
218
+ : null;
219
+ if (this.resultCache && cacheKey) {
220
+ const cachedResult = this.resultCache.get(cacheKey);
221
+ if (cachedResult !== undefined) {
222
+ if (this.promotion) {
223
+ this.promotion.recordCall(server, tool);
224
+ }
225
+ this.toolResolver.recordCall(server, tool);
226
+ return { server, action: "call", tool, result: cachedResult };
227
+ }
138
228
  }
139
229
  this.markUsed(server);
140
230
  const response = await state.transport.sendRequest({
@@ -152,8 +242,12 @@ export class McpRouter {
152
242
  if (this.promotion) {
153
243
  this.promotion.recordCall(server, tool);
154
244
  }
245
+ this.toolResolver.recordCall(server, tool);
155
246
  // Security pipeline: truncate → sanitize → trust-tag
156
247
  const result = processResult(response.result, server, serverConfig, this.clientConfig);
248
+ if (this.resultCache && cacheKey) {
249
+ this.resultCache.set(cacheKey, result);
250
+ }
157
251
  return { server, action: "call", tool, result };
158
252
  }
159
253
  catch (error) {
@@ -175,6 +269,10 @@ export class McpRouter {
175
269
  state.toolNames = tools.map((tool) => tool.name);
176
270
  // Store full tool metadata for action=schema
177
271
  state.fullToolsMap = new Map(tools.map((tool) => [tool.name, { description: tool.description || "", inputSchema: tool.inputSchema }]));
272
+ this.toolResolver.registerServerTools(server, tools.map((tool) => ({
273
+ name: tool.name,
274
+ inputSchema: tool.inputSchema
275
+ })));
178
276
  const compressionEnabled = this.clientConfig.schemaCompression?.enabled ?? true;
179
277
  const maxLen = this.clientConfig.schemaCompression?.maxDescriptionLength ?? 80;
180
278
  state.toolsCache = tools.map((tool) => ({
@@ -187,6 +285,17 @@ export class McpRouter {
187
285
  this.markUsed(server);
188
286
  return state.toolsCache;
189
287
  }
288
+ async primeToolResolutionIndex() {
289
+ for (const serverName of Object.keys(this.servers)) {
290
+ try {
291
+ await this.getToolList(serverName);
292
+ }
293
+ catch (error) {
294
+ this.toolResolver.removeServer(serverName);
295
+ this.logger.warn(`[mcp-bridge] Tool resolution: failed to load tools from ${serverName}:`, error);
296
+ }
297
+ }
298
+ }
190
299
  async resolveIntent(intent) {
191
300
  try {
192
301
  // Lazily create the intent router
@@ -354,6 +463,8 @@ export class McpRouter {
354
463
  state.toolsCache = undefined;
355
464
  state.fullToolsMap = undefined;
356
465
  state.toolNames = [];
466
+ this.toolResolver.removeServer(server);
467
+ this.resultCache?.invalidate(`${server}:`);
357
468
  }
358
469
  markUsed(server) {
359
470
  const state = this.states.get(server);
@@ -382,6 +493,8 @@ export class McpRouter {
382
493
  state.toolsCache = undefined;
383
494
  state.fullToolsMap = undefined;
384
495
  state.toolNames = [];
496
+ this.toolResolver.removeServer(serverName);
497
+ this.resultCache?.invalidate(`${serverName}:`);
385
498
  };
386
499
  if (serverConfig.transport === "sse") {
387
500
  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
+ }
@@ -0,0 +1,120 @@
1
+ const DEFAULT_MAX_ENTRIES = 100;
2
+ const DEFAULT_TTL_MS = 300_000;
3
+ function normalizeForStableJson(value, inArray) {
4
+ if (value === undefined || typeof value === "function" || typeof value === "symbol") {
5
+ return inArray ? null : undefined;
6
+ }
7
+ if (value === null || typeof value !== "object") {
8
+ return value;
9
+ }
10
+ if (Array.isArray(value)) {
11
+ return value.map((item) => normalizeForStableJson(item, true));
12
+ }
13
+ const obj = value;
14
+ const normalized = {};
15
+ const keys = Object.keys(obj).sort();
16
+ for (const key of keys) {
17
+ const normalizedValue = normalizeForStableJson(obj[key], false);
18
+ if (normalizedValue !== undefined) {
19
+ normalized[key] = normalizedValue;
20
+ }
21
+ }
22
+ return normalized;
23
+ }
24
+ export function stableStringify(value) {
25
+ const normalized = normalizeForStableJson(value, false);
26
+ const serialized = JSON.stringify(normalized);
27
+ return serialized === undefined ? "undefined" : serialized;
28
+ }
29
+ export function createResultCacheKey(server, tool, params) {
30
+ return `${server}:${tool}:${stableStringify(params)}`;
31
+ }
32
+ export class ResultCache {
33
+ maxEntries;
34
+ defaultTtlMs;
35
+ cacheTtl;
36
+ entries = new Map();
37
+ hits = 0;
38
+ misses = 0;
39
+ evictions = 0;
40
+ constructor(config = {}) {
41
+ this.maxEntries = config.maxEntries ?? DEFAULT_MAX_ENTRIES;
42
+ this.defaultTtlMs = config.defaultTtlMs ?? DEFAULT_TTL_MS;
43
+ this.cacheTtl = config.cacheTtl ?? {};
44
+ }
45
+ get(key) {
46
+ const entry = this.entries.get(key);
47
+ if (!entry) {
48
+ this.misses += 1;
49
+ return undefined;
50
+ }
51
+ if (entry.expiresAt <= Date.now()) {
52
+ this.entries.delete(key);
53
+ this.misses += 1;
54
+ return undefined;
55
+ }
56
+ this.entries.delete(key);
57
+ this.entries.set(key, entry);
58
+ this.hits += 1;
59
+ return entry.value;
60
+ }
61
+ set(key, value, ttlMs) {
62
+ const effectiveTtlMs = ttlMs ?? this.resolveTtlMsForKey(key);
63
+ const entry = {
64
+ value,
65
+ expiresAt: Date.now() + effectiveTtlMs
66
+ };
67
+ if (this.entries.has(key)) {
68
+ this.entries.delete(key);
69
+ }
70
+ this.entries.set(key, entry);
71
+ this.trimToCapacity();
72
+ }
73
+ invalidate(pattern) {
74
+ if (!pattern) {
75
+ const size = this.entries.size;
76
+ this.entries.clear();
77
+ return size;
78
+ }
79
+ let removed = 0;
80
+ for (const key of this.entries.keys()) {
81
+ const matches = typeof pattern === "string"
82
+ ? key.includes(pattern)
83
+ : pattern.test(key);
84
+ if (matches) {
85
+ this.entries.delete(key);
86
+ removed += 1;
87
+ }
88
+ }
89
+ return removed;
90
+ }
91
+ stats() {
92
+ return {
93
+ hits: this.hits,
94
+ misses: this.misses,
95
+ evictions: this.evictions,
96
+ size: this.entries.size
97
+ };
98
+ }
99
+ resolveTtlMsForKey(key) {
100
+ const firstColon = key.indexOf(":");
101
+ const secondColon = key.indexOf(":", firstColon + 1);
102
+ if (firstColon === -1 || secondColon === -1) {
103
+ return this.defaultTtlMs;
104
+ }
105
+ const server = key.slice(0, firstColon);
106
+ const tool = key.slice(firstColon + 1, secondColon);
107
+ const override = this.cacheTtl[`${server}:${tool}`];
108
+ return override ?? this.defaultTtlMs;
109
+ }
110
+ trimToCapacity() {
111
+ while (this.entries.size > this.maxEntries) {
112
+ const oldestKey = this.entries.keys().next().value;
113
+ if (oldestKey === undefined) {
114
+ break;
115
+ }
116
+ this.entries.delete(oldestKey);
117
+ this.evictions += 1;
118
+ }
119
+ }
120
+ }
@@ -207,11 +207,24 @@ export class StandaloneServer {
207
207
  type: "object",
208
208
  properties: {
209
209
  server: { type: "string", description: "Server name" },
210
- action: { type: "string", description: "list | call | refresh | status" },
211
- tool: { type: "string", description: "Tool name for action=call" },
212
- params: { type: "object", description: "Tool arguments" }
210
+ action: { type: "string", description: "list | call | batch | refresh | status | intent | schema | promotions" },
211
+ tool: { type: "string", description: "Tool name for action=call/schema" },
212
+ params: { type: "object", description: "Tool arguments" },
213
+ calls: {
214
+ type: "array",
215
+ description: "Batch calls for action=batch",
216
+ items: {
217
+ type: "object",
218
+ properties: {
219
+ server: { type: "string" },
220
+ tool: { type: "string" },
221
+ params: { type: "object" }
222
+ },
223
+ required: ["server", "tool"]
224
+ }
225
+ }
213
226
  },
214
- required: ["server"]
227
+ required: []
215
228
  }
216
229
  }]
217
230
  }
@@ -244,7 +257,10 @@ export class StandaloneServer {
244
257
  error: { code: -32004, message: `Unknown tool: ${toolName}. In router mode, use the 'mcp' tool.` }
245
258
  };
246
259
  }
247
- const result = await this.router.dispatch(toolArgs.server, toolArgs.action, toolArgs.tool, toolArgs.params);
260
+ const dispatchParams = toolArgs.action === "batch"
261
+ ? { ...(toolArgs.params ?? {}), calls: toolArgs.calls }
262
+ : toolArgs.params;
263
+ const result = await this.router.dispatch(toolArgs.server, toolArgs.action, toolArgs.tool, dispatchParams);
248
264
  // Check if result is an error
249
265
  if ("error" in result) {
250
266
  return {
@@ -0,0 +1,32 @@
1
+ export interface ToolResolutionCandidate {
2
+ server: string;
3
+ tool: string;
4
+ score: number;
5
+ suggested?: true;
6
+ }
7
+ export type ToolResolutionResult = {
8
+ server: string;
9
+ tool: string;
10
+ } | {
11
+ ambiguous: true;
12
+ message: string;
13
+ candidates: ToolResolutionCandidate[];
14
+ } | null;
15
+ export declare class ToolResolver {
16
+ private readonly basePriority;
17
+ private readonly toolsByName;
18
+ private readonly toolNamesByServer;
19
+ private readonly recentCalls;
20
+ constructor(serverOrder: string[]);
21
+ registerServerTools(server: string, tools: Array<{
22
+ name: string;
23
+ inputSchema: any;
24
+ }>): void;
25
+ removeServer(server: string): void;
26
+ resolve(toolName: string, params?: Record<string, unknown>, serverHint?: string): ToolResolutionResult;
27
+ recordCall(server: string, tool: string): void;
28
+ getKnownToolNames(): string[];
29
+ private scoreCandidate;
30
+ private wasUsedRecently;
31
+ private computeParamMatch;
32
+ }
@@ -0,0 +1,130 @@
1
+ const RECENT_CALL_LIMIT = 5;
2
+ const BASE_PRIORITY_STEP = 0.1;
3
+ const BASE_PRIORITY_MIN = 0.1;
4
+ const RECENCY_BOOST = 0.3;
5
+ const PARAM_MATCH_WEIGHT = 0.2;
6
+ const AUTO_RESOLVE_DELTA = 0.15;
7
+ export class ToolResolver {
8
+ basePriority = new Map();
9
+ toolsByName = new Map();
10
+ toolNamesByServer = new Map();
11
+ recentCalls = [];
12
+ constructor(serverOrder) {
13
+ const reversed = [...serverOrder].reverse();
14
+ reversed.forEach((server, index) => {
15
+ const score = Math.max(1.0 - (index * BASE_PRIORITY_STEP), BASE_PRIORITY_MIN);
16
+ this.basePriority.set(server, score);
17
+ });
18
+ }
19
+ registerServerTools(server, tools) {
20
+ this.removeServer(server);
21
+ const names = new Set();
22
+ for (const tool of tools) {
23
+ if (!tool?.name)
24
+ continue;
25
+ const registered = {
26
+ server,
27
+ tool: tool.name,
28
+ inputSchema: tool.inputSchema
29
+ };
30
+ const existing = this.toolsByName.get(tool.name) ?? [];
31
+ existing.push(registered);
32
+ this.toolsByName.set(tool.name, existing);
33
+ names.add(tool.name);
34
+ }
35
+ this.toolNamesByServer.set(server, names);
36
+ }
37
+ removeServer(server) {
38
+ const previousNames = this.toolNamesByServer.get(server);
39
+ if (previousNames) {
40
+ for (const toolName of previousNames) {
41
+ const filtered = (this.toolsByName.get(toolName) ?? []).filter((entry) => entry.server !== server);
42
+ if (filtered.length === 0) {
43
+ this.toolsByName.delete(toolName);
44
+ continue;
45
+ }
46
+ this.toolsByName.set(toolName, filtered);
47
+ }
48
+ }
49
+ this.toolNamesByServer.delete(server);
50
+ }
51
+ resolve(toolName, params, serverHint) {
52
+ const candidates = this.toolsByName.get(toolName) ?? [];
53
+ if (candidates.length === 0) {
54
+ return null;
55
+ }
56
+ if (serverHint) {
57
+ const explicit = candidates.find((candidate) => candidate.server === serverHint);
58
+ if (!explicit) {
59
+ return null;
60
+ }
61
+ return { server: explicit.server, tool: explicit.tool };
62
+ }
63
+ if (candidates.length === 1) {
64
+ return { server: candidates[0].server, tool: candidates[0].tool };
65
+ }
66
+ const scored = candidates
67
+ .map((candidate) => ({
68
+ ...candidate,
69
+ score: this.scoreCandidate(candidate.server, candidate.inputSchema, params)
70
+ }))
71
+ .sort((a, b) => {
72
+ if (b.score !== a.score) {
73
+ return b.score - a.score;
74
+ }
75
+ return (this.basePriority.get(b.server) ?? BASE_PRIORITY_MIN) - (this.basePriority.get(a.server) ?? BASE_PRIORITY_MIN);
76
+ });
77
+ const first = scored[0];
78
+ const second = scored[1];
79
+ if (!second || (first.score - second.score) >= AUTO_RESOLVE_DELTA) {
80
+ return { server: first.server, tool: first.tool };
81
+ }
82
+ return {
83
+ ambiguous: true,
84
+ message: `Multiple servers provide '${toolName}'. Please specify server=`,
85
+ candidates: scored.map((candidate, index) => ({
86
+ server: candidate.server,
87
+ tool: candidate.tool,
88
+ score: Number(candidate.score.toFixed(2)),
89
+ ...(index === 0 ? { suggested: true } : {})
90
+ }))
91
+ };
92
+ }
93
+ recordCall(server, tool) {
94
+ this.recentCalls.push({ server, tool });
95
+ if (this.recentCalls.length > RECENT_CALL_LIMIT) {
96
+ this.recentCalls.shift();
97
+ }
98
+ }
99
+ getKnownToolNames() {
100
+ return [...this.toolsByName.keys()];
101
+ }
102
+ scoreCandidate(server, inputSchema, params) {
103
+ const base = this.basePriority.get(server) ?? BASE_PRIORITY_MIN;
104
+ const recency = this.wasUsedRecently(server) ? RECENCY_BOOST : 0;
105
+ const paramMatch = this.computeParamMatch(inputSchema, params) * PARAM_MATCH_WEIGHT;
106
+ return base + recency + paramMatch;
107
+ }
108
+ wasUsedRecently(server) {
109
+ return this.recentCalls.some((call) => call.server === server);
110
+ }
111
+ computeParamMatch(inputSchema, params) {
112
+ if (!params || typeof params !== "object") {
113
+ return 0;
114
+ }
115
+ const paramNames = Object.keys(params);
116
+ if (paramNames.length === 0) {
117
+ return 0;
118
+ }
119
+ const schemaProperties = inputSchema?.properties;
120
+ if (!schemaProperties || typeof schemaProperties !== "object") {
121
+ return 0;
122
+ }
123
+ const propertyNames = new Set(Object.keys(schemaProperties));
124
+ if (propertyNames.size === 0) {
125
+ return 0;
126
+ }
127
+ const matching = paramNames.filter((paramName) => propertyNames.has(paramName)).length;
128
+ return matching / paramNames.length;
129
+ }
130
+ }
@@ -30,6 +30,7 @@ export interface McpClientConfig {
30
30
  requestTimeoutMs?: number;
31
31
  routerIdleTimeoutMs?: number;
32
32
  routerMaxConcurrent?: number;
33
+ maxBatchSize?: number;
33
34
  schemaCompression?: {
34
35
  enabled?: boolean;
35
36
  maxDescriptionLength?: number;
@@ -48,6 +49,12 @@ export interface McpClientConfig {
48
49
  minCalls?: number;
49
50
  decayMs?: number;
50
51
  };
52
+ resultCache?: {
53
+ enabled?: boolean;
54
+ maxEntries?: number;
55
+ defaultTtlMs?: number;
56
+ cacheTtl?: Record<string, number>;
57
+ };
51
58
  }
52
59
  export interface McpTool {
53
60
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Standalone MCP server that multiplexes multiple MCP servers into one interface",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",