@aiwerk/mcp-bridge 1.3.0 → 1.5.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/dist/src/embeddings.d.ts +37 -0
- package/dist/src/embeddings.js +176 -0
- package/dist/src/intent-router.d.ts +25 -0
- package/dist/src/intent-router.js +80 -0
- package/dist/src/mcp-router.d.ts +15 -0
- package/dist/src/mcp-router.js +73 -3
- package/dist/src/security.d.ts +28 -0
- package/dist/src/security.js +127 -0
- package/dist/src/types.d.ts +12 -0
- package/dist/src/vector-store.d.ts +17 -0
- package/dist/src/vector-store.js +43 -0
- package/package.json +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Logger } from "./types.js";
|
|
2
|
+
export interface EmbeddingProvider {
|
|
3
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
4
|
+
dimensions(): number;
|
|
5
|
+
}
|
|
6
|
+
export declare class GeminiEmbedding implements EmbeddingProvider {
|
|
7
|
+
private readonly apiKey;
|
|
8
|
+
constructor(apiKey: string);
|
|
9
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
10
|
+
dimensions(): number;
|
|
11
|
+
}
|
|
12
|
+
export declare class OpenAIEmbedding implements EmbeddingProvider {
|
|
13
|
+
private readonly apiKey;
|
|
14
|
+
private readonly model;
|
|
15
|
+
constructor(apiKey: string, model?: string);
|
|
16
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
17
|
+
dimensions(): number;
|
|
18
|
+
}
|
|
19
|
+
export declare class OllamaEmbedding implements EmbeddingProvider {
|
|
20
|
+
private readonly model;
|
|
21
|
+
private readonly baseUrl;
|
|
22
|
+
constructor(model?: string, baseUrl?: string);
|
|
23
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
24
|
+
dimensions(): number;
|
|
25
|
+
}
|
|
26
|
+
export declare class KeywordEmbedding implements EmbeddingProvider {
|
|
27
|
+
private vocabulary;
|
|
28
|
+
private vocabSize;
|
|
29
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
30
|
+
dimensions(): number;
|
|
31
|
+
private tokenize;
|
|
32
|
+
}
|
|
33
|
+
export declare function createEmbeddingProvider(type: "auto" | "gemini" | "openai" | "ollama" | "keyword", config?: {
|
|
34
|
+
model?: string;
|
|
35
|
+
apiKey?: string;
|
|
36
|
+
ollamaUrl?: string;
|
|
37
|
+
}, logger?: Logger): EmbeddingProvider;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
export class GeminiEmbedding {
|
|
2
|
+
apiKey;
|
|
3
|
+
constructor(apiKey) {
|
|
4
|
+
this.apiKey = apiKey;
|
|
5
|
+
}
|
|
6
|
+
async embed(texts) {
|
|
7
|
+
const results = [];
|
|
8
|
+
for (const text of texts) {
|
|
9
|
+
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=${this.apiKey}`, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: { "Content-Type": "application/json" },
|
|
12
|
+
body: JSON.stringify({
|
|
13
|
+
content: { parts: [{ text }] }
|
|
14
|
+
})
|
|
15
|
+
});
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
throw new Error(`Gemini API error: ${response.status} ${response.statusText}`);
|
|
18
|
+
}
|
|
19
|
+
const data = await response.json();
|
|
20
|
+
results.push(data.embedding.values);
|
|
21
|
+
}
|
|
22
|
+
return results;
|
|
23
|
+
}
|
|
24
|
+
dimensions() {
|
|
25
|
+
return 768;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class OpenAIEmbedding {
|
|
29
|
+
apiKey;
|
|
30
|
+
model;
|
|
31
|
+
constructor(apiKey, model = "text-embedding-3-small") {
|
|
32
|
+
this.apiKey = apiKey;
|
|
33
|
+
this.model = model;
|
|
34
|
+
}
|
|
35
|
+
async embed(texts) {
|
|
36
|
+
const response = await fetch("https://api.openai.com/v1/embeddings", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({ input: texts, model: this.model })
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
|
|
46
|
+
}
|
|
47
|
+
const data = await response.json();
|
|
48
|
+
return data.data
|
|
49
|
+
.sort((a, b) => a.index - b.index)
|
|
50
|
+
.map((item) => item.embedding);
|
|
51
|
+
}
|
|
52
|
+
dimensions() {
|
|
53
|
+
return 1536;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export class OllamaEmbedding {
|
|
57
|
+
model;
|
|
58
|
+
baseUrl;
|
|
59
|
+
constructor(model = "nomic-embed-text", baseUrl = "http://localhost:11434") {
|
|
60
|
+
this.model = model;
|
|
61
|
+
this.baseUrl = baseUrl;
|
|
62
|
+
}
|
|
63
|
+
async embed(texts) {
|
|
64
|
+
const response = await fetch(`${this.baseUrl}/api/embed`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify({ model: this.model, input: texts })
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
|
|
71
|
+
}
|
|
72
|
+
const data = await response.json();
|
|
73
|
+
return data.embeddings;
|
|
74
|
+
}
|
|
75
|
+
dimensions() {
|
|
76
|
+
return 768;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export class KeywordEmbedding {
|
|
80
|
+
vocabulary = new Map();
|
|
81
|
+
vocabSize = 0;
|
|
82
|
+
async embed(texts) {
|
|
83
|
+
// Build vocabulary from all texts
|
|
84
|
+
this.vocabulary.clear();
|
|
85
|
+
this.vocabSize = 0;
|
|
86
|
+
const tokenizedTexts = texts.map((text) => this.tokenize(text));
|
|
87
|
+
for (const tokens of tokenizedTexts) {
|
|
88
|
+
for (const token of tokens) {
|
|
89
|
+
if (!this.vocabulary.has(token)) {
|
|
90
|
+
this.vocabulary.set(token, this.vocabSize++);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (this.vocabSize === 0) {
|
|
95
|
+
return texts.map(() => [0]);
|
|
96
|
+
}
|
|
97
|
+
// Create TF vectors
|
|
98
|
+
const vectors = [];
|
|
99
|
+
for (const tokens of tokenizedTexts) {
|
|
100
|
+
const vector = new Array(this.vocabSize).fill(0);
|
|
101
|
+
for (const token of tokens) {
|
|
102
|
+
const idx = this.vocabulary.get(token);
|
|
103
|
+
vector[idx] += 1;
|
|
104
|
+
}
|
|
105
|
+
// Normalize by document length
|
|
106
|
+
const len = tokens.length || 1;
|
|
107
|
+
for (let i = 0; i < vector.length; i++) {
|
|
108
|
+
vector[i] /= len;
|
|
109
|
+
}
|
|
110
|
+
vectors.push(vector);
|
|
111
|
+
}
|
|
112
|
+
// Apply IDF weighting
|
|
113
|
+
const docCount = texts.length;
|
|
114
|
+
const idf = new Array(this.vocabSize).fill(0);
|
|
115
|
+
for (let i = 0; i < this.vocabSize; i++) {
|
|
116
|
+
let df = 0;
|
|
117
|
+
for (const vec of vectors) {
|
|
118
|
+
if (vec[i] > 0)
|
|
119
|
+
df++;
|
|
120
|
+
}
|
|
121
|
+
idf[i] = Math.log((docCount + 1) / (df + 1)) + 1;
|
|
122
|
+
}
|
|
123
|
+
for (const vec of vectors) {
|
|
124
|
+
for (let i = 0; i < vec.length; i++) {
|
|
125
|
+
vec[i] *= idf[i];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return vectors;
|
|
129
|
+
}
|
|
130
|
+
dimensions() {
|
|
131
|
+
return this.vocabSize || 1;
|
|
132
|
+
}
|
|
133
|
+
tokenize(text) {
|
|
134
|
+
return text
|
|
135
|
+
.toLowerCase()
|
|
136
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
137
|
+
.split(/\s+/)
|
|
138
|
+
.filter((t) => t.length > 1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function createEmbeddingProvider(type, config, logger) {
|
|
142
|
+
if (type === "gemini") {
|
|
143
|
+
const key = config?.apiKey || process.env.GEMINI_API_KEY;
|
|
144
|
+
if (!key)
|
|
145
|
+
throw new Error("GEMINI_API_KEY is required for gemini embedding provider");
|
|
146
|
+
return new GeminiEmbedding(key);
|
|
147
|
+
}
|
|
148
|
+
if (type === "openai") {
|
|
149
|
+
const key = config?.apiKey || process.env.OPENAI_API_KEY;
|
|
150
|
+
if (!key)
|
|
151
|
+
throw new Error("OPENAI_API_KEY is required for openai embedding provider");
|
|
152
|
+
return new OpenAIEmbedding(key, config?.model);
|
|
153
|
+
}
|
|
154
|
+
if (type === "ollama") {
|
|
155
|
+
return new OllamaEmbedding(config?.model, config?.ollamaUrl);
|
|
156
|
+
}
|
|
157
|
+
if (type === "keyword") {
|
|
158
|
+
return new KeywordEmbedding();
|
|
159
|
+
}
|
|
160
|
+
// auto: try gemini, openai, ollama, then keyword fallback
|
|
161
|
+
const geminiKey = config?.apiKey || process.env.GEMINI_API_KEY;
|
|
162
|
+
if (geminiKey) {
|
|
163
|
+
logger?.debug("[mcp-bridge] Intent routing: using Gemini embeddings");
|
|
164
|
+
return new GeminiEmbedding(geminiKey);
|
|
165
|
+
}
|
|
166
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
167
|
+
if (openaiKey) {
|
|
168
|
+
logger?.debug("[mcp-bridge] Intent routing: using OpenAI embeddings");
|
|
169
|
+
return new OpenAIEmbedding(openaiKey, config?.model);
|
|
170
|
+
}
|
|
171
|
+
// For ollama, we can't synchronously check reachability in factory,
|
|
172
|
+
// so we skip it in auto mode and fall back to keyword.
|
|
173
|
+
// Users who want ollama should specify type="ollama" explicitly.
|
|
174
|
+
logger?.debug("[mcp-bridge] Intent routing: using keyword fallback embeddings");
|
|
175
|
+
return new KeywordEmbedding();
|
|
176
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { EmbeddingProvider } from "./embeddings.js";
|
|
2
|
+
import type { Logger } from "./types.js";
|
|
3
|
+
import type { McpTool } from "./types.js";
|
|
4
|
+
export interface IntentMatch {
|
|
5
|
+
server: string;
|
|
6
|
+
tool: string;
|
|
7
|
+
score: number;
|
|
8
|
+
alternatives: Array<{
|
|
9
|
+
server: string;
|
|
10
|
+
tool: string;
|
|
11
|
+
score: number;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
export declare class IntentRouter {
|
|
15
|
+
private readonly embeddingProvider;
|
|
16
|
+
private readonly logger;
|
|
17
|
+
private readonly store;
|
|
18
|
+
private indexed;
|
|
19
|
+
private minScore;
|
|
20
|
+
constructor(embeddingProvider: EmbeddingProvider, logger: Logger, minScore?: number);
|
|
21
|
+
indexTools(servers: Record<string, McpTool[]>): Promise<void>;
|
|
22
|
+
resolve(intent: string): Promise<IntentMatch | null>;
|
|
23
|
+
isIndexed(): boolean;
|
|
24
|
+
clearIndex(): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { VectorStore } from "./vector-store.js";
|
|
2
|
+
const DEFAULT_MIN_SCORE = 0.3;
|
|
3
|
+
const DEFAULT_TOP_K = 4;
|
|
4
|
+
export class IntentRouter {
|
|
5
|
+
embeddingProvider;
|
|
6
|
+
logger;
|
|
7
|
+
store = new VectorStore();
|
|
8
|
+
indexed = false;
|
|
9
|
+
minScore;
|
|
10
|
+
constructor(embeddingProvider, logger, minScore) {
|
|
11
|
+
this.embeddingProvider = embeddingProvider;
|
|
12
|
+
this.logger = logger;
|
|
13
|
+
this.minScore = minScore ?? DEFAULT_MIN_SCORE;
|
|
14
|
+
}
|
|
15
|
+
async indexTools(servers) {
|
|
16
|
+
this.store.clear();
|
|
17
|
+
this.indexed = false;
|
|
18
|
+
const entries = [];
|
|
19
|
+
for (const [serverName, tools] of Object.entries(servers)) {
|
|
20
|
+
for (const tool of tools) {
|
|
21
|
+
const text = `${tool.name}: ${tool.description || ""}`;
|
|
22
|
+
entries.push({
|
|
23
|
+
id: `${serverName}/${tool.name}`,
|
|
24
|
+
text,
|
|
25
|
+
server: serverName,
|
|
26
|
+
tool: tool.name
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (entries.length === 0) {
|
|
31
|
+
this.indexed = true;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const texts = entries.map((e) => e.text);
|
|
35
|
+
const vectors = await this.embeddingProvider.embed(texts);
|
|
36
|
+
for (let i = 0; i < entries.length; i++) {
|
|
37
|
+
this.store.add(entries[i].id, vectors[i], {
|
|
38
|
+
server: entries[i].server,
|
|
39
|
+
tool: entries[i].tool,
|
|
40
|
+
description: entries[i].text
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
this.indexed = true;
|
|
44
|
+
this.logger.debug(`[mcp-bridge] Intent router indexed ${entries.length} tools`);
|
|
45
|
+
}
|
|
46
|
+
async resolve(intent) {
|
|
47
|
+
if (!this.indexed || this.store.size() === 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
// Embed the intent together with stored texts for keyword provider compatibility
|
|
51
|
+
// For API-based providers this is just a single text embedding
|
|
52
|
+
const [queryVector] = await this.embeddingProvider.embed([intent]);
|
|
53
|
+
const results = this.store.search(queryVector, DEFAULT_TOP_K);
|
|
54
|
+
if (results.length === 0) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const best = results[0];
|
|
58
|
+
if (best.score < this.minScore) {
|
|
59
|
+
this.logger.debug(`[mcp-bridge] Intent "${intent}" best match score ${best.score.toFixed(3)} below threshold ${this.minScore}`);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
server: best.metadata.server,
|
|
64
|
+
tool: best.metadata.tool,
|
|
65
|
+
score: best.score,
|
|
66
|
+
alternatives: results.slice(1).map((r) => ({
|
|
67
|
+
server: r.metadata.server,
|
|
68
|
+
tool: r.metadata.tool,
|
|
69
|
+
score: r.score
|
|
70
|
+
}))
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
isIndexed() {
|
|
74
|
+
return this.indexed;
|
|
75
|
+
}
|
|
76
|
+
clearIndex() {
|
|
77
|
+
this.store.clear();
|
|
78
|
+
this.indexed = false;
|
|
79
|
+
}
|
|
80
|
+
}
|
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: "intent";
|
|
40
|
+
intent: string;
|
|
41
|
+
match: {
|
|
42
|
+
server: string;
|
|
43
|
+
tool: string;
|
|
44
|
+
score: number;
|
|
45
|
+
};
|
|
46
|
+
alternatives: Array<{
|
|
47
|
+
server: string;
|
|
48
|
+
tool: string;
|
|
49
|
+
score: number;
|
|
50
|
+
}>;
|
|
38
51
|
} | {
|
|
39
52
|
error: RouterErrorCode;
|
|
40
53
|
message: string;
|
|
@@ -54,10 +67,12 @@ export declare class McpRouter {
|
|
|
54
67
|
private readonly idleTimeoutMs;
|
|
55
68
|
private readonly maxConcurrent;
|
|
56
69
|
private readonly states;
|
|
70
|
+
private intentRouter;
|
|
57
71
|
constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
|
|
58
72
|
static generateDescription(servers: Record<string, McpServerConfig>): string;
|
|
59
73
|
dispatch(server?: string, action?: string, tool?: string, params?: any): Promise<RouterDispatchResponse>;
|
|
60
74
|
getToolList(server: string): Promise<RouterToolHint[]>;
|
|
75
|
+
private resolveIntent;
|
|
61
76
|
private getStatus;
|
|
62
77
|
disconnectAll(): Promise<void>;
|
|
63
78
|
private ensureConnected;
|
package/dist/src/mcp-router.js
CHANGED
|
@@ -3,6 +3,9 @@ import { StdioTransport } from "./transport-stdio.js";
|
|
|
3
3
|
import { StreamableHttpTransport } from "./transport-streamable-http.js";
|
|
4
4
|
import { fetchToolsList, initializeProtocol, PACKAGE_VERSION } from "./protocol.js";
|
|
5
5
|
import { compressDescription } from "./schema-compression.js";
|
|
6
|
+
import { IntentRouter } from "./intent-router.js";
|
|
7
|
+
import { createEmbeddingProvider } from "./embeddings.js";
|
|
8
|
+
import { isToolAllowed, processResult } from "./security.js";
|
|
6
9
|
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
10
|
const DEFAULT_MAX_CONCURRENT = 5;
|
|
8
11
|
export class McpRouter {
|
|
@@ -13,6 +16,7 @@ export class McpRouter {
|
|
|
13
16
|
idleTimeoutMs;
|
|
14
17
|
maxConcurrent;
|
|
15
18
|
states = new Map();
|
|
19
|
+
intentRouter = null;
|
|
16
20
|
constructor(servers, clientConfig, logger, transportRefs) {
|
|
17
21
|
this.servers = servers;
|
|
18
22
|
this.clientConfig = clientConfig;
|
|
@@ -45,6 +49,14 @@ export class McpRouter {
|
|
|
45
49
|
if (normalizedAction === "status") {
|
|
46
50
|
return this.getStatus();
|
|
47
51
|
}
|
|
52
|
+
// Intent action: find server+tool from natural language
|
|
53
|
+
if (normalizedAction === "intent") {
|
|
54
|
+
const intent = params?.intent || tool;
|
|
55
|
+
if (!intent) {
|
|
56
|
+
return this.error("invalid_params", "intent string is required for action=intent");
|
|
57
|
+
}
|
|
58
|
+
return this.resolveIntent(intent);
|
|
59
|
+
}
|
|
48
60
|
if (!server) {
|
|
49
61
|
return this.error("invalid_params", "server is required");
|
|
50
62
|
}
|
|
@@ -83,6 +95,10 @@ export class McpRouter {
|
|
|
83
95
|
state.toolsCache = undefined;
|
|
84
96
|
state.fullToolsMap = undefined;
|
|
85
97
|
state.toolNames = [];
|
|
98
|
+
// Clear intent index so it re-indexes on next intent query
|
|
99
|
+
if (this.intentRouter) {
|
|
100
|
+
this.intentRouter.clearIndex();
|
|
101
|
+
}
|
|
86
102
|
const tools = await this.getToolList(server);
|
|
87
103
|
return { server, action: "refresh", refreshed: true, tools };
|
|
88
104
|
}
|
|
@@ -91,7 +107,7 @@ export class McpRouter {
|
|
|
91
107
|
}
|
|
92
108
|
}
|
|
93
109
|
if (normalizedAction !== "call") {
|
|
94
|
-
return this.error("invalid_params", `action must be one of: list, call, refresh, schema`);
|
|
110
|
+
return this.error("invalid_params", `action must be one of: list, call, refresh, schema, intent`);
|
|
95
111
|
}
|
|
96
112
|
if (!tool) {
|
|
97
113
|
return this.error("invalid_params", "tool is required for action=call");
|
|
@@ -106,6 +122,11 @@ export class McpRouter {
|
|
|
106
122
|
if (!state.toolNames.includes(tool)) {
|
|
107
123
|
return this.error("unknown_tool", `Tool '${tool}' not found on server '${server}'`, state.toolNames);
|
|
108
124
|
}
|
|
125
|
+
// Defense in depth: double-check tool filter
|
|
126
|
+
const serverConfig = this.servers[server];
|
|
127
|
+
if (!isToolAllowed(tool, serverConfig)) {
|
|
128
|
+
return this.error("unknown_tool", `Tool '${tool}' is not allowed on server '${server}'`, state.toolNames);
|
|
129
|
+
}
|
|
109
130
|
this.markUsed(server);
|
|
110
131
|
const response = await state.transport.sendRequest({
|
|
111
132
|
jsonrpc: "2.0",
|
|
@@ -118,7 +139,9 @@ export class McpRouter {
|
|
|
118
139
|
if (response.error) {
|
|
119
140
|
return this.error("mcp_error", response.error.message, undefined, response.error.code);
|
|
120
141
|
}
|
|
121
|
-
|
|
142
|
+
// Security pipeline: truncate → sanitize → trust-tag
|
|
143
|
+
const result = processResult(response.result, server, serverConfig, this.clientConfig);
|
|
144
|
+
return { server, action: "call", tool, result };
|
|
122
145
|
}
|
|
123
146
|
catch (error) {
|
|
124
147
|
return this.error("mcp_error", error instanceof Error ? error.message : String(error));
|
|
@@ -133,7 +156,9 @@ export class McpRouter {
|
|
|
133
156
|
this.markUsed(server);
|
|
134
157
|
return state.toolsCache;
|
|
135
158
|
}
|
|
136
|
-
const
|
|
159
|
+
const allTools = await fetchToolsList(state.transport);
|
|
160
|
+
const serverConfig = this.servers[server];
|
|
161
|
+
const tools = allTools.filter((tool) => isToolAllowed(tool.name, serverConfig));
|
|
137
162
|
state.toolNames = tools.map((tool) => tool.name);
|
|
138
163
|
// Store full tool metadata for action=schema
|
|
139
164
|
state.fullToolsMap = new Map(tools.map((tool) => [tool.name, { description: tool.description || "", inputSchema: tool.inputSchema }]));
|
|
@@ -149,6 +174,51 @@ export class McpRouter {
|
|
|
149
174
|
this.markUsed(server);
|
|
150
175
|
return state.toolsCache;
|
|
151
176
|
}
|
|
177
|
+
async resolveIntent(intent) {
|
|
178
|
+
try {
|
|
179
|
+
// Lazily create the intent router
|
|
180
|
+
if (!this.intentRouter) {
|
|
181
|
+
const routingConfig = this.clientConfig.intentRouting;
|
|
182
|
+
const embeddingType = routingConfig?.embedding ?? "auto";
|
|
183
|
+
const provider = createEmbeddingProvider(embeddingType, { model: routingConfig?.model }, this.logger);
|
|
184
|
+
this.intentRouter = new IntentRouter(provider, this.logger, routingConfig?.minScore);
|
|
185
|
+
}
|
|
186
|
+
// Index tools if not already done
|
|
187
|
+
if (!this.intentRouter.isIndexed()) {
|
|
188
|
+
const allTools = {};
|
|
189
|
+
for (const serverName of Object.keys(this.servers)) {
|
|
190
|
+
try {
|
|
191
|
+
await this.getToolList(serverName);
|
|
192
|
+
const state = this.states.get(serverName);
|
|
193
|
+
if (state?.fullToolsMap) {
|
|
194
|
+
allTools[serverName] = [...state.fullToolsMap.entries()].map(([name, meta]) => ({
|
|
195
|
+
name,
|
|
196
|
+
description: meta.description,
|
|
197
|
+
inputSchema: meta.inputSchema
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
this.logger.warn(`[mcp-bridge] Intent routing: failed to index tools from ${serverName}:`, err);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
await this.intentRouter.indexTools(allTools);
|
|
206
|
+
}
|
|
207
|
+
const match = await this.intentRouter.resolve(intent);
|
|
208
|
+
if (!match) {
|
|
209
|
+
return this.error("invalid_params", `No tool found matching intent: "${intent}"`);
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
action: "intent",
|
|
213
|
+
intent,
|
|
214
|
+
match: { server: match.server, tool: match.tool, score: match.score },
|
|
215
|
+
alternatives: match.alternatives
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
return this.error("mcp_error", `Intent resolution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
152
222
|
getStatus() {
|
|
153
223
|
const serverStatuses = Object.entries(this.servers).map(([name, config]) => {
|
|
154
224
|
const state = this.states.get(name);
|
|
@@ -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>;
|
|
@@ -28,6 +34,12 @@ export interface McpClientConfig {
|
|
|
28
34
|
enabled?: boolean;
|
|
29
35
|
maxDescriptionLength?: number;
|
|
30
36
|
};
|
|
37
|
+
intentRouting?: {
|
|
38
|
+
embedding?: "auto" | "gemini" | "openai" | "ollama" | "keyword";
|
|
39
|
+
model?: string;
|
|
40
|
+
minScore?: number;
|
|
41
|
+
};
|
|
42
|
+
maxResultChars?: number;
|
|
31
43
|
}
|
|
32
44
|
export interface McpTool {
|
|
33
45
|
name: string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface VectorMetadata {
|
|
2
|
+
server: string;
|
|
3
|
+
tool: string;
|
|
4
|
+
description: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class VectorStore {
|
|
7
|
+
private entries;
|
|
8
|
+
add(id: string, vector: number[], metadata: VectorMetadata): void;
|
|
9
|
+
search(queryVector: number[], topK: number): Array<{
|
|
10
|
+
id: string;
|
|
11
|
+
score: number;
|
|
12
|
+
metadata: VectorMetadata;
|
|
13
|
+
}>;
|
|
14
|
+
clear(): void;
|
|
15
|
+
size(): number;
|
|
16
|
+
}
|
|
17
|
+
export declare function cosineSimilarity(a: number[], b: number[]): number;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export class VectorStore {
|
|
2
|
+
entries = [];
|
|
3
|
+
add(id, vector, metadata) {
|
|
4
|
+
this.entries.push({ id, vector, metadata });
|
|
5
|
+
}
|
|
6
|
+
search(queryVector, topK) {
|
|
7
|
+
const scored = this.entries.map((entry) => ({
|
|
8
|
+
id: entry.id,
|
|
9
|
+
score: cosineSimilarity(queryVector, entry.vector),
|
|
10
|
+
metadata: entry.metadata
|
|
11
|
+
}));
|
|
12
|
+
scored.sort((a, b) => b.score - a.score);
|
|
13
|
+
return scored.slice(0, topK);
|
|
14
|
+
}
|
|
15
|
+
clear() {
|
|
16
|
+
this.entries = [];
|
|
17
|
+
}
|
|
18
|
+
size() {
|
|
19
|
+
return this.entries.length;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function cosineSimilarity(a, b) {
|
|
23
|
+
const len = Math.min(a.length, b.length);
|
|
24
|
+
let dot = 0;
|
|
25
|
+
let normA = 0;
|
|
26
|
+
let normB = 0;
|
|
27
|
+
for (let i = 0; i < len; i++) {
|
|
28
|
+
dot += a[i] * b[i];
|
|
29
|
+
normA += a[i] * a[i];
|
|
30
|
+
normB += b[i] * b[i];
|
|
31
|
+
}
|
|
32
|
+
// Handle any extra dimensions (vectors may differ in length for keyword embeddings)
|
|
33
|
+
for (let i = len; i < a.length; i++) {
|
|
34
|
+
normA += a[i] * a[i];
|
|
35
|
+
}
|
|
36
|
+
for (let i = len; i < b.length; i++) {
|
|
37
|
+
normB += b[i] * b[i];
|
|
38
|
+
}
|
|
39
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
40
|
+
if (denom === 0)
|
|
41
|
+
return 0;
|
|
42
|
+
return dot / denom;
|
|
43
|
+
}
|