@aiwerk/mcp-bridge 1.2.3 → 1.4.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 +32 -1
- 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 +21 -0
- package/dist/src/mcp-router.js +89 -2
- package/dist/src/schema-compression.d.ts +4 -0
- package/dist/src/schema-compression.js +21 -0
- package/dist/src/types.d.ts +9 -0
- package/dist/src/vector-store.d.ts +17 -0
- package/dist/src/vector-store.js +43 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,10 +115,38 @@ Config: `~/.mcp-bridge/config.json` | Secrets: `~/.mcp-bridge/.env`
|
|
|
115
115
|
},
|
|
116
116
|
"toolPrefix": true,
|
|
117
117
|
"connectionTimeoutMs": 5000,
|
|
118
|
-
"requestTimeoutMs": 60000
|
|
118
|
+
"requestTimeoutMs": 60000,
|
|
119
|
+
"schemaCompression": {
|
|
120
|
+
"enabled": true,
|
|
121
|
+
"maxDescriptionLength": 80
|
|
122
|
+
}
|
|
119
123
|
}
|
|
120
124
|
```
|
|
121
125
|
|
|
126
|
+
### Schema Compression
|
|
127
|
+
|
|
128
|
+
In router mode, tool descriptions from upstream servers can be verbose (100-300+ chars each). Schema compression truncates them to save tokens:
|
|
129
|
+
|
|
130
|
+
- **Enabled by default** — descriptions capped at 80 characters
|
|
131
|
+
- Cuts at sentence boundary when possible, otherwise word boundary
|
|
132
|
+
- Use `action=schema` to retrieve the full uncompressed schema for any tool on demand
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
"schemaCompression": {
|
|
136
|
+
"enabled": true,
|
|
137
|
+
"maxDescriptionLength": 80
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Token savings example:** 30 Todoist tools: ~2800 tokens uncompressed -> ~1200 compressed (~57% reduction).
|
|
142
|
+
|
|
143
|
+
To get full details for a specific tool:
|
|
144
|
+
```
|
|
145
|
+
mcp(server="todoist", action="schema", tool="find-tasks")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Set `"enabled": false` to disable compression and return full descriptions.
|
|
149
|
+
|
|
122
150
|
### Modes
|
|
123
151
|
|
|
124
152
|
| Mode | Tools exposed | Best for |
|
|
@@ -186,6 +214,9 @@ Built-in catalog with pre-configured servers:
|
|
|
186
214
|
| wise | stdio | International payments |
|
|
187
215
|
| tavily | stdio | AI-optimized web search |
|
|
188
216
|
| apify | streamable-http | Web scraping and automation |
|
|
217
|
+
| atlassian | stdio | Confluence and Jira |
|
|
218
|
+
| chrome-devtools | stdio | Chrome browser automation |
|
|
219
|
+
| hostinger | sse | Web hosting management |
|
|
189
220
|
|
|
190
221
|
```bash
|
|
191
222
|
mcp-bridge install todoist # Interactive setup with API key prompt
|
|
@@ -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
|
@@ -26,9 +26,28 @@ export type RouterDispatchResponse = {
|
|
|
26
26
|
action: "call";
|
|
27
27
|
tool: string;
|
|
28
28
|
result: any;
|
|
29
|
+
} | {
|
|
30
|
+
server: string;
|
|
31
|
+
action: "schema";
|
|
32
|
+
tool: string;
|
|
33
|
+
schema: any;
|
|
34
|
+
description: string;
|
|
29
35
|
} | {
|
|
30
36
|
action: "status";
|
|
31
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
|
+
}>;
|
|
32
51
|
} | {
|
|
33
52
|
error: RouterErrorCode;
|
|
34
53
|
message: string;
|
|
@@ -48,10 +67,12 @@ export declare class McpRouter {
|
|
|
48
67
|
private readonly idleTimeoutMs;
|
|
49
68
|
private readonly maxConcurrent;
|
|
50
69
|
private readonly states;
|
|
70
|
+
private intentRouter;
|
|
51
71
|
constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
|
|
52
72
|
static generateDescription(servers: Record<string, McpServerConfig>): string;
|
|
53
73
|
dispatch(server?: string, action?: string, tool?: string, params?: any): Promise<RouterDispatchResponse>;
|
|
54
74
|
getToolList(server: string): Promise<RouterToolHint[]>;
|
|
75
|
+
private resolveIntent;
|
|
55
76
|
private getStatus;
|
|
56
77
|
disconnectAll(): Promise<void>;
|
|
57
78
|
private ensureConnected;
|
package/dist/src/mcp-router.js
CHANGED
|
@@ -2,6 +2,9 @@ import { SseTransport } from "./transport-sse.js";
|
|
|
2
2
|
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
|
+
import { compressDescription } from "./schema-compression.js";
|
|
6
|
+
import { IntentRouter } from "./intent-router.js";
|
|
7
|
+
import { createEmbeddingProvider } from "./embeddings.js";
|
|
5
8
|
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
6
9
|
const DEFAULT_MAX_CONCURRENT = 5;
|
|
7
10
|
export class McpRouter {
|
|
@@ -12,6 +15,7 @@ export class McpRouter {
|
|
|
12
15
|
idleTimeoutMs;
|
|
13
16
|
maxConcurrent;
|
|
14
17
|
states = new Map();
|
|
18
|
+
intentRouter = null;
|
|
15
19
|
constructor(servers, clientConfig, logger, transportRefs) {
|
|
16
20
|
this.servers = servers;
|
|
17
21
|
this.clientConfig = clientConfig;
|
|
@@ -44,6 +48,14 @@ export class McpRouter {
|
|
|
44
48
|
if (normalizedAction === "status") {
|
|
45
49
|
return this.getStatus();
|
|
46
50
|
}
|
|
51
|
+
// Intent action: find server+tool from natural language
|
|
52
|
+
if (normalizedAction === "intent") {
|
|
53
|
+
const intent = params?.intent || tool;
|
|
54
|
+
if (!intent) {
|
|
55
|
+
return this.error("invalid_params", "intent string is required for action=intent");
|
|
56
|
+
}
|
|
57
|
+
return this.resolveIntent(intent);
|
|
58
|
+
}
|
|
47
59
|
if (!server) {
|
|
48
60
|
return this.error("invalid_params", "server is required");
|
|
49
61
|
}
|
|
@@ -59,11 +71,33 @@ export class McpRouter {
|
|
|
59
71
|
return this.error("connection_failed", `Failed to connect to ${server}: ${error instanceof Error ? error.message : String(error)}`);
|
|
60
72
|
}
|
|
61
73
|
}
|
|
74
|
+
if (normalizedAction === "schema") {
|
|
75
|
+
if (!tool) {
|
|
76
|
+
return this.error("invalid_params", "tool is required for action=schema");
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await this.getToolList(server);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return this.error("connection_failed", `Failed to connect to ${server}: ${error instanceof Error ? error.message : String(error)}`);
|
|
83
|
+
}
|
|
84
|
+
const state = this.states.get(server);
|
|
85
|
+
const fullTool = state.fullToolsMap?.get(tool);
|
|
86
|
+
if (!fullTool) {
|
|
87
|
+
return this.error("unknown_tool", `Tool '${tool}' not found on server '${server}'`, state.toolNames);
|
|
88
|
+
}
|
|
89
|
+
return { server, action: "schema", tool, schema: fullTool.inputSchema, description: fullTool.description };
|
|
90
|
+
}
|
|
62
91
|
if (normalizedAction === "refresh") {
|
|
63
92
|
try {
|
|
64
93
|
const state = await this.ensureConnected(server);
|
|
65
94
|
state.toolsCache = undefined;
|
|
95
|
+
state.fullToolsMap = undefined;
|
|
66
96
|
state.toolNames = [];
|
|
97
|
+
// Clear intent index so it re-indexes on next intent query
|
|
98
|
+
if (this.intentRouter) {
|
|
99
|
+
this.intentRouter.clearIndex();
|
|
100
|
+
}
|
|
67
101
|
const tools = await this.getToolList(server);
|
|
68
102
|
return { server, action: "refresh", refreshed: true, tools };
|
|
69
103
|
}
|
|
@@ -72,7 +106,7 @@ export class McpRouter {
|
|
|
72
106
|
}
|
|
73
107
|
}
|
|
74
108
|
if (normalizedAction !== "call") {
|
|
75
|
-
return this.error("invalid_params", `action must be one of: list, call, refresh`);
|
|
109
|
+
return this.error("invalid_params", `action must be one of: list, call, refresh, schema, intent`);
|
|
76
110
|
}
|
|
77
111
|
if (!tool) {
|
|
78
112
|
return this.error("invalid_params", "tool is required for action=call");
|
|
@@ -116,14 +150,65 @@ export class McpRouter {
|
|
|
116
150
|
}
|
|
117
151
|
const tools = await fetchToolsList(state.transport);
|
|
118
152
|
state.toolNames = tools.map((tool) => tool.name);
|
|
153
|
+
// Store full tool metadata for action=schema
|
|
154
|
+
state.fullToolsMap = new Map(tools.map((tool) => [tool.name, { description: tool.description || "", inputSchema: tool.inputSchema }]));
|
|
155
|
+
const compressionEnabled = this.clientConfig.schemaCompression?.enabled ?? true;
|
|
156
|
+
const maxLen = this.clientConfig.schemaCompression?.maxDescriptionLength ?? 80;
|
|
119
157
|
state.toolsCache = tools.map((tool) => ({
|
|
120
158
|
name: tool.name,
|
|
121
|
-
description:
|
|
159
|
+
description: compressionEnabled
|
|
160
|
+
? compressDescription(tool.description || "", maxLen)
|
|
161
|
+
: (tool.description || ""),
|
|
122
162
|
requiredParams: this.extractRequiredParams(tool)
|
|
123
163
|
}));
|
|
124
164
|
this.markUsed(server);
|
|
125
165
|
return state.toolsCache;
|
|
126
166
|
}
|
|
167
|
+
async resolveIntent(intent) {
|
|
168
|
+
try {
|
|
169
|
+
// Lazily create the intent router
|
|
170
|
+
if (!this.intentRouter) {
|
|
171
|
+
const routingConfig = this.clientConfig.intentRouting;
|
|
172
|
+
const embeddingType = routingConfig?.embedding ?? "auto";
|
|
173
|
+
const provider = createEmbeddingProvider(embeddingType, { model: routingConfig?.model }, this.logger);
|
|
174
|
+
this.intentRouter = new IntentRouter(provider, this.logger, routingConfig?.minScore);
|
|
175
|
+
}
|
|
176
|
+
// Index tools if not already done
|
|
177
|
+
if (!this.intentRouter.isIndexed()) {
|
|
178
|
+
const allTools = {};
|
|
179
|
+
for (const serverName of Object.keys(this.servers)) {
|
|
180
|
+
try {
|
|
181
|
+
await this.getToolList(serverName);
|
|
182
|
+
const state = this.states.get(serverName);
|
|
183
|
+
if (state?.fullToolsMap) {
|
|
184
|
+
allTools[serverName] = [...state.fullToolsMap.entries()].map(([name, meta]) => ({
|
|
185
|
+
name,
|
|
186
|
+
description: meta.description,
|
|
187
|
+
inputSchema: meta.inputSchema
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
this.logger.warn(`[mcp-bridge] Intent routing: failed to index tools from ${serverName}:`, err);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
await this.intentRouter.indexTools(allTools);
|
|
196
|
+
}
|
|
197
|
+
const match = await this.intentRouter.resolve(intent);
|
|
198
|
+
if (!match) {
|
|
199
|
+
return this.error("invalid_params", `No tool found matching intent: "${intent}"`);
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
action: "intent",
|
|
203
|
+
intent,
|
|
204
|
+
match: { server: match.server, tool: match.tool, score: match.score },
|
|
205
|
+
alternatives: match.alternatives
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
return this.error("mcp_error", `Intent resolution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
127
212
|
getStatus() {
|
|
128
213
|
const serverStatuses = Object.entries(this.servers).map(([name, config]) => {
|
|
129
214
|
const state = this.states.get(name);
|
|
@@ -213,6 +298,7 @@ export class McpRouter {
|
|
|
213
298
|
}
|
|
214
299
|
state.initialized = false;
|
|
215
300
|
state.toolsCache = undefined;
|
|
301
|
+
state.fullToolsMap = undefined;
|
|
216
302
|
state.toolNames = [];
|
|
217
303
|
}
|
|
218
304
|
markUsed(server) {
|
|
@@ -240,6 +326,7 @@ export class McpRouter {
|
|
|
240
326
|
return;
|
|
241
327
|
state.initialized = false;
|
|
242
328
|
state.toolsCache = undefined;
|
|
329
|
+
state.fullToolsMap = undefined;
|
|
243
330
|
state.toolNames = [];
|
|
244
331
|
};
|
|
245
332
|
if (serverConfig.transport === "sse") {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compress tool descriptions to reduce token usage in router tool listings.
|
|
3
|
+
*/
|
|
4
|
+
export function compressDescription(desc, maxLen = 80) {
|
|
5
|
+
if (desc.length <= maxLen) {
|
|
6
|
+
return desc;
|
|
7
|
+
}
|
|
8
|
+
// Try to cut at sentence boundary (". " before maxLen)
|
|
9
|
+
const searchArea = desc.slice(0, maxLen);
|
|
10
|
+
const sentenceEnd = searchArea.lastIndexOf(". ");
|
|
11
|
+
if (sentenceEnd > 0) {
|
|
12
|
+
return desc.slice(0, sentenceEnd + 1) + "\u2026";
|
|
13
|
+
}
|
|
14
|
+
// Fall back to word boundary
|
|
15
|
+
const lastSpace = searchArea.lastIndexOf(" ");
|
|
16
|
+
if (lastSpace > 0) {
|
|
17
|
+
return desc.slice(0, lastSpace) + "\u2026";
|
|
18
|
+
}
|
|
19
|
+
// No word boundary found, hard truncate
|
|
20
|
+
return desc.slice(0, maxLen) + "\u2026";
|
|
21
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -24,6 +24,15 @@ export interface McpClientConfig {
|
|
|
24
24
|
requestTimeoutMs?: number;
|
|
25
25
|
routerIdleTimeoutMs?: number;
|
|
26
26
|
routerMaxConcurrent?: number;
|
|
27
|
+
schemaCompression?: {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
maxDescriptionLength?: number;
|
|
30
|
+
};
|
|
31
|
+
intentRouting?: {
|
|
32
|
+
embedding?: "auto" | "gemini" | "openai" | "ollama" | "keyword";
|
|
33
|
+
model?: string;
|
|
34
|
+
minScore?: number;
|
|
35
|
+
};
|
|
27
36
|
}
|
|
28
37
|
export interface McpTool {
|
|
29
38
|
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
|
+
}
|