@aiwerk/mcp-bridge 1.5.0 → 1.6.1
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 +141 -12
- package/dist/src/adaptive-promotion.d.ts +33 -0
- package/dist/src/adaptive-promotion.js +95 -0
- package/dist/src/mcp-router.d.ts +21 -0
- package/dist/src/mcp-router.js +44 -0
- package/dist/src/security.js +12 -0
- package/dist/src/smart-filter.d.ts +3 -10
- package/dist/src/smart-filter.js +26 -44
- package/dist/src/transport-sse.js +3 -2
- package/dist/src/types.d.ts +7 -0
- package/dist/src/types.js +2 -1
- package/package.json +1 -1
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**:
|
|
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
|
+
#### Max Result Size
|
|
232
|
+
|
|
233
|
+
Prevent oversized responses from consuming your context:
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"maxResultChars": 50000,
|
|
238
|
+
"servers": {
|
|
239
|
+
"verbose-server": {
|
|
240
|
+
"maxResultChars": 10000
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- Global default + per-server override
|
|
247
|
+
- Truncated results include `_truncated: true` and `_originalLength`
|
|
248
|
+
|
|
249
|
+
### Adaptive Promotion
|
|
250
|
+
|
|
251
|
+
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.
|
|
252
|
+
|
|
253
|
+
```json
|
|
254
|
+
"adaptivePromotion": {
|
|
255
|
+
"enabled": true,
|
|
256
|
+
"maxPromoted": 10,
|
|
257
|
+
"minCalls": 3,
|
|
258
|
+
"windowMs": 86400000,
|
|
259
|
+
"decayMs": 172800000
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
| Option | Default | Description |
|
|
264
|
+
|--------|---------|-------------|
|
|
265
|
+
| `enabled` | `false` | Opt-in: must be explicitly enabled |
|
|
266
|
+
| `maxPromoted` | `10` | Maximum number of tools to promote |
|
|
267
|
+
| `minCalls` | `3` | Minimum calls within window to qualify |
|
|
268
|
+
| `windowMs` | `86400000` (24h) | Time window for counting calls |
|
|
269
|
+
| `decayMs` | `172800000` (48h) | Demote tools with no calls in this period |
|
|
270
|
+
|
|
271
|
+
Use `action="promotions"` to check current promotion state:
|
|
272
|
+
```
|
|
273
|
+
mcp(action="promotions")
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Returns promoted tools (sorted by frequency) and full usage stats. All tracking is in-memory — promotion rebuilds naturally from usage after restart.
|
|
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 │
|
|
256
|
-
│ Any MCP client │
|
|
257
|
-
└─────────────────┘ │ │
|
|
258
|
-
│
|
|
259
|
-
│
|
|
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
|
+
}
|
package/dist/src/mcp-router.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/mcp-router.js
CHANGED
|
@@ -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);
|
package/dist/src/security.js
CHANGED
|
@@ -121,7 +121,19 @@ export function applyTrustLevel(result, serverName, serverConfig) {
|
|
|
121
121
|
*/
|
|
122
122
|
export function processResult(result, serverName, serverConfig, clientConfig) {
|
|
123
123
|
let processed = applyMaxResultSize(result, serverConfig, clientConfig);
|
|
124
|
+
const wasTruncated = processed !== null && typeof processed === "object" && processed._truncated === true;
|
|
124
125
|
// Sanitize step (only for trust=sanitize, handled inside applyTrustLevel)
|
|
125
126
|
processed = applyTrustLevel(processed, serverName, serverConfig);
|
|
127
|
+
// If both truncated and untrusted, flatten the metadata to top level
|
|
128
|
+
const trust = serverConfig.trust ?? "trusted";
|
|
129
|
+
if (wasTruncated && trust === "untrusted") {
|
|
130
|
+
return {
|
|
131
|
+
_trust: "untrusted",
|
|
132
|
+
_server: serverName,
|
|
133
|
+
_truncated: true,
|
|
134
|
+
_originalLength: processed.result?._originalLength,
|
|
135
|
+
result: processed.result?.result,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
126
138
|
return processed;
|
|
127
139
|
}
|
|
@@ -62,15 +62,15 @@ export declare class SmartFilter {
|
|
|
62
62
|
/**
|
|
63
63
|
* Extract meaningful intent from last 1-3 user turns
|
|
64
64
|
*/
|
|
65
|
-
|
|
66
|
-
private extractMeaningfulContent;
|
|
65
|
+
static synthesizeQuery(userTurns: UserTurn[]): string;
|
|
66
|
+
private static extractMeaningfulContent;
|
|
67
67
|
private prepareFilterableServers;
|
|
68
68
|
private normalizeKeywords;
|
|
69
69
|
/**
|
|
70
70
|
* Score servers using weighted overlap scoring
|
|
71
71
|
*/
|
|
72
72
|
private scoreServers;
|
|
73
|
-
|
|
73
|
+
static tokenize(text: string): string[];
|
|
74
74
|
private calculateServerScore;
|
|
75
75
|
private getSemanticScore;
|
|
76
76
|
private countOverlap;
|
|
@@ -88,15 +88,8 @@ export declare class SmartFilter {
|
|
|
88
88
|
private logTelemetry;
|
|
89
89
|
}
|
|
90
90
|
export declare const DEFAULTS: Required<SmartFilterConfig>;
|
|
91
|
-
/** Lowercase, split on whitespace + punctuation, preserve numbers, drop empties. */
|
|
92
|
-
export declare function tokenize(text: string): string[];
|
|
93
91
|
/** Normalize keywords: lowercase, trim, dedup, strip empties, cap at MAX_KEYWORDS. */
|
|
94
92
|
export declare function validateKeywords(raw: string[]): string[];
|
|
95
|
-
/**
|
|
96
|
-
* Extract a meaningful intent string from the last 1-3 user turns.
|
|
97
|
-
* Returns null if no meaningful query can be extracted.
|
|
98
|
-
*/
|
|
99
|
-
export declare function synthesizeQuery(userTurns: string[]): string | null;
|
|
100
93
|
export interface ServerScore {
|
|
101
94
|
name: string;
|
|
102
95
|
score: number;
|
package/dist/src/smart-filter.js
CHANGED
|
@@ -36,15 +36,22 @@ export class SmartFilter {
|
|
|
36
36
|
let timeoutOccurred = false;
|
|
37
37
|
try {
|
|
38
38
|
// Set up timeout
|
|
39
|
+
let timeoutId;
|
|
39
40
|
const timeoutPromise = new Promise((resolve) => {
|
|
40
|
-
setTimeout(() => {
|
|
41
|
+
timeoutId = setTimeout(() => {
|
|
41
42
|
timeoutOccurred = true;
|
|
42
43
|
this.logger.warn(`[smart-filter] Filter timeout after ${this.config.timeoutMs}ms, falling back to show all`);
|
|
43
44
|
resolve(this.createUnfilteredResult(servers, allTools, "keyword"));
|
|
44
45
|
}, this.config.timeoutMs);
|
|
45
46
|
});
|
|
46
47
|
const filterPromise = this.performFilter(servers, allTools, userTurns);
|
|
47
|
-
|
|
48
|
+
let result;
|
|
49
|
+
try {
|
|
50
|
+
result = await Promise.race([filterPromise, timeoutPromise]);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearTimeout(timeoutId);
|
|
54
|
+
}
|
|
48
55
|
result.metadata.timeoutOccurred = timeoutOccurred;
|
|
49
56
|
const duration = Date.now() - startTime;
|
|
50
57
|
if (this.config.telemetry) {
|
|
@@ -61,7 +68,7 @@ export class SmartFilter {
|
|
|
61
68
|
}
|
|
62
69
|
async performFilter(servers, allTools, userTurns) {
|
|
63
70
|
// Step 1: Query synthesis
|
|
64
|
-
const query =
|
|
71
|
+
const query = SmartFilter.synthesizeQuery(userTurns);
|
|
65
72
|
if (!query) {
|
|
66
73
|
this.logger.debug("[smart-filter] No meaningful query found, showing all servers");
|
|
67
74
|
return this.createUnfilteredResult(servers, allTools, "keyword", "");
|
|
@@ -89,7 +96,7 @@ export class SmartFilter {
|
|
|
89
96
|
/**
|
|
90
97
|
* Extract meaningful intent from last 1-3 user turns
|
|
91
98
|
*/
|
|
92
|
-
synthesizeQuery(userTurns) {
|
|
99
|
+
static synthesizeQuery(userTurns) {
|
|
93
100
|
if (!userTurns || userTurns.length === 0) {
|
|
94
101
|
return "";
|
|
95
102
|
}
|
|
@@ -99,20 +106,20 @@ export class SmartFilter {
|
|
|
99
106
|
.reverse()
|
|
100
107
|
.map(turn => turn.content.trim());
|
|
101
108
|
for (const content of recentTurns) {
|
|
102
|
-
const cleanedQuery =
|
|
109
|
+
const cleanedQuery = SmartFilter.extractMeaningfulContent(content);
|
|
103
110
|
if (cleanedQuery.length >= 3) {
|
|
104
111
|
return cleanedQuery;
|
|
105
112
|
}
|
|
106
113
|
}
|
|
107
114
|
// If all recent turns are too short, try combining them
|
|
108
115
|
const combined = recentTurns
|
|
109
|
-
.map(content =>
|
|
116
|
+
.map(content => SmartFilter.extractMeaningfulContent(content))
|
|
110
117
|
.filter(content => content.length > 0)
|
|
111
118
|
.join(" ")
|
|
112
119
|
.trim();
|
|
113
120
|
return combined.length >= 3 ? combined : "";
|
|
114
121
|
}
|
|
115
|
-
extractMeaningfulContent(content) {
|
|
122
|
+
static extractMeaningfulContent(content) {
|
|
116
123
|
// Remove metadata patterns
|
|
117
124
|
const cleaned = content
|
|
118
125
|
.replace(/\[.*?\]/g, "") // [timestamps], [commands]
|
|
@@ -157,13 +164,13 @@ export class SmartFilter {
|
|
|
157
164
|
* Score servers using weighted overlap scoring
|
|
158
165
|
*/
|
|
159
166
|
scoreServers(query, servers) {
|
|
160
|
-
const queryWords =
|
|
167
|
+
const queryWords = SmartFilter.tokenize(query.toLowerCase());
|
|
161
168
|
return servers.map(server => ({
|
|
162
169
|
server,
|
|
163
170
|
score: this.calculateServerScore(queryWords, server),
|
|
164
171
|
}));
|
|
165
172
|
}
|
|
166
|
-
tokenize(text) {
|
|
173
|
+
static tokenize(text) {
|
|
167
174
|
return text
|
|
168
175
|
.toLowerCase()
|
|
169
176
|
.replace(/[^\w\s]/g, " ")
|
|
@@ -173,7 +180,7 @@ export class SmartFilter {
|
|
|
173
180
|
calculateServerScore(queryWords, server) {
|
|
174
181
|
if (queryWords.length === 0)
|
|
175
182
|
return 0;
|
|
176
|
-
const descriptionWords =
|
|
183
|
+
const descriptionWords = SmartFilter.tokenize(server.description);
|
|
177
184
|
const keywordWords = server.keywords;
|
|
178
185
|
const allServerWords = [...descriptionWords, ...keywordWords];
|
|
179
186
|
// Calculate overlaps
|
|
@@ -311,7 +318,7 @@ export class SmartFilter {
|
|
|
311
318
|
* Filter tools within selected servers
|
|
312
319
|
*/
|
|
313
320
|
filterTools(query, selectedServers) {
|
|
314
|
-
const queryWords =
|
|
321
|
+
const queryWords = SmartFilter.tokenize(query);
|
|
315
322
|
const allTools = [];
|
|
316
323
|
for (const { server } of selectedServers) {
|
|
317
324
|
for (const tool of server.tools) {
|
|
@@ -330,8 +337,8 @@ export class SmartFilter {
|
|
|
330
337
|
calculateToolScore(queryWords, tool) {
|
|
331
338
|
if (queryWords.length === 0)
|
|
332
339
|
return 0;
|
|
333
|
-
const nameWords =
|
|
334
|
-
const descWords =
|
|
340
|
+
const nameWords = SmartFilter.tokenize(tool.name);
|
|
341
|
+
const descWords = SmartFilter.tokenize(tool.description || "");
|
|
335
342
|
const nameMatches = this.countOverlap(queryWords, nameWords);
|
|
336
343
|
const descMatches = this.countOverlap(queryWords, descWords) - this.countOverlap(queryWords, nameWords);
|
|
337
344
|
// Weighted: description 1.0x, name 0.5x (name is less descriptive usually)
|
|
@@ -382,11 +389,6 @@ export class SmartFilter {
|
|
|
382
389
|
}
|
|
383
390
|
// ── Standalone utility exports (for testing and external use) ────────────────
|
|
384
391
|
const MAX_KEYWORDS = 30;
|
|
385
|
-
const NOISE_WORDS = new Set([
|
|
386
|
-
"yes", "no", "ok", "okay", "sure", "yep", "nope", "yeah", "nah",
|
|
387
|
-
"do", "it", "please", "thanks", "thank", "you", "hi", "hello",
|
|
388
|
-
"hey", "right", "alright", "fine", "got", "hmm", "hm",
|
|
389
|
-
]);
|
|
390
392
|
export const DEFAULTS = {
|
|
391
393
|
enabled: true,
|
|
392
394
|
embedding: "keyword",
|
|
@@ -400,13 +402,6 @@ export const DEFAULTS = {
|
|
|
400
402
|
timeoutMs: 500,
|
|
401
403
|
telemetry: false,
|
|
402
404
|
};
|
|
403
|
-
/** Lowercase, split on whitespace + punctuation, preserve numbers, drop empties. */
|
|
404
|
-
export function tokenize(text) {
|
|
405
|
-
return text
|
|
406
|
-
.toLowerCase()
|
|
407
|
-
.split(/[\s\p{P}]+/u)
|
|
408
|
-
.filter(t => t.length > 0);
|
|
409
|
-
}
|
|
410
405
|
/** Normalize keywords: lowercase, trim, dedup, strip empties, cap at MAX_KEYWORDS. */
|
|
411
406
|
export function validateKeywords(raw) {
|
|
412
407
|
const seen = new Set();
|
|
@@ -422,20 +417,6 @@ export function validateKeywords(raw) {
|
|
|
422
417
|
}
|
|
423
418
|
return out;
|
|
424
419
|
}
|
|
425
|
-
/**
|
|
426
|
-
* Extract a meaningful intent string from the last 1-3 user turns.
|
|
427
|
-
* Returns null if no meaningful query can be extracted.
|
|
428
|
-
*/
|
|
429
|
-
export function synthesizeQuery(userTurns) {
|
|
430
|
-
const recent = userTurns.slice(-3).reverse();
|
|
431
|
-
for (const turn of recent) {
|
|
432
|
-
const tokens = tokenize(turn).filter(t => !NOISE_WORDS.has(t));
|
|
433
|
-
if (tokens.length >= 2) {
|
|
434
|
-
return tokens.join(" ");
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
return null;
|
|
438
|
-
}
|
|
439
420
|
/**
|
|
440
421
|
* Score a single server against a query using weighted word overlap.
|
|
441
422
|
* desc_matches * 1.0 + kw_only_matches * 0.5, normalized by query length.
|
|
@@ -443,10 +424,10 @@ export function synthesizeQuery(userTurns) {
|
|
|
443
424
|
export function scoreServer(queryTokens, serverName, description, keywords) {
|
|
444
425
|
if (queryTokens.length === 0)
|
|
445
426
|
return 0;
|
|
446
|
-
const descTokens = new Set(tokenize(description));
|
|
447
|
-
for (const t of tokenize(serverName))
|
|
427
|
+
const descTokens = new Set(SmartFilter.tokenize(description));
|
|
428
|
+
for (const t of SmartFilter.tokenize(serverName))
|
|
448
429
|
descTokens.add(t);
|
|
449
|
-
const kwTokens = new Set(validateKeywords(keywords).flatMap(kw => tokenize(kw)));
|
|
430
|
+
const kwTokens = new Set(validateKeywords(keywords).flatMap(kw => SmartFilter.tokenize(kw)));
|
|
450
431
|
let descMatches = 0;
|
|
451
432
|
let kwOnlyMatches = 0;
|
|
452
433
|
for (const qt of queryTokens) {
|
|
@@ -521,14 +502,15 @@ export function filterServers(servers, userTurns, config, logger) {
|
|
|
521
502
|
try {
|
|
522
503
|
const merged = { ...DEFAULTS, ...config };
|
|
523
504
|
const startTime = Date.now();
|
|
524
|
-
const
|
|
505
|
+
const userTurnObjects = userTurns.map(content => ({ content, timestamp: Date.now() }));
|
|
506
|
+
const query = SmartFilter.synthesizeQuery(userTurnObjects) || null;
|
|
525
507
|
if (!query)
|
|
526
508
|
return showAll("no-query");
|
|
527
509
|
if (Date.now() - startTime > merged.timeoutMs) {
|
|
528
510
|
logger?.warn("[smart-filter] Timeout during query synthesis");
|
|
529
511
|
return showAll("timeout", query);
|
|
530
512
|
}
|
|
531
|
-
const queryTokens = tokenize(query);
|
|
513
|
+
const queryTokens = SmartFilter.tokenize(query);
|
|
532
514
|
if (queryTokens.length === 0)
|
|
533
515
|
return showAll("no-query");
|
|
534
516
|
const scores = scoreAllServers(queryTokens, servers);
|
|
@@ -85,7 +85,8 @@ export class SseTransport extends BaseTransport {
|
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
87
|
if (trimmed.startsWith("data:")) {
|
|
88
|
-
|
|
88
|
+
const rawData = trimmed.substring(5);
|
|
89
|
+
state.dataBuffer.push(rawData.startsWith(" ") ? rawData.substring(1) : rawData);
|
|
89
90
|
return;
|
|
90
91
|
}
|
|
91
92
|
if (trimmed === "") {
|
|
@@ -100,7 +101,7 @@ export class SseTransport extends BaseTransport {
|
|
|
100
101
|
const base = new URL(this.config.url);
|
|
101
102
|
this.endpointUrl = `${base.origin}${data}`;
|
|
102
103
|
}
|
|
103
|
-
else if (data.startsWith("http")) {
|
|
104
|
+
else if (data.startsWith("http://") || data.startsWith("https://")) {
|
|
104
105
|
if (!this.isSameOrigin(data)) {
|
|
105
106
|
this.logger.warn(`[mcp-bridge] Rejected SSE endpoint with mismatched origin: ${data}`);
|
|
106
107
|
return;
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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/dist/src/types.js
CHANGED