@aiwerk/mcp-bridge 1.5.0 → 1.6.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
@@ -14,9 +14,12 @@ Most AI agents connect to MCP servers one-by-one. With 10+ servers, that's 10+ c
14
14
 
15
15
  **MCP Bridge** solves this:
16
16
  - **Router mode**: all servers behind one `mcp` meta-tool (~99% token reduction)
17
+ - **Intent routing**: say what you need in plain language, the bridge finds the right tool
18
+ - **Schema compression**: tool descriptions compressed ~57%, full schema on demand
19
+ - **Security layer**: trust levels, tool deny/allow lists, result size limits
17
20
  - **Direct mode**: all tools registered individually with automatic prefixing
18
21
  - **3 transports**: stdio, SSE, streamable-http
19
- - **Built-in catalog**: install popular servers with one command
22
+ - **Built-in catalog**: 14 pre-configured servers, install with one command
20
23
  - **Zero config secrets in files**: `${ENV_VAR}` resolution from `.env`
21
24
 
22
25
  ## Install
@@ -147,6 +150,131 @@ mcp(server="todoist", action="schema", tool="find-tasks")
147
150
 
148
151
  Set `"enabled": false` to disable compression and return full descriptions.
149
152
 
153
+ ### Intent Routing
154
+
155
+ Instead of specifying the exact server and tool, describe what you need:
156
+
157
+ ```
158
+ mcp(action="intent", intent="find my tasks for today")
159
+ ```
160
+
161
+ The bridge uses vector embeddings to match your intent to the right server and tool automatically. Returns the best match with a confidence score and alternatives.
162
+
163
+ **Embedding providers** (configured via `intentRouting.embedding`):
164
+
165
+ | Provider | Config | Requires |
166
+ |----------|--------|----------|
167
+ | `gemini` (default for auto) | `GEMINI_API_KEY` in `.env` | Free tier available |
168
+ | `openai` | `OPENAI_API_KEY` in `.env` | Paid API |
169
+ | `ollama` | Local Ollama running | No API key |
170
+ | `keyword` | Nothing | Offline fallback, less accurate |
171
+
172
+ ```json
173
+ "intentRouting": {
174
+ "embedding": "auto",
175
+ "minScore": 0.3
176
+ }
177
+ ```
178
+
179
+ - `auto` (default): tries gemini, openai, ollama, then keyword - in order of availability
180
+ - `minScore`: minimum confidence to return a match (0-1, default: 0.3)
181
+ - Index is built lazily on first `action=intent` call
182
+
183
+ ### Security
184
+
185
+ Three layers of protection for tool results:
186
+
187
+ #### Trust Levels
188
+
189
+ Per-server control over how results are passed to the agent:
190
+
191
+ ```json
192
+ "servers": {
193
+ "my-trusted-server": {
194
+ "trust": "trusted"
195
+ },
196
+ "unknown-server": {
197
+ "trust": "untrusted"
198
+ },
199
+ "sketchy-server": {
200
+ "trust": "sanitize"
201
+ }
202
+ }
203
+ ```
204
+
205
+ | Level | Behavior |
206
+ |-------|----------|
207
+ | `trusted` (default) | Results pass through as-is |
208
+ | `untrusted` | Results tagged with `_trust: "untrusted"` metadata |
209
+ | `sanitize` | HTML tags stripped, prompt injection patterns removed |
210
+
211
+ #### Tool Filter
212
+
213
+ Control which tools are visible and callable per server:
214
+
215
+ ```json
216
+ "servers": {
217
+ "github": {
218
+ "toolFilter": {
219
+ "deny": ["delete_repository"],
220
+ "allow": ["list_repos", "create_issue", "search_code"]
221
+ }
222
+ }
223
+ }
224
+ ```
225
+
226
+ - `deny`: block specific dangerous tools
227
+ - `allow`: whitelist mode - only these tools are visible
228
+ - If both: allowed tools minus denied ones
229
+ - Applied in both tool listing and execution (defense in depth)
230
+
231
+ ### Adaptive Promotion
232
+
233
+ Frequently used tools can be automatically "promoted" to standalone tools alongside the `mcp` meta-tool. The promotion system tracks usage and reports which tools qualify — the host environment (e.g., OpenClaw plugin) decides how to register them.
234
+
235
+ ```json
236
+ "adaptivePromotion": {
237
+ "enabled": true,
238
+ "maxPromoted": 10,
239
+ "minCalls": 3,
240
+ "windowMs": 86400000,
241
+ "decayMs": 172800000
242
+ }
243
+ ```
244
+
245
+ | Option | Default | Description |
246
+ |--------|---------|-------------|
247
+ | `enabled` | `false` | Opt-in: must be explicitly enabled |
248
+ | `maxPromoted` | `10` | Maximum number of tools to promote |
249
+ | `minCalls` | `3` | Minimum calls within window to qualify |
250
+ | `windowMs` | `86400000` (24h) | Time window for counting calls |
251
+ | `decayMs` | `172800000` (48h) | Demote tools with no calls in this period |
252
+
253
+ Use `action="promotions"` to check current promotion state:
254
+ ```
255
+ mcp(action="promotions")
256
+ ```
257
+
258
+ Returns promoted tools (sorted by frequency) and full usage stats. All tracking is in-memory — promotion rebuilds naturally from usage after restart.
259
+
260
+ #### Max Result Size
261
+
262
+ Prevent oversized responses from consuming your context:
263
+
264
+ ```json
265
+ {
266
+ "maxResultChars": 50000,
267
+ "servers": {
268
+ "verbose-server": {
269
+ "maxResultChars": 10000
270
+ }
271
+ }
272
+ }
273
+ ```
274
+
275
+ - Global default + per-server override
276
+ - Truncated results include `_truncated: true` and `_originalLength`
277
+
150
278
  ### Modes
151
279
 
152
280
  | Mode | Tools exposed | Best for |
@@ -248,17 +376,18 @@ const result = await router.dispatch("todoist", "call", "find-tasks", { query: "
248
376
  ## Architecture
249
377
 
250
378
  ```
251
- ┌─────────────────┐ ┌──────────────────────────────────────┐
252
- │ Claude Desktop │ │ MCP Bridge
253
- │ Cursor │◄───►│
254
- │ Windsurf │stdio│ ┌─────────┐ ┌──────────────────┐
255
- │ OpenClaw │ │ │ Router / │ │ Backend servers: │ │
256
- │ Any MCP client │ │ │ Direct │──│ • todoist (stdio) │ │
257
- └─────────────────┘ │ │ mode │ │ github (stdio) │ │
258
- └─────────┘ │ • notion (stdio) │ │
259
- stripe (sse) │ │
260
- └──────────────────┘
261
- └──────────────────────────────────────┘
379
+ ┌─────────────────┐ ┌──────────────────────────────────────────────┐
380
+ │ Claude Desktop │ │ MCP Bridge
381
+ │ Cursor │◄───►│
382
+ │ Windsurf │stdio│ ┌──────────┐ ┌────────┐ ┌────────────┐
383
+ │ OpenClaw │ SSE │ │ Router │ │Security│Backend
384
+ │ Any MCP client │ HTTP│ │ Intent │─►│ Trust │─►│ servers: │ │
385
+ └─────────────────┘ │ │ Schema │ │ Filter │ │ todoist │ │
386
+ Compress Limit │ │ github │ │
387
+ └──────────┘ └────────┘ notion │ │
388
+ • stripe
389
+ │ └────────────┘ │
390
+ └──────────────────────────────────────────────┘
262
391
  ```
263
392
 
264
393
  ## Related
@@ -0,0 +1,33 @@
1
+ import type { Logger } from "./types.js";
2
+ export interface PromotionConfig {
3
+ enabled?: boolean;
4
+ maxPromoted?: number;
5
+ windowMs?: number;
6
+ minCalls?: number;
7
+ decayMs?: number;
8
+ }
9
+ export declare class AdaptivePromotion {
10
+ private readonly enabled;
11
+ private readonly maxPromoted;
12
+ private readonly windowMs;
13
+ private readonly minCalls;
14
+ private readonly decayMs;
15
+ private readonly logger;
16
+ private readonly usage;
17
+ constructor(config: PromotionConfig, logger: Logger);
18
+ private key;
19
+ recordCall(server: string, tool: string): void;
20
+ getPromotedTools(): Array<{
21
+ server: string;
22
+ tool: string;
23
+ callCount: number;
24
+ }>;
25
+ isPromoted(server: string, tool: string): boolean;
26
+ getStats(): Array<{
27
+ server: string;
28
+ tool: string;
29
+ callCount: number;
30
+ lastCall: number;
31
+ }>;
32
+ private cleanup;
33
+ }
@@ -0,0 +1,95 @@
1
+ const DEFAULT_WINDOW_MS = 86_400_000; // 24h
2
+ const DEFAULT_DECAY_MS = 172_800_000; // 48h
3
+ const DEFAULT_MAX_PROMOTED = 10;
4
+ const DEFAULT_MIN_CALLS = 3;
5
+ export class AdaptivePromotion {
6
+ enabled;
7
+ maxPromoted;
8
+ windowMs;
9
+ minCalls;
10
+ decayMs;
11
+ logger;
12
+ usage = new Map();
13
+ constructor(config, logger) {
14
+ this.enabled = config.enabled ?? false;
15
+ this.maxPromoted = config.maxPromoted ?? DEFAULT_MAX_PROMOTED;
16
+ this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
17
+ this.minCalls = config.minCalls ?? DEFAULT_MIN_CALLS;
18
+ this.decayMs = config.decayMs ?? DEFAULT_DECAY_MS;
19
+ this.logger = logger;
20
+ }
21
+ key(server, tool) {
22
+ return `${server}::${tool}`;
23
+ }
24
+ recordCall(server, tool) {
25
+ if (!this.enabled)
26
+ return;
27
+ const k = this.key(server, tool);
28
+ let entry = this.usage.get(k);
29
+ if (!entry) {
30
+ entry = { server, tool, callTimestamps: [] };
31
+ this.usage.set(k, entry);
32
+ }
33
+ entry.callTimestamps.push(Date.now());
34
+ this.cleanup();
35
+ }
36
+ getPromotedTools() {
37
+ if (!this.enabled)
38
+ return [];
39
+ const now = Date.now();
40
+ const cutoff = now - this.windowMs;
41
+ const candidates = [];
42
+ for (const entry of this.usage.values()) {
43
+ const recentCalls = entry.callTimestamps.filter(t => t > cutoff);
44
+ if (recentCalls.length >= this.minCalls) {
45
+ candidates.push({
46
+ server: entry.server,
47
+ tool: entry.tool,
48
+ callCount: recentCalls.length
49
+ });
50
+ }
51
+ }
52
+ candidates.sort((a, b) => b.callCount - a.callCount);
53
+ return candidates.slice(0, this.maxPromoted);
54
+ }
55
+ isPromoted(server, tool) {
56
+ if (!this.enabled)
57
+ return false;
58
+ return this.getPromotedTools().some(p => p.server === server && p.tool === tool);
59
+ }
60
+ getStats() {
61
+ if (!this.enabled)
62
+ return [];
63
+ const now = Date.now();
64
+ const cutoff = now - this.windowMs;
65
+ const stats = [];
66
+ for (const entry of this.usage.values()) {
67
+ const recentCalls = entry.callTimestamps.filter(t => t > cutoff);
68
+ if (recentCalls.length > 0) {
69
+ stats.push({
70
+ server: entry.server,
71
+ tool: entry.tool,
72
+ callCount: recentCalls.length,
73
+ lastCall: Math.max(...entry.callTimestamps)
74
+ });
75
+ }
76
+ }
77
+ return stats;
78
+ }
79
+ cleanup() {
80
+ const now = Date.now();
81
+ const windowCutoff = now - this.windowMs;
82
+ const decayCutoff = now - this.decayMs;
83
+ for (const [k, entry] of this.usage.entries()) {
84
+ // Remove timestamps older than the window
85
+ entry.callTimestamps = entry.callTimestamps.filter(t => t > windowCutoff);
86
+ // Remove entire entry if no calls within decay period
87
+ const lastCall = entry.callTimestamps.length > 0
88
+ ? Math.max(...entry.callTimestamps)
89
+ : 0;
90
+ if (lastCall === 0 || lastCall < decayCutoff) {
91
+ this.usage.delete(k);
92
+ }
93
+ }
94
+ }
95
+ }
@@ -35,6 +35,19 @@ export type RouterDispatchResponse = {
35
35
  } | {
36
36
  action: "status";
37
37
  servers: RouterServerStatus[];
38
+ } | {
39
+ action: "promotions";
40
+ promoted: Array<{
41
+ server: string;
42
+ tool: string;
43
+ callCount: number;
44
+ }>;
45
+ stats: Array<{
46
+ server: string;
47
+ tool: string;
48
+ callCount: number;
49
+ lastCall: string;
50
+ }>;
38
51
  } | {
39
52
  action: "intent";
40
53
  intent: string;
@@ -68,12 +81,20 @@ export declare class McpRouter {
68
81
  private readonly maxConcurrent;
69
82
  private readonly states;
70
83
  private intentRouter;
84
+ private readonly promotion;
71
85
  constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
72
86
  static generateDescription(servers: Record<string, McpServerConfig>): string;
73
87
  dispatch(server?: string, action?: string, tool?: string, params?: any): Promise<RouterDispatchResponse>;
74
88
  getToolList(server: string): Promise<RouterToolHint[]>;
75
89
  private resolveIntent;
76
90
  private getStatus;
91
+ getPromotedTools(): Array<{
92
+ server: string;
93
+ tool: string;
94
+ toolHint: RouterToolHint;
95
+ inputSchema: any;
96
+ }>;
97
+ private getPromotionStats;
77
98
  disconnectAll(): Promise<void>;
78
99
  private ensureConnected;
79
100
  private enforceMaxConcurrent;
@@ -6,6 +6,7 @@ import { compressDescription } from "./schema-compression.js";
6
6
  import { IntentRouter } from "./intent-router.js";
7
7
  import { createEmbeddingProvider } from "./embeddings.js";
8
8
  import { isToolAllowed, processResult } from "./security.js";
9
+ import { AdaptivePromotion } from "./adaptive-promotion.js";
9
10
  const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
10
11
  const DEFAULT_MAX_CONCURRENT = 5;
11
12
  export class McpRouter {
@@ -17,6 +18,7 @@ export class McpRouter {
17
18
  maxConcurrent;
18
19
  states = new Map();
19
20
  intentRouter = null;
21
+ promotion = null;
20
22
  constructor(servers, clientConfig, logger, transportRefs) {
21
23
  this.servers = servers;
22
24
  this.clientConfig = clientConfig;
@@ -28,6 +30,9 @@ export class McpRouter {
28
30
  };
29
31
  this.idleTimeoutMs = clientConfig.routerIdleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
30
32
  this.maxConcurrent = clientConfig.routerMaxConcurrent ?? DEFAULT_MAX_CONCURRENT;
33
+ if (clientConfig.adaptivePromotion?.enabled) {
34
+ this.promotion = new AdaptivePromotion(clientConfig.adaptivePromotion, logger);
35
+ }
31
36
  }
32
37
  static generateDescription(servers) {
33
38
  const serverNames = Object.keys(servers);
@@ -49,6 +54,10 @@ export class McpRouter {
49
54
  if (normalizedAction === "status") {
50
55
  return this.getStatus();
51
56
  }
57
+ // Promotions action: return promotion stats
58
+ if (normalizedAction === "promotions") {
59
+ return this.getPromotionStats();
60
+ }
52
61
  // Intent action: find server+tool from natural language
53
62
  if (normalizedAction === "intent") {
54
63
  const intent = params?.intent || tool;
@@ -139,6 +148,10 @@ export class McpRouter {
139
148
  if (response.error) {
140
149
  return this.error("mcp_error", response.error.message, undefined, response.error.code);
141
150
  }
151
+ // Record usage for adaptive promotion
152
+ if (this.promotion) {
153
+ this.promotion.recordCall(server, tool);
154
+ }
142
155
  // Security pipeline: truncate → sanitize → trust-tag
143
156
  const result = processResult(response.result, server, serverConfig, this.clientConfig);
144
157
  return { server, action: "call", tool, result };
@@ -237,6 +250,37 @@ export class McpRouter {
237
250
  });
238
251
  return { action: "status", servers: serverStatuses };
239
252
  }
253
+ getPromotedTools() {
254
+ if (!this.promotion)
255
+ return [];
256
+ const promoted = this.promotion.getPromotedTools();
257
+ const result = [];
258
+ for (const p of promoted) {
259
+ const state = this.states.get(p.server);
260
+ const fullTool = state?.fullToolsMap?.get(p.tool);
261
+ const hint = state?.toolsCache?.find(t => t.name === p.tool);
262
+ if (fullTool && hint) {
263
+ result.push({
264
+ server: p.server,
265
+ tool: p.tool,
266
+ toolHint: hint,
267
+ inputSchema: fullTool.inputSchema
268
+ });
269
+ }
270
+ }
271
+ return result;
272
+ }
273
+ getPromotionStats() {
274
+ if (!this.promotion) {
275
+ return { action: "promotions", promoted: [], stats: [] };
276
+ }
277
+ const promoted = this.promotion.getPromotedTools();
278
+ const stats = this.promotion.getStats().map(s => ({
279
+ ...s,
280
+ lastCall: new Date(s.lastCall).toISOString()
281
+ }));
282
+ return { action: "promotions", promoted, stats };
283
+ }
240
284
  async disconnectAll() {
241
285
  for (const serverName of Object.keys(this.servers)) {
242
286
  await this.disconnectServer(serverName);
@@ -40,6 +40,13 @@ export interface McpClientConfig {
40
40
  minScore?: number;
41
41
  };
42
42
  maxResultChars?: number;
43
+ adaptivePromotion?: {
44
+ enabled?: boolean;
45
+ maxPromoted?: number;
46
+ windowMs?: number;
47
+ minCalls?: number;
48
+ decayMs?: number;
49
+ };
43
50
  }
44
51
  export interface McpTool {
45
52
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Standalone MCP server that multiplexes multiple MCP servers into one interface",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",