@aiwerk/mcp-bridge 1.4.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 +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 +56 -2
- package/dist/src/security.d.ts +28 -0
- package/dist/src/security.js +127 -0
- package/dist/src/types.d.ts +14 -0
- 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
|
+
### 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 │
|
|
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
|
@@ -5,6 +5,8 @@ import { fetchToolsList, initializeProtocol, PACKAGE_VERSION } from "./protocol.
|
|
|
5
5
|
import { compressDescription } from "./schema-compression.js";
|
|
6
6
|
import { IntentRouter } from "./intent-router.js";
|
|
7
7
|
import { createEmbeddingProvider } from "./embeddings.js";
|
|
8
|
+
import { isToolAllowed, processResult } from "./security.js";
|
|
9
|
+
import { AdaptivePromotion } from "./adaptive-promotion.js";
|
|
8
10
|
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
9
11
|
const DEFAULT_MAX_CONCURRENT = 5;
|
|
10
12
|
export class McpRouter {
|
|
@@ -16,6 +18,7 @@ export class McpRouter {
|
|
|
16
18
|
maxConcurrent;
|
|
17
19
|
states = new Map();
|
|
18
20
|
intentRouter = null;
|
|
21
|
+
promotion = null;
|
|
19
22
|
constructor(servers, clientConfig, logger, transportRefs) {
|
|
20
23
|
this.servers = servers;
|
|
21
24
|
this.clientConfig = clientConfig;
|
|
@@ -27,6 +30,9 @@ export class McpRouter {
|
|
|
27
30
|
};
|
|
28
31
|
this.idleTimeoutMs = clientConfig.routerIdleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
29
32
|
this.maxConcurrent = clientConfig.routerMaxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
33
|
+
if (clientConfig.adaptivePromotion?.enabled) {
|
|
34
|
+
this.promotion = new AdaptivePromotion(clientConfig.adaptivePromotion, logger);
|
|
35
|
+
}
|
|
30
36
|
}
|
|
31
37
|
static generateDescription(servers) {
|
|
32
38
|
const serverNames = Object.keys(servers);
|
|
@@ -48,6 +54,10 @@ export class McpRouter {
|
|
|
48
54
|
if (normalizedAction === "status") {
|
|
49
55
|
return this.getStatus();
|
|
50
56
|
}
|
|
57
|
+
// Promotions action: return promotion stats
|
|
58
|
+
if (normalizedAction === "promotions") {
|
|
59
|
+
return this.getPromotionStats();
|
|
60
|
+
}
|
|
51
61
|
// Intent action: find server+tool from natural language
|
|
52
62
|
if (normalizedAction === "intent") {
|
|
53
63
|
const intent = params?.intent || tool;
|
|
@@ -121,6 +131,11 @@ export class McpRouter {
|
|
|
121
131
|
if (!state.toolNames.includes(tool)) {
|
|
122
132
|
return this.error("unknown_tool", `Tool '${tool}' not found on server '${server}'`, state.toolNames);
|
|
123
133
|
}
|
|
134
|
+
// Defense in depth: double-check tool filter
|
|
135
|
+
const serverConfig = this.servers[server];
|
|
136
|
+
if (!isToolAllowed(tool, serverConfig)) {
|
|
137
|
+
return this.error("unknown_tool", `Tool '${tool}' is not allowed on server '${server}'`, state.toolNames);
|
|
138
|
+
}
|
|
124
139
|
this.markUsed(server);
|
|
125
140
|
const response = await state.transport.sendRequest({
|
|
126
141
|
jsonrpc: "2.0",
|
|
@@ -133,7 +148,13 @@ export class McpRouter {
|
|
|
133
148
|
if (response.error) {
|
|
134
149
|
return this.error("mcp_error", response.error.message, undefined, response.error.code);
|
|
135
150
|
}
|
|
136
|
-
|
|
151
|
+
// Record usage for adaptive promotion
|
|
152
|
+
if (this.promotion) {
|
|
153
|
+
this.promotion.recordCall(server, tool);
|
|
154
|
+
}
|
|
155
|
+
// Security pipeline: truncate → sanitize → trust-tag
|
|
156
|
+
const result = processResult(response.result, server, serverConfig, this.clientConfig);
|
|
157
|
+
return { server, action: "call", tool, result };
|
|
137
158
|
}
|
|
138
159
|
catch (error) {
|
|
139
160
|
return this.error("mcp_error", error instanceof Error ? error.message : String(error));
|
|
@@ -148,7 +169,9 @@ export class McpRouter {
|
|
|
148
169
|
this.markUsed(server);
|
|
149
170
|
return state.toolsCache;
|
|
150
171
|
}
|
|
151
|
-
const
|
|
172
|
+
const allTools = await fetchToolsList(state.transport);
|
|
173
|
+
const serverConfig = this.servers[server];
|
|
174
|
+
const tools = allTools.filter((tool) => isToolAllowed(tool.name, serverConfig));
|
|
152
175
|
state.toolNames = tools.map((tool) => tool.name);
|
|
153
176
|
// Store full tool metadata for action=schema
|
|
154
177
|
state.fullToolsMap = new Map(tools.map((tool) => [tool.name, { description: tool.description || "", inputSchema: tool.inputSchema }]));
|
|
@@ -227,6 +250,37 @@ export class McpRouter {
|
|
|
227
250
|
});
|
|
228
251
|
return { action: "status", servers: serverStatuses };
|
|
229
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
|
+
}
|
|
230
284
|
async disconnectAll() {
|
|
231
285
|
for (const serverName of Object.keys(this.servers)) {
|
|
232
286
|
await this.disconnectServer(serverName);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security processing for MCP tool results.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline order: truncate → sanitize → trust-tag
|
|
5
|
+
*/
|
|
6
|
+
import type { McpServerConfig, McpClientConfig } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Sanitize a tool result by stripping HTML and prompt injection patterns.
|
|
9
|
+
*/
|
|
10
|
+
export declare function sanitizeResult(result: any): any;
|
|
11
|
+
/**
|
|
12
|
+
* Check if a tool is allowed by the server's toolFilter config.
|
|
13
|
+
* Returns true if the tool should be visible/callable.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isToolAllowed(toolName: string, serverConfig: McpServerConfig): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Apply max result size truncation.
|
|
18
|
+
* Returns the result as-is or a truncation wrapper.
|
|
19
|
+
*/
|
|
20
|
+
export declare function applyMaxResultSize(result: any, serverConfig: McpServerConfig, clientConfig: McpClientConfig): any;
|
|
21
|
+
/**
|
|
22
|
+
* Apply trust level wrapping/sanitization.
|
|
23
|
+
*/
|
|
24
|
+
export declare function applyTrustLevel(result: any, serverName: string, serverConfig: McpServerConfig): any;
|
|
25
|
+
/**
|
|
26
|
+
* Full security pipeline: truncate → sanitize → trust-tag
|
|
27
|
+
*/
|
|
28
|
+
export declare function processResult(result: any, serverName: string, serverConfig: McpServerConfig, clientConfig: McpClientConfig): any;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security processing for MCP tool results.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline order: truncate → sanitize → trust-tag
|
|
5
|
+
*/
|
|
6
|
+
// Prompt injection patterns to strip (case-insensitive)
|
|
7
|
+
const INJECTION_PATTERNS = [
|
|
8
|
+
/ignore\s+(all\s+)?previous\s+instructions/gi,
|
|
9
|
+
/ignore\s+(all\s+)?prior\s+instructions/gi,
|
|
10
|
+
/disregard\s+(all\s+)?previous\s+instructions/gi,
|
|
11
|
+
/you\s+are\s+now\b/gi,
|
|
12
|
+
/^system\s*:/gim,
|
|
13
|
+
/\bact\s+as\s+(a|an)\s+/gi,
|
|
14
|
+
/pretend\s+you\s+are\b/gi,
|
|
15
|
+
/from\s+now\s+on\s+you\s+are\b/gi,
|
|
16
|
+
/new\s+instructions\s*:/gi,
|
|
17
|
+
/override\s+(all\s+)?instructions/gi,
|
|
18
|
+
];
|
|
19
|
+
function stripHtmlTags(text) {
|
|
20
|
+
return text.replace(/<[^>]*>/g, "");
|
|
21
|
+
}
|
|
22
|
+
function stripInjectionPatterns(text) {
|
|
23
|
+
let result = text;
|
|
24
|
+
for (const pattern of INJECTION_PATTERNS) {
|
|
25
|
+
// Reset lastIndex for global regexes
|
|
26
|
+
pattern.lastIndex = 0;
|
|
27
|
+
result = result.replace(pattern, "");
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
function sanitizeString(text) {
|
|
32
|
+
return stripInjectionPatterns(stripHtmlTags(text));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Sanitize a tool result by stripping HTML and prompt injection patterns.
|
|
36
|
+
*/
|
|
37
|
+
export function sanitizeResult(result) {
|
|
38
|
+
if (typeof result === "string") {
|
|
39
|
+
return sanitizeString(result);
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(result)) {
|
|
42
|
+
return result.map((item) => sanitizeResult(item));
|
|
43
|
+
}
|
|
44
|
+
if (result !== null && typeof result === "object") {
|
|
45
|
+
// MCP standard content array
|
|
46
|
+
if (Array.isArray(result.content)) {
|
|
47
|
+
return {
|
|
48
|
+
...result,
|
|
49
|
+
content: result.content.map((item) => {
|
|
50
|
+
if (item.type === "text" && typeof item.text === "string") {
|
|
51
|
+
return { ...item, text: sanitizeString(item.text) };
|
|
52
|
+
}
|
|
53
|
+
return item;
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Recursively sanitize object values
|
|
58
|
+
const sanitized = {};
|
|
59
|
+
for (const [key, value] of Object.entries(result)) {
|
|
60
|
+
sanitized[key] = sanitizeResult(value);
|
|
61
|
+
}
|
|
62
|
+
return sanitized;
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Check if a tool is allowed by the server's toolFilter config.
|
|
68
|
+
* Returns true if the tool should be visible/callable.
|
|
69
|
+
*/
|
|
70
|
+
export function isToolAllowed(toolName, serverConfig) {
|
|
71
|
+
const filter = serverConfig.toolFilter;
|
|
72
|
+
if (!filter)
|
|
73
|
+
return true;
|
|
74
|
+
const { allow, deny } = filter;
|
|
75
|
+
if (allow && allow.length > 0) {
|
|
76
|
+
// Whitelist mode: only allowed tools, minus denied
|
|
77
|
+
if (!allow.includes(toolName))
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (deny && deny.length > 0) {
|
|
81
|
+
if (deny.includes(toolName))
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Apply max result size truncation.
|
|
88
|
+
* Returns the result as-is or a truncation wrapper.
|
|
89
|
+
*/
|
|
90
|
+
export function applyMaxResultSize(result, serverConfig, clientConfig) {
|
|
91
|
+
const limit = serverConfig.maxResultChars ?? clientConfig.maxResultChars;
|
|
92
|
+
if (limit === undefined)
|
|
93
|
+
return result;
|
|
94
|
+
const serialized = JSON.stringify(result);
|
|
95
|
+
if (serialized.length <= limit)
|
|
96
|
+
return result;
|
|
97
|
+
return {
|
|
98
|
+
_truncated: true,
|
|
99
|
+
_originalLength: serialized.length,
|
|
100
|
+
result: serialized.slice(0, limit),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Apply trust level wrapping/sanitization.
|
|
105
|
+
*/
|
|
106
|
+
export function applyTrustLevel(result, serverName, serverConfig) {
|
|
107
|
+
const trust = serverConfig.trust ?? "trusted";
|
|
108
|
+
switch (trust) {
|
|
109
|
+
case "trusted":
|
|
110
|
+
return result;
|
|
111
|
+
case "untrusted":
|
|
112
|
+
return { _trust: "untrusted", _server: serverName, result };
|
|
113
|
+
case "sanitize":
|
|
114
|
+
return sanitizeResult(result);
|
|
115
|
+
default:
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Full security pipeline: truncate → sanitize → trust-tag
|
|
121
|
+
*/
|
|
122
|
+
export function processResult(result, serverName, serverConfig, clientConfig) {
|
|
123
|
+
let processed = applyMaxResultSize(result, serverConfig, clientConfig);
|
|
124
|
+
// Sanitize step (only for trust=sanitize, handled inside applyTrustLevel)
|
|
125
|
+
processed = applyTrustLevel(processed, serverName, serverConfig);
|
|
126
|
+
return processed;
|
|
127
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface McpServerConfig {
|
|
|
14
14
|
args?: string[];
|
|
15
15
|
env?: Record<string, string>;
|
|
16
16
|
framing?: "auto" | "lsp" | "newline";
|
|
17
|
+
trust?: "trusted" | "untrusted" | "sanitize";
|
|
18
|
+
toolFilter?: {
|
|
19
|
+
deny?: string[];
|
|
20
|
+
allow?: string[];
|
|
21
|
+
};
|
|
22
|
+
maxResultChars?: number;
|
|
17
23
|
}
|
|
18
24
|
export interface McpClientConfig {
|
|
19
25
|
servers: Record<string, McpServerConfig>;
|
|
@@ -33,6 +39,14 @@ export interface McpClientConfig {
|
|
|
33
39
|
model?: string;
|
|
34
40
|
minScore?: number;
|
|
35
41
|
};
|
|
42
|
+
maxResultChars?: number;
|
|
43
|
+
adaptivePromotion?: {
|
|
44
|
+
enabled?: boolean;
|
|
45
|
+
maxPromoted?: number;
|
|
46
|
+
windowMs?: number;
|
|
47
|
+
minCalls?: number;
|
|
48
|
+
decayMs?: number;
|
|
49
|
+
};
|
|
36
50
|
}
|
|
37
51
|
export interface McpTool {
|
|
38
52
|
name: string;
|