@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 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,6 @@
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
+ export {};
@@ -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
+ });
@@ -160,6 +160,7 @@ export function initConfigDir(logger) {
160
160
  requestTimeoutMs: 60000,
161
161
  routerIdleTimeoutMs: 600000,
162
162
  routerMaxConcurrent: 5,
163
+ maxBatchSize: 10,
163
164
  http: {
164
165
  auth: {
165
166
  type: "bearer",
@@ -4,6 +4,10 @@ export { SseTransport } from "./transport-sse.js";
4
4
  export { StreamableHttpTransport } from "./transport-streamable-http.js";
5
5
  export { McpRouter } from "./mcp-router.js";
6
6
  export type { RouterToolHint, RouterServerStatus, RouterDispatchResponse, RouterTransportRefs } from "./mcp-router.js";
7
+ export { ResultCache, createResultCacheKey, stableStringify } from "./result-cache.js";
8
+ export type { ResultCacheConfig, ResultCacheStats } from "./result-cache.js";
9
+ export { ToolResolver } from "./tool-resolution.js";
10
+ export type { ToolResolutionResult, ToolResolutionCandidate } from "./tool-resolution.js";
7
11
  export { convertJsonSchemaToTypeBox, createToolParameters, setTypeBoxLoader, setSchemaLogger } from "./schema-convert.js";
8
12
  export { initializeProtocol, fetchToolsList, PACKAGE_VERSION } from "./protocol.js";
9
13
  export { loadConfig, parseEnvFile, initConfigDir, getConfigDir } from "./config.js";
package/dist/src/index.js CHANGED
@@ -6,6 +6,9 @@ export { SseTransport } from "./transport-sse.js";
6
6
  export { StreamableHttpTransport } from "./transport-streamable-http.js";
7
7
  // Router
8
8
  export { McpRouter } from "./mcp-router.js";
9
+ // Result cache
10
+ export { ResultCache, createResultCacheKey, stableStringify } from "./result-cache.js";
11
+ export { ToolResolver } from "./tool-resolution.js";
9
12
  // Schema conversion
10
13
  export { convertJsonSchemaToTypeBox, createToolParameters, setTypeBoxLoader, setSchemaLogger } from "./schema-convert.js";
11
14
  // Protocol helpers
@@ -1,5 +1,16 @@
1
1
  import { McpClientConfig, McpServerConfig, McpTransport, Logger } from "./types.js";
2
2
  type RouterErrorCode = "unknown_server" | "unknown_tool" | "connection_failed" | "mcp_error" | "invalid_params";
3
+ interface RouterBatchResult {
4
+ server: string;
5
+ tool: string;
6
+ result?: any;
7
+ error?: {
8
+ error: RouterErrorCode;
9
+ message: string;
10
+ available?: string[];
11
+ code?: number;
12
+ };
13
+ }
3
14
  export interface RouterToolHint {
4
15
  name: string;
5
16
  description: string;
@@ -35,6 +46,9 @@ export type RouterDispatchResponse = {
35
46
  } | {
36
47
  action: "status";
37
48
  servers: RouterServerStatus[];
49
+ } | {
50
+ action: "batch";
51
+ results: RouterBatchResult[];
38
52
  } | {
39
53
  action: "promotions";
40
54
  promoted: Array<{
@@ -61,6 +75,15 @@ export type RouterDispatchResponse = {
61
75
  tool: string;
62
76
  score: number;
63
77
  }>;
78
+ } | {
79
+ ambiguous: true;
80
+ message: string;
81
+ candidates: Array<{
82
+ server: string;
83
+ tool: string;
84
+ score: number;
85
+ suggested?: true;
86
+ }>;
64
87
  } | {
65
88
  error: RouterErrorCode;
66
89
  message: string;
@@ -79,13 +102,17 @@ export declare class McpRouter {
79
102
  private readonly transportRefs;
80
103
  private readonly idleTimeoutMs;
81
104
  private readonly maxConcurrent;
105
+ private readonly resultCache;
106
+ private readonly maxBatchSize;
82
107
  private readonly states;
108
+ private readonly toolResolver;
83
109
  private intentRouter;
84
110
  private promotion;
85
111
  constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
86
112
  static generateDescription(servers: Record<string, McpServerConfig>): string;
87
113
  dispatch(server?: string, action?: string, tool?: string, params?: any): Promise<RouterDispatchResponse>;
88
114
  getToolList(server: string): Promise<RouterToolHint[]>;
115
+ private primeToolResolutionIndex;
89
116
  private resolveIntent;
90
117
  private getStatus;
91
118
  getPromotedTools(): Array<{
@@ -7,8 +7,11 @@ import { IntentRouter } from "./intent-router.js";
7
7
  import { createEmbeddingProvider } from "./embeddings.js";
8
8
  import { isToolAllowed, processResult } from "./security.js";
9
9
  import { AdaptivePromotion } from "./adaptive-promotion.js";
10
+ import { ResultCache, createResultCacheKey } from "./result-cache.js";
11
+ import { ToolResolver } from "./tool-resolution.js";
10
12
  const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
11
13
  const DEFAULT_MAX_CONCURRENT = 5;
14
+ const DEFAULT_MAX_BATCH_SIZE = 10;
12
15
  export class McpRouter {
13
16
  servers;
14
17
  clientConfig;
@@ -16,7 +19,10 @@ export class McpRouter {
16
19
  transportRefs;
17
20
  idleTimeoutMs;
18
21
  maxConcurrent;
22
+ resultCache;
23
+ maxBatchSize;
19
24
  states = new Map();
25
+ toolResolver;
20
26
  intentRouter = null;
21
27
  promotion = null;
22
28
  constructor(servers, clientConfig, logger, transportRefs) {
@@ -30,6 +36,15 @@ export class McpRouter {
30
36
  };
31
37
  this.idleTimeoutMs = clientConfig.routerIdleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
32
38
  this.maxConcurrent = clientConfig.routerMaxConcurrent ?? DEFAULT_MAX_CONCURRENT;
39
+ this.resultCache = clientConfig.resultCache?.enabled
40
+ ? new ResultCache({
41
+ maxEntries: clientConfig.resultCache.maxEntries,
42
+ defaultTtlMs: clientConfig.resultCache.defaultTtlMs,
43
+ cacheTtl: clientConfig.resultCache.cacheTtl
44
+ })
45
+ : null;
46
+ this.maxBatchSize = clientConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
47
+ this.toolResolver = new ToolResolver(Object.keys(servers));
33
48
  if (clientConfig.adaptivePromotion?.enabled) {
34
49
  this.promotion = new AdaptivePromotion(clientConfig.adaptivePromotion, logger);
35
50
  }
@@ -45,7 +60,7 @@ export class McpRouter {
45
60
  return desc ? `${name} (${desc})` : name;
46
61
  })
47
62
  .join(", ");
48
- return `Call any MCP server tool. Servers: ${serverList}. Use action='list' to discover tools and required parameters, action='call' to execute a tool, action='refresh' to clear cache and re-discover tools, and action='status' to check server connection states. If the user mentions a specific tool by name, the call action auto-connects and works without listing first.`;
63
+ return `Call any MCP server tool. Servers: ${serverList}. Use action='list' to discover tools and required parameters, action='call' to execute a tool, action='batch' to execute multiple calls in one round-trip, action='refresh' to clear cache and re-discover tools, and action='status' to check server connection states. If the user mentions a specific tool by name, the call action auto-connects and works without listing first.`;
49
64
  }
50
65
  async dispatch(server, action = "call", tool, params) {
51
66
  try {
@@ -66,13 +81,45 @@ export class McpRouter {
66
81
  }
67
82
  return this.resolveIntent(intent);
68
83
  }
69
- if (!server) {
70
- return this.error("invalid_params", "server is required");
71
- }
72
- if (!this.servers[server]) {
73
- return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
84
+ if (normalizedAction === "batch") {
85
+ const calls = params?.calls;
86
+ if (!Array.isArray(calls) || calls.length === 0) {
87
+ return this.error("invalid_params", "calls must be a non-empty array for action=batch");
88
+ }
89
+ if (calls.length > this.maxBatchSize) {
90
+ return this.error("invalid_params", `batch size exceeds maxBatchSize (${this.maxBatchSize})`);
91
+ }
92
+ const results = await Promise.all(calls.map(async (call) => {
93
+ const callServer = typeof call?.server === "string" ? call.server : "";
94
+ const callTool = typeof call?.tool === "string" ? call.tool : "";
95
+ const response = await this.dispatch(callServer, "call", callTool, call?.params);
96
+ if ("error" in response) {
97
+ return {
98
+ server: callServer,
99
+ tool: callTool,
100
+ error: {
101
+ error: response.error,
102
+ message: response.message,
103
+ ...(response.available ? { available: response.available } : {}),
104
+ ...(typeof response.code === "number" ? { code: response.code } : {})
105
+ }
106
+ };
107
+ }
108
+ return {
109
+ server: callServer,
110
+ tool: callTool,
111
+ result: "result" in response ? response.result : response
112
+ };
113
+ }));
114
+ return { action: "batch", results };
74
115
  }
75
116
  if (normalizedAction === "list") {
117
+ if (!server) {
118
+ return this.error("invalid_params", "server is required for action=list");
119
+ }
120
+ if (!this.servers[server]) {
121
+ return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
122
+ }
76
123
  try {
77
124
  const tools = await this.getToolList(server);
78
125
  return { server, action: "list", tools };
@@ -82,6 +129,12 @@ export class McpRouter {
82
129
  }
83
130
  }
84
131
  if (normalizedAction === "schema") {
132
+ if (!server) {
133
+ return this.error("invalid_params", "server is required for action=schema");
134
+ }
135
+ if (!this.servers[server]) {
136
+ return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
137
+ }
85
138
  if (!tool) {
86
139
  return this.error("invalid_params", "tool is required for action=schema");
87
140
  }
@@ -99,11 +152,19 @@ export class McpRouter {
99
152
  return { server, action: "schema", tool, schema: fullTool.inputSchema, description: fullTool.description };
100
153
  }
101
154
  if (normalizedAction === "refresh") {
155
+ if (!server) {
156
+ return this.error("invalid_params", "server is required for action=refresh");
157
+ }
158
+ if (!this.servers[server]) {
159
+ return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
160
+ }
161
+ this.resultCache?.invalidate();
102
162
  try {
103
163
  const state = await this.ensureConnected(server);
104
164
  state.toolsCache = undefined;
105
165
  state.fullToolsMap = undefined;
106
166
  state.toolNames = [];
167
+ this.toolResolver.removeServer(server);
107
168
  // Clear intent index so it re-indexes on next intent query
108
169
  if (this.intentRouter) {
109
170
  this.intentRouter.clearIndex();
@@ -116,25 +177,54 @@ export class McpRouter {
116
177
  }
117
178
  }
118
179
  if (normalizedAction !== "call") {
119
- return this.error("invalid_params", `action must be one of: list, call, refresh, schema, intent`);
180
+ return this.error("invalid_params", `action must be one of: list, call, batch, refresh, schema, intent`);
120
181
  }
121
182
  if (!tool) {
122
183
  return this.error("invalid_params", "tool is required for action=call");
123
184
  }
185
+ let targetServer = server;
186
+ if (!targetServer) {
187
+ await this.primeToolResolutionIndex();
188
+ const resolution = this.toolResolver.resolve(tool, params ?? {});
189
+ if (!resolution) {
190
+ return this.error("unknown_tool", `Tool '${tool}' was not found on any connected server`, this.toolResolver.getKnownToolNames());
191
+ }
192
+ if ("ambiguous" in resolution) {
193
+ return resolution;
194
+ }
195
+ targetServer = resolution.server;
196
+ }
197
+ if (!this.servers[targetServer]) {
198
+ return this.error("unknown_server", `Server '${targetServer}' not found`, Object.keys(this.servers));
199
+ }
124
200
  try {
125
- await this.getToolList(server);
201
+ await this.getToolList(targetServer);
126
202
  }
127
203
  catch (error) {
128
- return this.error("connection_failed", `Failed to connect to ${server}: ${error instanceof Error ? error.message : String(error)}`);
204
+ return this.error("connection_failed", `Failed to connect to ${targetServer}: ${error instanceof Error ? error.message : String(error)}`);
129
205
  }
130
- const state = this.states.get(server);
206
+ const state = this.states.get(targetServer);
131
207
  if (!state.toolNames.includes(tool)) {
132
- return this.error("unknown_tool", `Tool '${tool}' not found on server '${server}'`, state.toolNames);
208
+ return this.error("unknown_tool", `Tool '${tool}' not found on server '${targetServer}'`, state.toolNames);
133
209
  }
134
210
  // Defense in depth: double-check tool filter
135
- const serverConfig = this.servers[server];
211
+ const serverConfig = this.servers[targetServer];
136
212
  if (!isToolAllowed(tool, serverConfig)) {
137
- return this.error("unknown_tool", `Tool '${tool}' is not allowed on server '${server}'`, state.toolNames);
213
+ return this.error("unknown_tool", `Tool '${tool}' is not allowed on server '${targetServer}'`, state.toolNames);
214
+ }
215
+ server = targetServer;
216
+ const cacheKey = this.resultCache
217
+ ? createResultCacheKey(server, tool, params ?? {})
218
+ : null;
219
+ if (this.resultCache && cacheKey) {
220
+ const cachedResult = this.resultCache.get(cacheKey);
221
+ if (cachedResult !== undefined) {
222
+ if (this.promotion) {
223
+ this.promotion.recordCall(server, tool);
224
+ }
225
+ this.toolResolver.recordCall(server, tool);
226
+ return { server, action: "call", tool, result: cachedResult };
227
+ }
138
228
  }
139
229
  this.markUsed(server);
140
230
  const response = await state.transport.sendRequest({
@@ -152,8 +242,12 @@ export class McpRouter {
152
242
  if (this.promotion) {
153
243
  this.promotion.recordCall(server, tool);
154
244
  }
245
+ this.toolResolver.recordCall(server, tool);
155
246
  // Security pipeline: truncate → sanitize → trust-tag
156
247
  const result = processResult(response.result, server, serverConfig, this.clientConfig);
248
+ if (this.resultCache && cacheKey) {
249
+ this.resultCache.set(cacheKey, result);
250
+ }
157
251
  return { server, action: "call", tool, result };
158
252
  }
159
253
  catch (error) {
@@ -175,6 +269,10 @@ export class McpRouter {
175
269
  state.toolNames = tools.map((tool) => tool.name);
176
270
  // Store full tool metadata for action=schema
177
271
  state.fullToolsMap = new Map(tools.map((tool) => [tool.name, { description: tool.description || "", inputSchema: tool.inputSchema }]));
272
+ this.toolResolver.registerServerTools(server, tools.map((tool) => ({
273
+ name: tool.name,
274
+ inputSchema: tool.inputSchema
275
+ })));
178
276
  const compressionEnabled = this.clientConfig.schemaCompression?.enabled ?? true;
179
277
  const maxLen = this.clientConfig.schemaCompression?.maxDescriptionLength ?? 80;
180
278
  state.toolsCache = tools.map((tool) => ({
@@ -187,6 +285,17 @@ export class McpRouter {
187
285
  this.markUsed(server);
188
286
  return state.toolsCache;
189
287
  }
288
+ async primeToolResolutionIndex() {
289
+ for (const serverName of Object.keys(this.servers)) {
290
+ try {
291
+ await this.getToolList(serverName);
292
+ }
293
+ catch (error) {
294
+ this.toolResolver.removeServer(serverName);
295
+ this.logger.warn(`[mcp-bridge] Tool resolution: failed to load tools from ${serverName}:`, error);
296
+ }
297
+ }
298
+ }
190
299
  async resolveIntent(intent) {
191
300
  try {
192
301
  // Lazily create the intent router
@@ -354,6 +463,8 @@ export class McpRouter {
354
463
  state.toolsCache = undefined;
355
464
  state.fullToolsMap = undefined;
356
465
  state.toolNames = [];
466
+ this.toolResolver.removeServer(server);
467
+ this.resultCache?.invalidate(`${server}:`);
357
468
  }
358
469
  markUsed(server) {
359
470
  const state = this.states.get(server);
@@ -382,6 +493,8 @@ export class McpRouter {
382
493
  state.toolsCache = undefined;
383
494
  state.fullToolsMap = undefined;
384
495
  state.toolNames = [];
496
+ this.toolResolver.removeServer(serverName);
497
+ this.resultCache?.invalidate(`${serverName}:`);
385
498
  };
386
499
  if (serverConfig.transport === "sse") {
387
500
  return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected);
@@ -0,0 +1,29 @@
1
+ export interface ResultCacheConfig {
2
+ maxEntries?: number;
3
+ defaultTtlMs?: number;
4
+ cacheTtl?: Record<string, number>;
5
+ }
6
+ export interface ResultCacheStats {
7
+ hits: number;
8
+ misses: number;
9
+ evictions: number;
10
+ size: number;
11
+ }
12
+ export declare function stableStringify(value: unknown): string;
13
+ export declare function createResultCacheKey(server: string, tool: string, params: unknown): string;
14
+ export declare class ResultCache {
15
+ private readonly maxEntries;
16
+ private readonly defaultTtlMs;
17
+ private readonly cacheTtl;
18
+ private readonly entries;
19
+ private hits;
20
+ private misses;
21
+ private evictions;
22
+ constructor(config?: ResultCacheConfig);
23
+ get(key: string): unknown;
24
+ set(key: string, value: unknown, ttlMs?: number): void;
25
+ invalidate(pattern?: string | RegExp): number;
26
+ stats(): ResultCacheStats;
27
+ private resolveTtlMsForKey;
28
+ private trimToCapacity;
29
+ }
@@ -0,0 +1,120 @@
1
+ const DEFAULT_MAX_ENTRIES = 100;
2
+ const DEFAULT_TTL_MS = 300_000;
3
+ function normalizeForStableJson(value, inArray) {
4
+ if (value === undefined || typeof value === "function" || typeof value === "symbol") {
5
+ return inArray ? null : undefined;
6
+ }
7
+ if (value === null || typeof value !== "object") {
8
+ return value;
9
+ }
10
+ if (Array.isArray(value)) {
11
+ return value.map((item) => normalizeForStableJson(item, true));
12
+ }
13
+ const obj = value;
14
+ const normalized = {};
15
+ const keys = Object.keys(obj).sort();
16
+ for (const key of keys) {
17
+ const normalizedValue = normalizeForStableJson(obj[key], false);
18
+ if (normalizedValue !== undefined) {
19
+ normalized[key] = normalizedValue;
20
+ }
21
+ }
22
+ return normalized;
23
+ }
24
+ export function stableStringify(value) {
25
+ const normalized = normalizeForStableJson(value, false);
26
+ const serialized = JSON.stringify(normalized);
27
+ return serialized === undefined ? "undefined" : serialized;
28
+ }
29
+ export function createResultCacheKey(server, tool, params) {
30
+ return `${server}:${tool}:${stableStringify(params)}`;
31
+ }
32
+ export class ResultCache {
33
+ maxEntries;
34
+ defaultTtlMs;
35
+ cacheTtl;
36
+ entries = new Map();
37
+ hits = 0;
38
+ misses = 0;
39
+ evictions = 0;
40
+ constructor(config = {}) {
41
+ this.maxEntries = config.maxEntries ?? DEFAULT_MAX_ENTRIES;
42
+ this.defaultTtlMs = config.defaultTtlMs ?? DEFAULT_TTL_MS;
43
+ this.cacheTtl = config.cacheTtl ?? {};
44
+ }
45
+ get(key) {
46
+ const entry = this.entries.get(key);
47
+ if (!entry) {
48
+ this.misses += 1;
49
+ return undefined;
50
+ }
51
+ if (entry.expiresAt <= Date.now()) {
52
+ this.entries.delete(key);
53
+ this.misses += 1;
54
+ return undefined;
55
+ }
56
+ this.entries.delete(key);
57
+ this.entries.set(key, entry);
58
+ this.hits += 1;
59
+ return entry.value;
60
+ }
61
+ set(key, value, ttlMs) {
62
+ const effectiveTtlMs = ttlMs ?? this.resolveTtlMsForKey(key);
63
+ const entry = {
64
+ value,
65
+ expiresAt: Date.now() + effectiveTtlMs
66
+ };
67
+ if (this.entries.has(key)) {
68
+ this.entries.delete(key);
69
+ }
70
+ this.entries.set(key, entry);
71
+ this.trimToCapacity();
72
+ }
73
+ invalidate(pattern) {
74
+ if (!pattern) {
75
+ const size = this.entries.size;
76
+ this.entries.clear();
77
+ return size;
78
+ }
79
+ let removed = 0;
80
+ for (const key of this.entries.keys()) {
81
+ const matches = typeof pattern === "string"
82
+ ? key.includes(pattern)
83
+ : pattern.test(key);
84
+ if (matches) {
85
+ this.entries.delete(key);
86
+ removed += 1;
87
+ }
88
+ }
89
+ return removed;
90
+ }
91
+ stats() {
92
+ return {
93
+ hits: this.hits,
94
+ misses: this.misses,
95
+ evictions: this.evictions,
96
+ size: this.entries.size
97
+ };
98
+ }
99
+ resolveTtlMsForKey(key) {
100
+ const firstColon = key.indexOf(":");
101
+ const secondColon = key.indexOf(":", firstColon + 1);
102
+ if (firstColon === -1 || secondColon === -1) {
103
+ return this.defaultTtlMs;
104
+ }
105
+ const server = key.slice(0, firstColon);
106
+ const tool = key.slice(firstColon + 1, secondColon);
107
+ const override = this.cacheTtl[`${server}:${tool}`];
108
+ return override ?? this.defaultTtlMs;
109
+ }
110
+ trimToCapacity() {
111
+ while (this.entries.size > this.maxEntries) {
112
+ const oldestKey = this.entries.keys().next().value;
113
+ if (oldestKey === undefined) {
114
+ break;
115
+ }
116
+ this.entries.delete(oldestKey);
117
+ this.evictions += 1;
118
+ }
119
+ }
120
+ }