@aiwerk/mcp-bridge 1.3.0 → 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.
@@ -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
+ }
@@ -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;
@@ -3,6 +3,8 @@ 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";
6
8
  const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
7
9
  const DEFAULT_MAX_CONCURRENT = 5;
8
10
  export class McpRouter {
@@ -13,6 +15,7 @@ export class McpRouter {
13
15
  idleTimeoutMs;
14
16
  maxConcurrent;
15
17
  states = new Map();
18
+ intentRouter = null;
16
19
  constructor(servers, clientConfig, logger, transportRefs) {
17
20
  this.servers = servers;
18
21
  this.clientConfig = clientConfig;
@@ -45,6 +48,14 @@ export class McpRouter {
45
48
  if (normalizedAction === "status") {
46
49
  return this.getStatus();
47
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
+ }
48
59
  if (!server) {
49
60
  return this.error("invalid_params", "server is required");
50
61
  }
@@ -83,6 +94,10 @@ export class McpRouter {
83
94
  state.toolsCache = undefined;
84
95
  state.fullToolsMap = undefined;
85
96
  state.toolNames = [];
97
+ // Clear intent index so it re-indexes on next intent query
98
+ if (this.intentRouter) {
99
+ this.intentRouter.clearIndex();
100
+ }
86
101
  const tools = await this.getToolList(server);
87
102
  return { server, action: "refresh", refreshed: true, tools };
88
103
  }
@@ -91,7 +106,7 @@ export class McpRouter {
91
106
  }
92
107
  }
93
108
  if (normalizedAction !== "call") {
94
- return this.error("invalid_params", `action must be one of: list, call, refresh, schema`);
109
+ return this.error("invalid_params", `action must be one of: list, call, refresh, schema, intent`);
95
110
  }
96
111
  if (!tool) {
97
112
  return this.error("invalid_params", "tool is required for action=call");
@@ -149,6 +164,51 @@ export class McpRouter {
149
164
  this.markUsed(server);
150
165
  return state.toolsCache;
151
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
+ }
152
212
  getStatus() {
153
213
  const serverStatuses = Object.entries(this.servers).map(([name, config]) => {
154
214
  const state = this.states.get(name);
@@ -28,6 +28,11 @@ export interface McpClientConfig {
28
28
  enabled?: boolean;
29
29
  maxDescriptionLength?: number;
30
30
  };
31
+ intentRouting?: {
32
+ embedding?: "auto" | "gemini" | "openai" | "ollama" | "keyword";
33
+ model?: string;
34
+ minScore?: number;
35
+ };
31
36
  }
32
37
  export interface McpTool {
33
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Standalone MCP server that multiplexes multiple MCP servers into one interface",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",