@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 +49 -0
- package/dist/src/config.js +1 -0
- package/dist/src/index.d.ts +6 -2
- package/dist/src/index.js +4 -1
- package/dist/src/mcp-router.d.ts +32 -0
- package/dist/src/mcp-router.js +228 -22
- package/dist/src/result-cache.d.ts +29 -0
- package/dist/src/result-cache.js +120 -0
- package/dist/src/standalone-server.js +22 -6
- package/dist/src/tool-resolution.d.ts +33 -0
- package/dist/src/tool-resolution.js +135 -0
- package/dist/src/transport-base.d.ts +8 -0
- package/dist/src/transport-base.js +20 -0
- package/dist/src/transport-sse.d.ts +2 -0
- package/dist/src/transport-sse.js +21 -6
- package/dist/src/transport-stdio.d.ts +1 -0
- package/dist/src/transport-stdio.js +20 -8
- package/dist/src/transport-streamable-http.d.ts +3 -0
- package/dist/src/transport-streamable-http.js +32 -14
- package/dist/src/types.d.ts +25 -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
|
@@ -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
|
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;
|
|
@@ -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;
|
package/dist/src/mcp-router.js
CHANGED
|
@@ -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 (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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(
|
|
202
|
+
await this.getToolList(targetServer);
|
|
126
203
|
}
|
|
127
204
|
catch (error) {
|
|
128
|
-
return this.error("connection_failed", `Failed to connect to ${
|
|
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(
|
|
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 '${
|
|
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[
|
|
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 '${
|
|
214
|
+
return this.error("unknown_tool", `Tool '${tool}' is not allowed on server '${targetServer}'`, state.toolNames);
|
|
138
215
|
}
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
+
}
|