@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 +49 -0
- package/dist/src/config.js +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +3 -0
- package/dist/src/mcp-router.d.ts +27 -0
- package/dist/src/mcp-router.js +126 -13
- package/dist/src/result-cache.d.ts +29 -0
- package/dist/src/result-cache.js +120 -0
- package/dist/src/standalone-server.js +21 -5
- package/dist/src/tool-resolution.d.ts +32 -0
- package/dist/src/tool-resolution.js +130 -0
- package/dist/src/types.d.ts +7 -0
- package/package.json +1 -1
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
|
package/dist/src/config.js
CHANGED
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
package/dist/src/mcp-router.d.ts
CHANGED
|
@@ -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<{
|
package/dist/src/mcp-router.js
CHANGED
|
@@ -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 (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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(
|
|
201
|
+
await this.getToolList(targetServer);
|
|
126
202
|
}
|
|
127
203
|
catch (error) {
|
|
128
|
-
return this.error("connection_failed", `Failed to connect to ${
|
|
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(
|
|
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 '${
|
|
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[
|
|
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 '${
|
|
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: [
|
|
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
|
|
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
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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;
|