@aiwerk/mcp-bridge 1.7.2 → 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 +69 -0
- package/dist/bin/validate-recipe.d.ts +6 -0
- package/dist/bin/validate-recipe.js +33 -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/dist/src/validate-recipe.d.ts +76 -0
- package/dist/src/validate-recipe.js +241 -0
- package/package.json +3 -2
- package/scripts/install-server.sh +96 -14
- package/servers/apify/recipe.json +41 -0
- package/servers/atlassian/recipe.json +60 -0
- package/servers/chrome-devtools/recipe.json +45 -0
- package/servers/github/recipe.json +55 -0
- package/servers/google-maps/recipe.json +48 -0
- package/servers/hetzner/recipe.json +49 -0
- package/servers/hostinger/recipe.json +47 -0
- package/servers/index.json +130 -120
- package/servers/linear/recipe.json +49 -0
- package/servers/miro/recipe.json +48 -0
- package/servers/notion/recipe.json +48 -0
- package/servers/stripe/recipe.json +48 -0
- package/servers/tavily/recipe.json +48 -0
- package/servers/todoist/recipe.json +48 -0
- package/servers/wise/recipe.json +49 -0
package/README.md
CHANGED
|
@@ -59,6 +59,26 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
## Recipe Spec v2
|
|
63
|
+
|
|
64
|
+
Bundled servers now ship with `recipe.json` using **Universal Recipe Spec v2.0**.
|
|
65
|
+
During install, MCP Bridge prefers `recipe.json` when present and falls back to legacy `config.json` (v1) for backwards compatibility.
|
|
66
|
+
|
|
67
|
+
- Spec: [`docs/universal-recipe-spec.md`](docs/universal-recipe-spec.md)
|
|
68
|
+
- Runtime compatibility: v1 and v2 are both supported
|
|
69
|
+
- Existing v1-only servers continue to work unchanged
|
|
70
|
+
|
|
71
|
+
For third-party recipe authors:
|
|
72
|
+
|
|
73
|
+
1. Author `recipe.json` per the spec above.
|
|
74
|
+
2. Validate your recipe before publishing:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx @aiwerk/mcp-bridge validate-recipe ./recipe.json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`config.json` (v1) remains supported, but `recipe.json` (v2) is the recommended format going forward.
|
|
81
|
+
|
|
62
82
|
## Use with Cursor / Windsurf
|
|
63
83
|
|
|
64
84
|
Add to your MCP config:
|
|
@@ -119,6 +139,7 @@ Config: `~/.mcp-bridge/config.json` | Secrets: `~/.mcp-bridge/.env`
|
|
|
119
139
|
"toolPrefix": true,
|
|
120
140
|
"connectionTimeoutMs": 5000,
|
|
121
141
|
"requestTimeoutMs": 60000,
|
|
142
|
+
"maxBatchSize": 10,
|
|
122
143
|
"schemaCompression": {
|
|
123
144
|
"enabled": true,
|
|
124
145
|
"maxDescriptionLength": 80
|
|
@@ -150,6 +171,27 @@ mcp(server="todoist", action="schema", tool="find-tasks")
|
|
|
150
171
|
|
|
151
172
|
Set `"enabled": false` to disable compression and return full descriptions.
|
|
152
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
|
+
|
|
153
195
|
### Intent Routing
|
|
154
196
|
|
|
155
197
|
Instead of specifying the exact server and tool, describe what you need:
|
|
@@ -180,6 +222,20 @@ The bridge uses vector embeddings to match your intent to the right server and t
|
|
|
180
222
|
- `minScore`: minimum confidence to return a match (0-1, default: 0.3)
|
|
181
223
|
- Index is built lazily on first `action=intent` call
|
|
182
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
|
+
|
|
183
239
|
### Security
|
|
184
240
|
|
|
185
241
|
Three layers of protection for tool results:
|
|
@@ -284,6 +340,19 @@ Returns promoted tools (sorted by frequency) and full usage stats. All tracking
|
|
|
284
340
|
|
|
285
341
|
**Router mode** — the agent calls `mcp(server="todoist", action="list")` to discover, then `mcp(server="todoist", tool="find-tasks", params={...})` to execute.
|
|
286
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
|
+
|
|
287
356
|
**Direct mode** — tools are registered as `todoist_find_tasks`, `github_list_repos`, etc.
|
|
288
357
|
|
|
289
358
|
### Transports
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for the Universal MCP Recipe Validator.
|
|
4
|
+
* Usage: npx tsx bin/validate-recipe.ts <path-to-recipe.json>
|
|
5
|
+
*/
|
|
6
|
+
import { validateRecipeFile, formatValidationResult } from "../src/validate-recipe.js";
|
|
7
|
+
async function main() {
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
10
|
+
console.log("Usage: validate-recipe <path-to-recipe.json>");
|
|
11
|
+
console.log("");
|
|
12
|
+
console.log("Validates a Universal MCP Recipe against spec v2.0.");
|
|
13
|
+
console.log("Exits 0 if valid, 1 if invalid.");
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
const filePath = args[0];
|
|
17
|
+
let result;
|
|
18
|
+
try {
|
|
19
|
+
result = await validateRecipeFile(filePath);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
console.error(`❌ Could not read file: ${filePath}`);
|
|
23
|
+
console.error(` ${e.message}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const output = formatValidationResult(filePath, result);
|
|
27
|
+
console.log(output);
|
|
28
|
+
process.exit(result.valid ? 0 : 1);
|
|
29
|
+
}
|
|
30
|
+
main().catch((e) => {
|
|
31
|
+
console.error("Unexpected error:", e);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
});
|
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
|
+
}
|