@aiwerk/mcp-bridge 1.0.2 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -47
- package/dist/src/config.js +7 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +2 -0
- package/dist/src/mcp-router.d.ts +5 -5
- package/dist/src/protocol.js +1 -1
- package/dist/src/smart-filter.d.ts +129 -0
- package/dist/src/smart-filter.js +561 -0
- package/dist/src/standalone-server.js +6 -3
- package/dist/src/transport-base.d.ts +5 -5
- package/dist/src/transport-sse.js +11 -19
- package/dist/src/transport-streamable-http.js +15 -1
- package/dist/src/types.d.ts +5 -0
- package/dist/src/update-checker.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# @aiwerk/mcp-bridge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Multiplex multiple MCP servers into one interface. One config, one connection, all your tools.
|
|
4
|
+
|
|
5
|
+
Works with **Claude Desktop**, **Cursor**, **Windsurf**, **Cline**, **OpenClaw**, or any MCP client.
|
|
6
|
+
|
|
7
|
+
## Why?
|
|
8
|
+
|
|
9
|
+
Most AI agents connect to MCP servers one-by-one. With 10+ servers, that's 10+ connections, 200+ tools in context, and thousands of wasted tokens.
|
|
10
|
+
|
|
11
|
+
**MCP Bridge** solves this:
|
|
12
|
+
- **Router mode**: all servers behind one `mcp` meta-tool (~99% token reduction)
|
|
13
|
+
- **Direct mode**: all tools registered individually with automatic prefixing
|
|
14
|
+
- **3 transports**: stdio, SSE, streamable-http
|
|
15
|
+
- **Built-in catalog**: install popular servers with one command
|
|
16
|
+
- **Zero config secrets in files**: `${ENV_VAR}` resolution from `.env`
|
|
4
17
|
|
|
5
18
|
## Install
|
|
6
19
|
|
|
@@ -11,22 +24,62 @@ npm install -g @aiwerk/mcp-bridge
|
|
|
11
24
|
## Quick Start
|
|
12
25
|
|
|
13
26
|
```bash
|
|
14
|
-
# Initialize config
|
|
27
|
+
# 1. Initialize config
|
|
15
28
|
mcp-bridge init
|
|
16
29
|
|
|
17
|
-
#
|
|
18
|
-
vi ~/.mcp-bridge/config.json
|
|
19
|
-
|
|
20
|
-
# Install a server from the catalog
|
|
30
|
+
# 2. Install a server from the catalog
|
|
21
31
|
mcp-bridge install todoist
|
|
22
32
|
|
|
23
|
-
#
|
|
33
|
+
# 3. Add your API key
|
|
34
|
+
echo "TODOIST_API_TOKEN=your-token" >> ~/.mcp-bridge/.env
|
|
35
|
+
|
|
36
|
+
# 4. Start (stdio mode — connects to any MCP client)
|
|
24
37
|
mcp-bridge
|
|
25
38
|
```
|
|
26
39
|
|
|
40
|
+
## Use with Claude Desktop
|
|
41
|
+
|
|
42
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"bridge": {
|
|
48
|
+
"command": "mcp-bridge",
|
|
49
|
+
"args": []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Use with Cursor / Windsurf
|
|
56
|
+
|
|
57
|
+
Add to your MCP config:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"bridge": {
|
|
63
|
+
"command": "mcp-bridge",
|
|
64
|
+
"args": ["--config", "/path/to/config.json"]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Use with OpenClaw
|
|
71
|
+
|
|
72
|
+
Install as a plugin (handles everything automatically):
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
openclaw plugins install @aiwerk/openclaw-mcp-bridge
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
See [@aiwerk/openclaw-mcp-bridge](https://github.com/AIWerk/openclaw-mcp-bridge) for details.
|
|
79
|
+
|
|
27
80
|
## Configuration
|
|
28
81
|
|
|
29
|
-
Config
|
|
82
|
+
Config: `~/.mcp-bridge/config.json` | Secrets: `~/.mcp-bridge/.env`
|
|
30
83
|
|
|
31
84
|
```json
|
|
32
85
|
{
|
|
@@ -36,19 +89,22 @@ Config location: `~/.mcp-bridge/config.json`
|
|
|
36
89
|
"transport": "stdio",
|
|
37
90
|
"command": "npx",
|
|
38
91
|
"args": ["-y", "@doist/todoist-ai"],
|
|
39
|
-
"env": {
|
|
40
|
-
"TODOIST_API_KEY": "${TODOIST_API_TOKEN}"
|
|
41
|
-
},
|
|
92
|
+
"env": { "TODOIST_API_KEY": "${TODOIST_API_TOKEN}" },
|
|
42
93
|
"description": "Task management"
|
|
43
94
|
},
|
|
44
95
|
"github": {
|
|
45
96
|
"transport": "stdio",
|
|
46
|
-
"command": "
|
|
47
|
-
"args": ["
|
|
48
|
-
"env": {
|
|
49
|
-
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
|
|
50
|
-
},
|
|
97
|
+
"command": "npx",
|
|
98
|
+
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
99
|
+
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" },
|
|
51
100
|
"description": "GitHub repos, issues, PRs"
|
|
101
|
+
},
|
|
102
|
+
"notion": {
|
|
103
|
+
"transport": "stdio",
|
|
104
|
+
"command": "npx",
|
|
105
|
+
"args": ["-y", "@modelcontextprotocol/server-notion"],
|
|
106
|
+
"env": { "NOTION_API_KEY": "${NOTION_TOKEN}" },
|
|
107
|
+
"description": "Notion pages and databases"
|
|
52
108
|
}
|
|
53
109
|
},
|
|
54
110
|
"toolPrefix": true,
|
|
@@ -57,61 +113,123 @@ Config location: `~/.mcp-bridge/config.json`
|
|
|
57
113
|
}
|
|
58
114
|
```
|
|
59
115
|
|
|
60
|
-
|
|
116
|
+
### Modes
|
|
117
|
+
|
|
118
|
+
| Mode | Tools exposed | Best for |
|
|
119
|
+
|------|--------------|----------|
|
|
120
|
+
| `router` (default) | Single `mcp` meta-tool | 3+ servers, token-conscious agents |
|
|
121
|
+
| `direct` | All tools individually | Few servers, simple agents |
|
|
122
|
+
|
|
123
|
+
**Router mode** — the agent calls `mcp(server="todoist", action="list")` to discover, then `mcp(server="todoist", tool="find-tasks", params={...})` to execute.
|
|
124
|
+
|
|
125
|
+
**Direct mode** — tools are registered as `todoist_find_tasks`, `github_list_repos`, etc.
|
|
126
|
+
|
|
127
|
+
### Transports
|
|
128
|
+
|
|
129
|
+
| Transport | Config key | Use case |
|
|
130
|
+
|-----------|-----------|----------|
|
|
131
|
+
| `stdio` | `command`, `args` | Local CLI servers (most common) |
|
|
132
|
+
| `sse` | `url`, `headers` | Remote SSE servers |
|
|
133
|
+
| `streamable-http` | `url`, `headers` | Modern HTTP-based servers |
|
|
134
|
+
|
|
135
|
+
### Environment variables
|
|
136
|
+
|
|
137
|
+
Secrets go in `~/.mcp-bridge/.env` (chmod 600 on init):
|
|
61
138
|
|
|
62
139
|
```
|
|
63
140
|
TODOIST_API_TOKEN=your-token-here
|
|
64
141
|
GITHUB_TOKEN=ghp_xxxxx
|
|
142
|
+
NOTION_TOKEN=ntn_xxxxx
|
|
65
143
|
```
|
|
66
144
|
|
|
67
|
-
|
|
145
|
+
Use `${VAR_NAME}` in config — resolved from `.env` + system env.
|
|
68
146
|
|
|
69
|
-
|
|
147
|
+
## CLI Reference
|
|
70
148
|
|
|
71
|
-
|
|
149
|
+
```bash
|
|
150
|
+
mcp-bridge # Start in stdio mode (default)
|
|
151
|
+
mcp-bridge --sse --port 3000 # Start as SSE server
|
|
152
|
+
mcp-bridge --http --port 3000 # Start as HTTP server
|
|
153
|
+
mcp-bridge --verbose # Info-level logs to stderr
|
|
154
|
+
mcp-bridge --debug # Full protocol logs to stderr
|
|
155
|
+
mcp-bridge --config ./my.json # Custom config file
|
|
156
|
+
|
|
157
|
+
mcp-bridge init # Create ~/.mcp-bridge/ with template
|
|
158
|
+
mcp-bridge install <server> # Install from catalog
|
|
159
|
+
mcp-bridge catalog # List available servers
|
|
160
|
+
mcp-bridge servers # List configured servers
|
|
161
|
+
mcp-bridge search <query> # Search catalog by keyword
|
|
162
|
+
mcp-bridge update [--check] # Check for / install updates
|
|
163
|
+
mcp-bridge --version # Print version
|
|
164
|
+
```
|
|
72
165
|
|
|
73
|
-
##
|
|
166
|
+
## Server Catalog
|
|
74
167
|
|
|
75
|
-
|
|
168
|
+
Built-in catalog with pre-configured servers:
|
|
76
169
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
170
|
+
| Server | Transport | Description |
|
|
171
|
+
|--------|-----------|-------------|
|
|
172
|
+
| todoist | stdio | Task management |
|
|
173
|
+
| github | stdio | Repos, issues, PRs |
|
|
174
|
+
| notion | stdio | Pages and databases |
|
|
175
|
+
| stripe | stdio | Payments and billing |
|
|
176
|
+
| linear | stdio | Project management |
|
|
177
|
+
| google-maps | stdio | Places, geocoding, directions |
|
|
178
|
+
| hetzner | stdio | Cloud infrastructure |
|
|
179
|
+
| miro | stdio | Collaborative whiteboard |
|
|
180
|
+
| wise | stdio | International payments |
|
|
181
|
+
| tavily | stdio | AI-optimized web search |
|
|
182
|
+
| apify | streamable-http | Web scraping and automation |
|
|
89
183
|
|
|
90
184
|
```bash
|
|
91
|
-
mcp-bridge
|
|
92
|
-
mcp-bridge
|
|
93
|
-
mcp-bridge
|
|
94
|
-
mcp-bridge --config ./config.json # custom config file
|
|
95
|
-
|
|
96
|
-
mcp-bridge init # create ~/.mcp-bridge/ with template
|
|
97
|
-
mcp-bridge install <server> # install from catalog
|
|
98
|
-
mcp-bridge catalog # list available servers
|
|
99
|
-
mcp-bridge servers # list configured servers
|
|
100
|
-
mcp-bridge search <query> # search catalog
|
|
101
|
-
mcp-bridge update --check # check for updates
|
|
102
|
-
mcp-bridge update # install updates
|
|
185
|
+
mcp-bridge install todoist # Interactive setup with API key prompt
|
|
186
|
+
mcp-bridge catalog # Full list
|
|
187
|
+
mcp-bridge search payments # Search by keyword
|
|
103
188
|
```
|
|
104
189
|
|
|
105
190
|
## Library Usage
|
|
106
191
|
|
|
192
|
+
Use as a dependency in your own MCP server or OpenClaw plugin:
|
|
193
|
+
|
|
107
194
|
```typescript
|
|
108
195
|
import { McpRouter, StandaloneServer, loadConfig } from "@aiwerk/mcp-bridge";
|
|
109
196
|
|
|
197
|
+
// Quick start
|
|
110
198
|
const config = loadConfig({ configPath: "./config.json" });
|
|
111
199
|
const server = new StandaloneServer(config, console);
|
|
112
200
|
await server.startStdio();
|
|
113
201
|
```
|
|
114
202
|
|
|
203
|
+
```typescript
|
|
204
|
+
// Use the router directly
|
|
205
|
+
import { McpRouter } from "@aiwerk/mcp-bridge";
|
|
206
|
+
|
|
207
|
+
const router = new McpRouter(servers, config, logger);
|
|
208
|
+
const result = await router.dispatch("todoist", "call", "find-tasks", { query: "today" });
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Architecture
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
┌─────────────────┐ ┌──────────────────────────────────────┐
|
|
215
|
+
│ Claude Desktop │ │ MCP Bridge │
|
|
216
|
+
│ Cursor │◄───►│ │
|
|
217
|
+
│ Windsurf │stdio│ ┌─────────┐ ┌──────────────────┐ │
|
|
218
|
+
│ OpenClaw │ │ │ Router / │ │ Backend servers: │ │
|
|
219
|
+
│ Any MCP client │ │ │ Direct │──│ • todoist (stdio) │ │
|
|
220
|
+
└─────────────────┘ │ │ mode │ │ • github (stdio) │ │
|
|
221
|
+
│ └─────────┘ │ • notion (stdio) │ │
|
|
222
|
+
│ │ • stripe (sse) │ │
|
|
223
|
+
│ └──────────────────┘ │
|
|
224
|
+
└──────────────────────────────────────┘
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Related
|
|
228
|
+
|
|
229
|
+
- **[@aiwerk/openclaw-mcp-bridge](https://github.com/AIWerk/openclaw-mcp-bridge)** — OpenClaw plugin wrapper (uses this package as core)
|
|
230
|
+
- **[MCP Specification](https://spec.modelcontextprotocol.io)** — Model Context Protocol spec
|
|
231
|
+
- **[Awesome MCP Servers](https://github.com/punkpeye/awesome-mcp-servers)** — Community server directory
|
|
232
|
+
|
|
115
233
|
## License
|
|
116
234
|
|
|
117
|
-
MIT
|
|
235
|
+
MIT — [AIWerk](https://aiwerk.ch)
|
package/dist/src/config.js
CHANGED
|
@@ -100,7 +100,13 @@ export function loadConfig(options = {}) {
|
|
|
100
100
|
}
|
|
101
101
|
/** Get the default config directory path. */
|
|
102
102
|
export function getConfigDir(configPath) {
|
|
103
|
-
|
|
103
|
+
if (!configPath)
|
|
104
|
+
return DEFAULT_CONFIG_DIR;
|
|
105
|
+
// If path ends with separator or has no extension, treat as directory
|
|
106
|
+
if (configPath.endsWith("/") || configPath.endsWith("\\") || !configPath.includes(".")) {
|
|
107
|
+
return configPath;
|
|
108
|
+
}
|
|
109
|
+
return join(configPath, "..");
|
|
104
110
|
}
|
|
105
111
|
/** Initialize the config directory with template files. */
|
|
106
112
|
export function initConfigDir(logger) {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -13,3 +13,4 @@ export { pickRegisteredToolName } from "./tool-naming.js";
|
|
|
13
13
|
export { StandaloneServer } from "./standalone-server.js";
|
|
14
14
|
export { checkForUpdate, getUpdateNotice, runUpdate, resetNoticeFlag } from "./update-checker.js";
|
|
15
15
|
export type { UpdateInfo } from "./update-checker.js";
|
|
16
|
+
export { filterServers, buildFilteredDescription } from "./smart-filter.js";
|
package/dist/src/index.js
CHANGED
|
@@ -19,3 +19,5 @@ export { pickRegisteredToolName } from "./tool-naming.js";
|
|
|
19
19
|
export { StandaloneServer } from "./standalone-server.js";
|
|
20
20
|
// Update checker
|
|
21
21
|
export { checkForUpdate, getUpdateNotice, runUpdate, resetNoticeFlag } from "./update-checker.js";
|
|
22
|
+
// Smart filter
|
|
23
|
+
export { filterServers, buildFilteredDescription } from "./smart-filter.js";
|
package/dist/src/mcp-router.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { McpClientConfig, McpServerConfig, McpTransport } from "./types.js";
|
|
1
|
+
import { McpClientConfig, McpServerConfig, McpTransport, Logger } from "./types.js";
|
|
2
2
|
type RouterErrorCode = "unknown_server" | "unknown_tool" | "connection_failed" | "mcp_error" | "invalid_params";
|
|
3
3
|
export interface RouterToolHint {
|
|
4
4
|
name: string;
|
|
@@ -36,9 +36,9 @@ export type RouterDispatchResponse = {
|
|
|
36
36
|
code?: number;
|
|
37
37
|
};
|
|
38
38
|
export interface RouterTransportRefs {
|
|
39
|
-
sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger:
|
|
40
|
-
stdio: new (config: McpServerConfig, clientConfig: McpClientConfig, logger:
|
|
41
|
-
streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger:
|
|
39
|
+
sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>) => McpTransport;
|
|
40
|
+
stdio: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>) => McpTransport;
|
|
41
|
+
streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>) => McpTransport;
|
|
42
42
|
}
|
|
43
43
|
export declare class McpRouter {
|
|
44
44
|
private readonly servers;
|
|
@@ -48,7 +48,7 @@ export declare class McpRouter {
|
|
|
48
48
|
private readonly idleTimeoutMs;
|
|
49
49
|
private readonly maxConcurrent;
|
|
50
50
|
private readonly states;
|
|
51
|
-
constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger:
|
|
51
|
+
constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
|
|
52
52
|
static generateDescription(servers: Record<string, McpServerConfig>): string;
|
|
53
53
|
dispatch(server?: string, action?: string, tool?: string, params?: any): Promise<RouterDispatchResponse>;
|
|
54
54
|
getToolList(server: string): Promise<RouterToolHint[]>;
|
package/dist/src/protocol.js
CHANGED
|
@@ -5,7 +5,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
5
5
|
const __dirname = dirname(__filename);
|
|
6
6
|
export const PACKAGE_VERSION = (() => {
|
|
7
7
|
try {
|
|
8
|
-
return JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version;
|
|
8
|
+
return JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8")).version;
|
|
9
9
|
}
|
|
10
10
|
catch {
|
|
11
11
|
return "0.0.0";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Filter v2 - Phase 1: Keyword-based filtering
|
|
3
|
+
* Zero external dependencies, graceful degradation
|
|
4
|
+
*/
|
|
5
|
+
import type { Logger, McpServerConfig, McpTool } from "./types.js";
|
|
6
|
+
/** Smart filter configuration for router mode. */
|
|
7
|
+
export interface SmartFilterConfig {
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
embedding?: "auto" | "ollama" | "openai" | "gemini" | "keyword";
|
|
10
|
+
topServers?: number;
|
|
11
|
+
hardCap?: number;
|
|
12
|
+
topTools?: number;
|
|
13
|
+
serverThreshold?: number;
|
|
14
|
+
toolThreshold?: number;
|
|
15
|
+
fallback?: "keyword";
|
|
16
|
+
alwaysInclude?: string[];
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
telemetry?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Extended server config with optional keywords for smart filter. */
|
|
21
|
+
export interface PluginServerConfig extends McpServerConfig {
|
|
22
|
+
keywords?: string[];
|
|
23
|
+
}
|
|
24
|
+
export type OpenClawLogger = Logger;
|
|
25
|
+
export interface FilterableServer {
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
keywords: string[];
|
|
29
|
+
tools: McpTool[];
|
|
30
|
+
}
|
|
31
|
+
export interface FilterResult {
|
|
32
|
+
servers: FilterableServer[];
|
|
33
|
+
tools: Array<{
|
|
34
|
+
serverId: string;
|
|
35
|
+
tool: McpTool;
|
|
36
|
+
}>;
|
|
37
|
+
metadata: {
|
|
38
|
+
queryUsed: string;
|
|
39
|
+
totalServersBeforeFilter: number;
|
|
40
|
+
totalToolsBeforeFilter: number;
|
|
41
|
+
filterMode: "keyword" | "disabled";
|
|
42
|
+
timeoutOccurred: boolean;
|
|
43
|
+
confidenceScore?: number;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export interface UserTurn {
|
|
47
|
+
content: string;
|
|
48
|
+
timestamp: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Smart Filter implementation - Phase 1
|
|
52
|
+
*/
|
|
53
|
+
export declare class SmartFilter {
|
|
54
|
+
private config;
|
|
55
|
+
private logger;
|
|
56
|
+
constructor(config: SmartFilterConfig, logger: OpenClawLogger);
|
|
57
|
+
/**
|
|
58
|
+
* Main filter entry point
|
|
59
|
+
*/
|
|
60
|
+
filter(servers: Record<string, PluginServerConfig>, allTools: Map<string, McpTool[]>, userTurns: UserTurn[]): Promise<FilterResult>;
|
|
61
|
+
private performFilter;
|
|
62
|
+
/**
|
|
63
|
+
* Extract meaningful intent from last 1-3 user turns
|
|
64
|
+
*/
|
|
65
|
+
private synthesizeQuery;
|
|
66
|
+
private extractMeaningfulContent;
|
|
67
|
+
private prepareFilterableServers;
|
|
68
|
+
private normalizeKeywords;
|
|
69
|
+
/**
|
|
70
|
+
* Score servers using weighted overlap scoring
|
|
71
|
+
*/
|
|
72
|
+
private scoreServers;
|
|
73
|
+
private tokenize;
|
|
74
|
+
private calculateServerScore;
|
|
75
|
+
private getSemanticScore;
|
|
76
|
+
private countOverlap;
|
|
77
|
+
/**
|
|
78
|
+
* Select servers using dynamic topServers with confidence-based expansion
|
|
79
|
+
*/
|
|
80
|
+
private selectServers;
|
|
81
|
+
/**
|
|
82
|
+
* Filter tools within selected servers
|
|
83
|
+
*/
|
|
84
|
+
private filterTools;
|
|
85
|
+
private calculateToolScore;
|
|
86
|
+
private calculateConfidenceScore;
|
|
87
|
+
private createUnfilteredResult;
|
|
88
|
+
private logTelemetry;
|
|
89
|
+
}
|
|
90
|
+
export declare const DEFAULTS: Required<SmartFilterConfig>;
|
|
91
|
+
/** Lowercase, split on whitespace + punctuation, preserve numbers, drop empties. */
|
|
92
|
+
export declare function tokenize(text: string): string[];
|
|
93
|
+
/** Normalize keywords: lowercase, trim, dedup, strip empties, cap at MAX_KEYWORDS. */
|
|
94
|
+
export declare function validateKeywords(raw: string[]): string[];
|
|
95
|
+
/**
|
|
96
|
+
* Extract a meaningful intent string from the last 1-3 user turns.
|
|
97
|
+
* Returns null if no meaningful query can be extracted.
|
|
98
|
+
*/
|
|
99
|
+
export declare function synthesizeQuery(userTurns: string[]): string | null;
|
|
100
|
+
export interface ServerScore {
|
|
101
|
+
name: string;
|
|
102
|
+
score: number;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Score a single server against a query using weighted word overlap.
|
|
106
|
+
* desc_matches * 1.0 + kw_only_matches * 0.5, normalized by query length.
|
|
107
|
+
*/
|
|
108
|
+
export declare function scoreServer(queryTokens: string[], serverName: string, description: string, keywords: string[]): number;
|
|
109
|
+
/** Score all servers, return sorted highest-first. */
|
|
110
|
+
export declare function scoreAllServers(queryTokens: string[], servers: Record<string, PluginServerConfig>): ServerScore[];
|
|
111
|
+
/**
|
|
112
|
+
* Select top servers with dynamic expansion toward hardCap.
|
|
113
|
+
* If top score < threshold AND gap small → show all (true uncertainty).
|
|
114
|
+
*/
|
|
115
|
+
export declare function selectTopServers(scores: ServerScore[], topServers: number, hardCap: number, threshold: number, alwaysInclude: string[]): string[];
|
|
116
|
+
export interface SmartFilterResult {
|
|
117
|
+
filteredServers: string[];
|
|
118
|
+
allServers: string[];
|
|
119
|
+
query: string | null;
|
|
120
|
+
scores: ServerScore[];
|
|
121
|
+
reason: "filtered" | "no-query" | "timeout" | "error" | "disabled";
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Run the smart filter. Returns the list of server names to include.
|
|
125
|
+
* Guarantees: never throws, never blocks longer than timeoutMs.
|
|
126
|
+
*/
|
|
127
|
+
export declare function filterServers(servers: Record<string, PluginServerConfig>, userTurns: string[], config: SmartFilterConfig, logger?: OpenClawLogger): SmartFilterResult;
|
|
128
|
+
/** Build a filtered router tool description string. */
|
|
129
|
+
export declare function buildFilteredDescription(allServers: Record<string, PluginServerConfig>, filteredNames: string[]): string;
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Filter v2 - Phase 1: Keyword-based filtering
|
|
3
|
+
* Zero external dependencies, graceful degradation
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Smart Filter implementation - Phase 1
|
|
7
|
+
*/
|
|
8
|
+
export class SmartFilter {
|
|
9
|
+
config;
|
|
10
|
+
logger;
|
|
11
|
+
constructor(config, logger) {
|
|
12
|
+
// Apply defaults
|
|
13
|
+
this.config = {
|
|
14
|
+
enabled: config.enabled ?? false,
|
|
15
|
+
embedding: config.embedding ?? "auto",
|
|
16
|
+
topServers: config.topServers ?? 5,
|
|
17
|
+
hardCap: config.hardCap ?? 8,
|
|
18
|
+
topTools: config.topTools ?? 10,
|
|
19
|
+
serverThreshold: config.serverThreshold ?? 0.01, // Very low threshold for maximum recall
|
|
20
|
+
toolThreshold: config.toolThreshold ?? 0.05, // Much lower threshold for better recall
|
|
21
|
+
fallback: config.fallback ?? "keyword",
|
|
22
|
+
alwaysInclude: config.alwaysInclude ?? [],
|
|
23
|
+
timeoutMs: config.timeoutMs ?? 500,
|
|
24
|
+
telemetry: config.telemetry ?? false,
|
|
25
|
+
};
|
|
26
|
+
this.logger = logger;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Main filter entry point
|
|
30
|
+
*/
|
|
31
|
+
async filter(servers, allTools, userTurns) {
|
|
32
|
+
if (!this.config.enabled) {
|
|
33
|
+
return this.createUnfilteredResult(servers, allTools, "disabled");
|
|
34
|
+
}
|
|
35
|
+
const startTime = Date.now();
|
|
36
|
+
let timeoutOccurred = false;
|
|
37
|
+
try {
|
|
38
|
+
// Set up timeout
|
|
39
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
40
|
+
setTimeout(() => {
|
|
41
|
+
timeoutOccurred = true;
|
|
42
|
+
this.logger.warn(`[smart-filter] Filter timeout after ${this.config.timeoutMs}ms, falling back to show all`);
|
|
43
|
+
resolve(this.createUnfilteredResult(servers, allTools, "keyword"));
|
|
44
|
+
}, this.config.timeoutMs);
|
|
45
|
+
});
|
|
46
|
+
const filterPromise = this.performFilter(servers, allTools, userTurns);
|
|
47
|
+
const result = await Promise.race([filterPromise, timeoutPromise]);
|
|
48
|
+
result.metadata.timeoutOccurred = timeoutOccurred;
|
|
49
|
+
const duration = Date.now() - startTime;
|
|
50
|
+
if (this.config.telemetry) {
|
|
51
|
+
this.logTelemetry(result, duration);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
this.logger.warn(`[smart-filter] Filter failed: ${error instanceof Error ? error.message : String(error)}, falling back to show all`);
|
|
57
|
+
const result = this.createUnfilteredResult(servers, allTools, "keyword");
|
|
58
|
+
result.metadata.timeoutOccurred = timeoutOccurred;
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async performFilter(servers, allTools, userTurns) {
|
|
63
|
+
// Step 1: Query synthesis
|
|
64
|
+
const query = this.synthesizeQuery(userTurns);
|
|
65
|
+
if (!query) {
|
|
66
|
+
this.logger.debug("[smart-filter] No meaningful query found, showing all servers");
|
|
67
|
+
return this.createUnfilteredResult(servers, allTools, "keyword", "");
|
|
68
|
+
}
|
|
69
|
+
// Step 2: Prepare filterable servers
|
|
70
|
+
const filterableServers = this.prepareFilterableServers(servers, allTools);
|
|
71
|
+
// Step 3: Level 1 - Server filtering
|
|
72
|
+
const serverScores = this.scoreServers(query, filterableServers);
|
|
73
|
+
const selectedServers = this.selectServers(serverScores, filterableServers);
|
|
74
|
+
// Step 4: Level 2 - Tool filtering
|
|
75
|
+
const toolResults = this.filterTools(query, selectedServers);
|
|
76
|
+
return {
|
|
77
|
+
servers: selectedServers.map(s => s.server),
|
|
78
|
+
tools: toolResults,
|
|
79
|
+
metadata: {
|
|
80
|
+
queryUsed: query,
|
|
81
|
+
totalServersBeforeFilter: Object.keys(servers).length,
|
|
82
|
+
totalToolsBeforeFilter: Array.from(allTools.values()).flat().length,
|
|
83
|
+
filterMode: "keyword",
|
|
84
|
+
timeoutOccurred: false,
|
|
85
|
+
confidenceScore: this.calculateConfidenceScore(serverScores),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Extract meaningful intent from last 1-3 user turns
|
|
91
|
+
*/
|
|
92
|
+
synthesizeQuery(userTurns) {
|
|
93
|
+
if (!userTurns || userTurns.length === 0) {
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
// Take last 1-3 turns, newest first
|
|
97
|
+
const recentTurns = userTurns
|
|
98
|
+
.slice(-3)
|
|
99
|
+
.reverse()
|
|
100
|
+
.map(turn => turn.content.trim());
|
|
101
|
+
for (const content of recentTurns) {
|
|
102
|
+
const cleanedQuery = this.extractMeaningfulContent(content);
|
|
103
|
+
if (cleanedQuery.length >= 3) {
|
|
104
|
+
return cleanedQuery;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// If all recent turns are too short, try combining them
|
|
108
|
+
const combined = recentTurns
|
|
109
|
+
.map(content => this.extractMeaningfulContent(content))
|
|
110
|
+
.filter(content => content.length > 0)
|
|
111
|
+
.join(" ")
|
|
112
|
+
.trim();
|
|
113
|
+
return combined.length >= 3 ? combined : "";
|
|
114
|
+
}
|
|
115
|
+
extractMeaningfulContent(content) {
|
|
116
|
+
// Remove metadata patterns
|
|
117
|
+
const cleaned = content
|
|
118
|
+
.replace(/\[.*?\]/g, "") // [timestamps], [commands]
|
|
119
|
+
.replace(/^\s*[>]*\s*/gm, "") // quote markers
|
|
120
|
+
.replace(/^\s*[-*•]\s*/gm, "") // list markers
|
|
121
|
+
.trim();
|
|
122
|
+
// Filter out noise words/confirmations
|
|
123
|
+
const noisePatterns = [
|
|
124
|
+
/^(yes|no|ok|okay|sure|thanks?|thank you)\.?$/i,
|
|
125
|
+
/^(do it|go ahead|proceed)\.?$/i,
|
|
126
|
+
/^(yes,?\s+(do it|go ahead|proceed))\.?$/i,
|
|
127
|
+
/^\?+$/,
|
|
128
|
+
/^\.+$/,
|
|
129
|
+
/^!+$/,
|
|
130
|
+
];
|
|
131
|
+
if (noisePatterns.some(pattern => pattern.test(cleaned))) {
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
// Remove trailing "please" and other politeness words
|
|
135
|
+
const withoutPoliteness = cleaned
|
|
136
|
+
.replace(/\s+please\.?$/i, "")
|
|
137
|
+
.replace(/\s+thanks?\.?$/i, "")
|
|
138
|
+
.trim();
|
|
139
|
+
return withoutPoliteness;
|
|
140
|
+
}
|
|
141
|
+
prepareFilterableServers(servers, allTools) {
|
|
142
|
+
return Object.entries(servers).map(([name, config]) => ({
|
|
143
|
+
name,
|
|
144
|
+
description: config.description || "",
|
|
145
|
+
keywords: this.normalizeKeywords(config.keywords || []),
|
|
146
|
+
tools: allTools.get(name) || [],
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
normalizeKeywords(keywords) {
|
|
150
|
+
return keywords
|
|
151
|
+
.slice(0, 30) // Max 30 keywords
|
|
152
|
+
.map(kw => kw.toLowerCase().trim())
|
|
153
|
+
.filter(kw => kw.length > 0)
|
|
154
|
+
.filter((kw, index, arr) => arr.indexOf(kw) === index); // Deduplicate
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Score servers using weighted overlap scoring
|
|
158
|
+
*/
|
|
159
|
+
scoreServers(query, servers) {
|
|
160
|
+
const queryWords = this.tokenize(query.toLowerCase());
|
|
161
|
+
return servers.map(server => ({
|
|
162
|
+
server,
|
|
163
|
+
score: this.calculateServerScore(queryWords, server),
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
tokenize(text) {
|
|
167
|
+
return text
|
|
168
|
+
.toLowerCase()
|
|
169
|
+
.replace(/[^\w\s]/g, " ")
|
|
170
|
+
.split(/\s+/)
|
|
171
|
+
.filter(word => word.length > 0);
|
|
172
|
+
}
|
|
173
|
+
calculateServerScore(queryWords, server) {
|
|
174
|
+
if (queryWords.length === 0)
|
|
175
|
+
return 0;
|
|
176
|
+
const descriptionWords = this.tokenize(server.description);
|
|
177
|
+
const keywordWords = server.keywords;
|
|
178
|
+
const allServerWords = [...descriptionWords, ...keywordWords];
|
|
179
|
+
// Calculate overlaps
|
|
180
|
+
const descMatches = this.countOverlap(queryWords, descriptionWords);
|
|
181
|
+
// Count keyword matches that are NOT already counted in description
|
|
182
|
+
const keywordOnlyWords = keywordWords.filter(kw => !descriptionWords.includes(kw));
|
|
183
|
+
const keywordOnlyMatches = this.countOverlap(queryWords, keywordOnlyWords);
|
|
184
|
+
// Add basic synonym matching for common terms
|
|
185
|
+
let semanticMatches = 0;
|
|
186
|
+
for (const queryWord of queryWords) {
|
|
187
|
+
semanticMatches += this.getSemanticScore(queryWord, allServerWords);
|
|
188
|
+
}
|
|
189
|
+
// Also check for partial/substring matches for better recall
|
|
190
|
+
let partialMatches = 0;
|
|
191
|
+
for (const queryWord of queryWords) {
|
|
192
|
+
for (const serverWord of allServerWords) {
|
|
193
|
+
if (queryWord.length > 3 && serverWord.includes(queryWord)) {
|
|
194
|
+
partialMatches += 0.3; // Partial match gets partial credit
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Weighted scoring: description 1.0x, keywords 0.7x, semantic 0.5x, partial matches 0.3x
|
|
199
|
+
const score = (descMatches * 1.0 + keywordOnlyMatches * 0.7 + semanticMatches * 0.5 + partialMatches) / queryWords.length;
|
|
200
|
+
return score;
|
|
201
|
+
}
|
|
202
|
+
getSemanticScore(queryWord, serverWords) {
|
|
203
|
+
// Comprehensive synonym/semantic matching
|
|
204
|
+
const synonymMap = {
|
|
205
|
+
// Finance/payment terms
|
|
206
|
+
money: ["payment", "transfer", "currency", "invoice", "billing", "charge", "account", "balance"],
|
|
207
|
+
payment: ["money", "transfer", "invoice", "billing", "charge", "process"],
|
|
208
|
+
send: ["transfer", "payment", "international"],
|
|
209
|
+
transfer: ["send", "payment", "money", "international"],
|
|
210
|
+
invoice: ["bill", "charge", "payment", "billing", "customer"],
|
|
211
|
+
account: ["balance", "money", "payment"],
|
|
212
|
+
balance: ["account", "money"],
|
|
213
|
+
international: ["transfer", "money", "payment"],
|
|
214
|
+
// Task/productivity terms
|
|
215
|
+
task: ["todo", "reminder", "project", "management", "productivity"],
|
|
216
|
+
todo: ["task", "reminder", "management"],
|
|
217
|
+
create: ["add", "new", "task", "issue"],
|
|
218
|
+
project: ["task", "management", "board", "productivity"],
|
|
219
|
+
manage: ["task", "project", "productivity"],
|
|
220
|
+
schedule: ["meeting", "calendar", "appointment"],
|
|
221
|
+
meeting: ["schedule", "calendar"],
|
|
222
|
+
// Development terms
|
|
223
|
+
code: ["repo", "repository", "commit", "branch", "github"],
|
|
224
|
+
issue: ["bug", "ticket", "github", "repository"],
|
|
225
|
+
bug: ["issue", "github"],
|
|
226
|
+
repository: ["repo", "code", "github"],
|
|
227
|
+
commit: ["code", "repository", "github"],
|
|
228
|
+
// Location/maps terms
|
|
229
|
+
location: ["map", "address", "directions", "geocode", "places"],
|
|
230
|
+
directions: ["map", "route", "location"],
|
|
231
|
+
address: ["location", "geocode"],
|
|
232
|
+
geocode: ["address", "location"],
|
|
233
|
+
restaurant: ["location", "places", "map"],
|
|
234
|
+
nearby: ["location", "map"],
|
|
235
|
+
// Storage/document terms
|
|
236
|
+
upload: ["store", "save", "file", "document"],
|
|
237
|
+
document: ["file", "note", "upload", "storage"],
|
|
238
|
+
store: ["save", "upload", "note"],
|
|
239
|
+
notes: ["document", "store"],
|
|
240
|
+
// Infrastructure terms
|
|
241
|
+
deploy: ["infrastructure", "cloud", "server"],
|
|
242
|
+
cloud: ["infrastructure", "deploy"],
|
|
243
|
+
server: ["infrastructure", "monitoring"],
|
|
244
|
+
infrastructure: ["cloud", "server", "deploy"],
|
|
245
|
+
monitoring: ["server", "infrastructure"],
|
|
246
|
+
// Collaboration terms
|
|
247
|
+
whiteboard: ["collaboration", "brainstorming"],
|
|
248
|
+
brainstorming: ["whiteboard", "collaboration"],
|
|
249
|
+
collaboration: ["whiteboard", "design"],
|
|
250
|
+
// Search terms
|
|
251
|
+
search: ["find", "information", "papers"],
|
|
252
|
+
find: ["search", "information"],
|
|
253
|
+
information: ["search", "find"],
|
|
254
|
+
// Web scraping terms
|
|
255
|
+
analyze: ["data", "extract", "website"],
|
|
256
|
+
extract: ["data", "scraping", "website"],
|
|
257
|
+
website: ["scraping", "analyze", "extract"],
|
|
258
|
+
data: ["extract", "analyze", "scraping"],
|
|
259
|
+
traffic: ["website", "analyze"],
|
|
260
|
+
};
|
|
261
|
+
const synonyms = synonymMap[queryWord.toLowerCase()] || [];
|
|
262
|
+
let matches = 0;
|
|
263
|
+
for (const synonym of synonyms) {
|
|
264
|
+
if (serverWords.includes(synonym)) {
|
|
265
|
+
matches += 1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return matches;
|
|
269
|
+
}
|
|
270
|
+
countOverlap(words1, words2) {
|
|
271
|
+
const set2 = new Set(words2);
|
|
272
|
+
return words1.filter(word => set2.has(word)).length;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Select servers using dynamic topServers with confidence-based expansion
|
|
276
|
+
*/
|
|
277
|
+
selectServers(serverScores, allServers) {
|
|
278
|
+
// Include always-included servers first
|
|
279
|
+
const alwaysIncluded = allServers
|
|
280
|
+
.filter(s => this.config.alwaysInclude.includes(s.name))
|
|
281
|
+
.map(server => ({ server, score: 1.0 }));
|
|
282
|
+
// Sort all servers by score
|
|
283
|
+
const allScoredServers = serverScores
|
|
284
|
+
.filter(({ server }) => !this.config.alwaysInclude.includes(server.name))
|
|
285
|
+
.sort((a, b) => b.score - a.score);
|
|
286
|
+
// Primary filter: servers that meet threshold
|
|
287
|
+
const thresholdServers = allScoredServers.filter(({ score }) => score >= this.config.serverThreshold);
|
|
288
|
+
// Fallback: if too few servers pass threshold, include more based on ranking
|
|
289
|
+
let scoredServers = thresholdServers;
|
|
290
|
+
if (thresholdServers.length < 2) {
|
|
291
|
+
// Take at least top 3 servers regardless of threshold for better recall
|
|
292
|
+
scoredServers = allScoredServers.slice(0, Math.max(3, this.config.topServers));
|
|
293
|
+
this.logger.debug(`[smart-filter] Only ${thresholdServers.length} servers met threshold, expanding to top ${scoredServers.length}`);
|
|
294
|
+
}
|
|
295
|
+
// Dynamic topServers based on confidence
|
|
296
|
+
let numServers = this.config.topServers;
|
|
297
|
+
if (scoredServers.length >= 2) {
|
|
298
|
+
const topScore = scoredServers[0].score;
|
|
299
|
+
const cutoffScore = scoredServers[Math.min(this.config.topServers - 1, scoredServers.length - 1)].score;
|
|
300
|
+
const gap = topScore - cutoffScore;
|
|
301
|
+
// If gap is small (uncertain), expand toward hard cap
|
|
302
|
+
if (gap < 0.1 && scoredServers.length > numServers) {
|
|
303
|
+
numServers = Math.min(this.config.hardCap, scoredServers.length);
|
|
304
|
+
this.logger.debug(`[smart-filter] Low confidence (gap: ${gap.toFixed(3)}), expanding to ${numServers} servers`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const selectedScored = scoredServers.slice(0, numServers);
|
|
308
|
+
return [...alwaysIncluded, ...selectedScored];
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Filter tools within selected servers
|
|
312
|
+
*/
|
|
313
|
+
filterTools(query, selectedServers) {
|
|
314
|
+
const queryWords = this.tokenize(query);
|
|
315
|
+
const allTools = [];
|
|
316
|
+
for (const { server } of selectedServers) {
|
|
317
|
+
for (const tool of server.tools) {
|
|
318
|
+
const score = this.calculateToolScore(queryWords, tool);
|
|
319
|
+
if (score >= this.config.toolThreshold) {
|
|
320
|
+
allTools.push({ serverId: server.name, tool, score });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// Sort by score and take top N
|
|
325
|
+
return allTools
|
|
326
|
+
.sort((a, b) => b.score - a.score)
|
|
327
|
+
.slice(0, this.config.topTools)
|
|
328
|
+
.map(({ serverId, tool }) => ({ serverId, tool }));
|
|
329
|
+
}
|
|
330
|
+
calculateToolScore(queryWords, tool) {
|
|
331
|
+
if (queryWords.length === 0)
|
|
332
|
+
return 0;
|
|
333
|
+
const nameWords = this.tokenize(tool.name);
|
|
334
|
+
const descWords = this.tokenize(tool.description || "");
|
|
335
|
+
const nameMatches = this.countOverlap(queryWords, nameWords);
|
|
336
|
+
const descMatches = this.countOverlap(queryWords, descWords) - this.countOverlap(queryWords, nameWords);
|
|
337
|
+
// Weighted: description 1.0x, name 0.5x (name is less descriptive usually)
|
|
338
|
+
const score = (descMatches * 1.0 + nameMatches * 0.5) / queryWords.length;
|
|
339
|
+
return score;
|
|
340
|
+
}
|
|
341
|
+
calculateConfidenceScore(serverScores) {
|
|
342
|
+
if (serverScores.length < 2)
|
|
343
|
+
return 1.0;
|
|
344
|
+
const scores = serverScores.map(s => s.score).sort((a, b) => b - a);
|
|
345
|
+
const topScore = scores[0];
|
|
346
|
+
const secondScore = scores[1];
|
|
347
|
+
// Confidence based on gap between top scores
|
|
348
|
+
if (topScore === 0)
|
|
349
|
+
return 0;
|
|
350
|
+
return Math.min(1.0, (topScore - secondScore) / topScore);
|
|
351
|
+
}
|
|
352
|
+
createUnfilteredResult(servers, allTools, filterMode, queryUsed = "") {
|
|
353
|
+
const filterableServers = this.prepareFilterableServers(servers, allTools);
|
|
354
|
+
const tools = Array.from(allTools.entries()).flatMap(([serverId, tools]) => tools.map(tool => ({ serverId, tool })));
|
|
355
|
+
return {
|
|
356
|
+
servers: filterableServers,
|
|
357
|
+
tools,
|
|
358
|
+
metadata: {
|
|
359
|
+
queryUsed,
|
|
360
|
+
totalServersBeforeFilter: Object.keys(servers).length,
|
|
361
|
+
totalToolsBeforeFilter: tools.length,
|
|
362
|
+
filterMode,
|
|
363
|
+
timeoutOccurred: false,
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
logTelemetry(result, durationMs) {
|
|
368
|
+
const telemetry = {
|
|
369
|
+
timestamp: new Date().toISOString(),
|
|
370
|
+
query: result.metadata.queryUsed,
|
|
371
|
+
serversReturned: result.servers.length,
|
|
372
|
+
toolsReturned: result.tools.length,
|
|
373
|
+
totalServersBefore: result.metadata.totalServersBeforeFilter,
|
|
374
|
+
totalToolsBefore: result.metadata.totalToolsBeforeFilter,
|
|
375
|
+
filterMode: result.metadata.filterMode,
|
|
376
|
+
durationMs,
|
|
377
|
+
confidenceScore: result.metadata.confidenceScore,
|
|
378
|
+
timeoutOccurred: result.metadata.timeoutOccurred,
|
|
379
|
+
};
|
|
380
|
+
this.logger.debug("[smart-filter] Telemetry:", JSON.stringify(telemetry));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// ── Standalone utility exports (for testing and external use) ────────────────
|
|
384
|
+
const MAX_KEYWORDS = 30;
|
|
385
|
+
const NOISE_WORDS = new Set([
|
|
386
|
+
"yes", "no", "ok", "okay", "sure", "yep", "nope", "yeah", "nah",
|
|
387
|
+
"do", "it", "please", "thanks", "thank", "you", "hi", "hello",
|
|
388
|
+
"hey", "right", "alright", "fine", "got", "hmm", "hm",
|
|
389
|
+
]);
|
|
390
|
+
export const DEFAULTS = {
|
|
391
|
+
enabled: false,
|
|
392
|
+
embedding: "keyword",
|
|
393
|
+
topServers: 5,
|
|
394
|
+
hardCap: 8,
|
|
395
|
+
topTools: 10,
|
|
396
|
+
serverThreshold: 0.01,
|
|
397
|
+
toolThreshold: 0.05,
|
|
398
|
+
fallback: "keyword",
|
|
399
|
+
alwaysInclude: [],
|
|
400
|
+
timeoutMs: 500,
|
|
401
|
+
telemetry: false,
|
|
402
|
+
};
|
|
403
|
+
/** Lowercase, split on whitespace + punctuation, preserve numbers, drop empties. */
|
|
404
|
+
export function tokenize(text) {
|
|
405
|
+
return text
|
|
406
|
+
.toLowerCase()
|
|
407
|
+
.split(/[\s\p{P}]+/u)
|
|
408
|
+
.filter(t => t.length > 0);
|
|
409
|
+
}
|
|
410
|
+
/** Normalize keywords: lowercase, trim, dedup, strip empties, cap at MAX_KEYWORDS. */
|
|
411
|
+
export function validateKeywords(raw) {
|
|
412
|
+
const seen = new Set();
|
|
413
|
+
const out = [];
|
|
414
|
+
for (const kw of raw) {
|
|
415
|
+
const normalized = kw.toLowerCase().trim();
|
|
416
|
+
if (normalized.length === 0 || seen.has(normalized))
|
|
417
|
+
continue;
|
|
418
|
+
seen.add(normalized);
|
|
419
|
+
out.push(normalized);
|
|
420
|
+
if (out.length >= MAX_KEYWORDS)
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Extract a meaningful intent string from the last 1-3 user turns.
|
|
427
|
+
* Returns null if no meaningful query can be extracted.
|
|
428
|
+
*/
|
|
429
|
+
export function synthesizeQuery(userTurns) {
|
|
430
|
+
const recent = userTurns.slice(-3).reverse();
|
|
431
|
+
for (const turn of recent) {
|
|
432
|
+
const tokens = tokenize(turn).filter(t => !NOISE_WORDS.has(t));
|
|
433
|
+
if (tokens.length >= 2) {
|
|
434
|
+
return tokens.join(" ");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Score a single server against a query using weighted word overlap.
|
|
441
|
+
* desc_matches * 1.0 + kw_only_matches * 0.5, normalized by query length.
|
|
442
|
+
*/
|
|
443
|
+
export function scoreServer(queryTokens, serverName, description, keywords) {
|
|
444
|
+
if (queryTokens.length === 0)
|
|
445
|
+
return 0;
|
|
446
|
+
const descTokens = new Set(tokenize(description));
|
|
447
|
+
for (const t of tokenize(serverName))
|
|
448
|
+
descTokens.add(t);
|
|
449
|
+
const kwTokens = new Set(validateKeywords(keywords).flatMap(kw => tokenize(kw)));
|
|
450
|
+
let descMatches = 0;
|
|
451
|
+
let kwOnlyMatches = 0;
|
|
452
|
+
for (const qt of queryTokens) {
|
|
453
|
+
if (descTokens.has(qt)) {
|
|
454
|
+
descMatches++;
|
|
455
|
+
}
|
|
456
|
+
else if (kwTokens.has(qt)) {
|
|
457
|
+
kwOnlyMatches++;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return (descMatches * 1.0 + kwOnlyMatches * 0.5) / queryTokens.length;
|
|
461
|
+
}
|
|
462
|
+
/** Score all servers, return sorted highest-first. */
|
|
463
|
+
export function scoreAllServers(queryTokens, servers) {
|
|
464
|
+
const scores = [];
|
|
465
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
466
|
+
scores.push({ name, score: scoreServer(queryTokens, name, cfg.description ?? "", cfg.keywords ?? []) });
|
|
467
|
+
}
|
|
468
|
+
return scores.sort((a, b) => b.score - a.score);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Select top servers with dynamic expansion toward hardCap.
|
|
472
|
+
* If top score < threshold AND gap small → show all (true uncertainty).
|
|
473
|
+
*/
|
|
474
|
+
export function selectTopServers(scores, topServers, hardCap, threshold, alwaysInclude) {
|
|
475
|
+
if (scores.length === 0)
|
|
476
|
+
return [];
|
|
477
|
+
const topScore = scores[0].score;
|
|
478
|
+
if (topScore < threshold && scores.length > 1) {
|
|
479
|
+
const gap = topScore - scores[Math.min(scores.length - 1, topServers - 1)].score;
|
|
480
|
+
if (gap < 0.05) {
|
|
481
|
+
return scores.map(s => s.name);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
let k = Math.min(topServers, scores.length);
|
|
485
|
+
if (k < scores.length && k < hardCap) {
|
|
486
|
+
const kthScore = scores[k - 1].score;
|
|
487
|
+
while (k < Math.min(hardCap, scores.length)) {
|
|
488
|
+
if (scores[k].score >= kthScore * 0.8 && scores[k].score >= threshold) {
|
|
489
|
+
k++;
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const selected = new Set();
|
|
497
|
+
for (let i = 0; i < k && i < scores.length; i++) {
|
|
498
|
+
if (scores[i].score >= threshold || i === 0) {
|
|
499
|
+
selected.add(scores[i].name);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
for (const name of alwaysInclude)
|
|
503
|
+
selected.add(name);
|
|
504
|
+
return [...selected];
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Run the smart filter. Returns the list of server names to include.
|
|
508
|
+
* Guarantees: never throws, never blocks longer than timeoutMs.
|
|
509
|
+
*/
|
|
510
|
+
export function filterServers(servers, userTurns, config, logger) {
|
|
511
|
+
const allServers = Object.keys(servers);
|
|
512
|
+
const showAll = (reason, query = null) => ({
|
|
513
|
+
filteredServers: allServers,
|
|
514
|
+
allServers,
|
|
515
|
+
query,
|
|
516
|
+
scores: [],
|
|
517
|
+
reason,
|
|
518
|
+
});
|
|
519
|
+
if (!config.enabled)
|
|
520
|
+
return showAll("disabled");
|
|
521
|
+
try {
|
|
522
|
+
const merged = { ...DEFAULTS, ...config };
|
|
523
|
+
const startTime = Date.now();
|
|
524
|
+
const query = synthesizeQuery(userTurns);
|
|
525
|
+
if (!query)
|
|
526
|
+
return showAll("no-query");
|
|
527
|
+
if (Date.now() - startTime > merged.timeoutMs) {
|
|
528
|
+
logger?.warn("[smart-filter] Timeout during query synthesis");
|
|
529
|
+
return showAll("timeout", query);
|
|
530
|
+
}
|
|
531
|
+
const queryTokens = tokenize(query);
|
|
532
|
+
if (queryTokens.length === 0)
|
|
533
|
+
return showAll("no-query");
|
|
534
|
+
const scores = scoreAllServers(queryTokens, servers);
|
|
535
|
+
if (Date.now() - startTime > merged.timeoutMs) {
|
|
536
|
+
logger?.warn("[smart-filter] Timeout during scoring");
|
|
537
|
+
return showAll("timeout", query);
|
|
538
|
+
}
|
|
539
|
+
const filteredServers = selectTopServers(scores, merged.topServers, merged.hardCap, merged.serverThreshold, merged.alwaysInclude);
|
|
540
|
+
return { filteredServers, allServers, query, scores, reason: "filtered" };
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
logger?.error("[smart-filter] Error during filtering, showing all servers:", err);
|
|
544
|
+
return showAll("error");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/** Build a filtered router tool description string. */
|
|
548
|
+
export function buildFilteredDescription(allServers, filteredNames) {
|
|
549
|
+
const included = new Set(filteredNames);
|
|
550
|
+
const serverList = Object.entries(allServers)
|
|
551
|
+
.filter(([name]) => included.has(name))
|
|
552
|
+
.map(([name, cfg]) => {
|
|
553
|
+
const desc = cfg.description;
|
|
554
|
+
return desc ? `${name} (${desc})` : name;
|
|
555
|
+
})
|
|
556
|
+
.join(", ");
|
|
557
|
+
if (!serverList) {
|
|
558
|
+
return "Call MCP server tools. No servers matched the current context.";
|
|
559
|
+
}
|
|
560
|
+
return `Call any MCP server tool. Servers: ${serverList}. Use action='list' to discover tools and required parameters, action='call' to execute a tool, action='refresh' to clear cache and re-discover tools, and action='status' to check server connection states. If the user mentions a specific tool by name, the call action auto-connects and works without listing first.`;
|
|
561
|
+
}
|
|
@@ -246,9 +246,11 @@ export class StandaloneServer {
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
/** Connect to all backend servers and discover their tools (direct mode). */
|
|
249
|
-
async discoverDirectTools() {
|
|
250
|
-
if (this.directTools.length > 0)
|
|
249
|
+
async discoverDirectTools(force = false) {
|
|
250
|
+
if (this.directTools.length > 0 && !force)
|
|
251
251
|
return; // Already discovered
|
|
252
|
+
if (force)
|
|
253
|
+
this.directTools = [];
|
|
252
254
|
const globalNames = new Set();
|
|
253
255
|
for (const [serverName, serverConfig] of Object.entries(this.config.servers)) {
|
|
254
256
|
try {
|
|
@@ -279,7 +281,8 @@ export class StandaloneServer {
|
|
|
279
281
|
}
|
|
280
282
|
createTransport(serverName, serverConfig) {
|
|
281
283
|
const onReconnected = async () => {
|
|
282
|
-
this.logger.info(`[mcp-bridge] ${serverName} reconnected`);
|
|
284
|
+
this.logger.info(`[mcp-bridge] ${serverName} reconnected, refreshing tools`);
|
|
285
|
+
await this.discoverDirectTools(true);
|
|
283
286
|
};
|
|
284
287
|
switch (serverConfig.transport) {
|
|
285
288
|
case "sse":
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { McpTransport, McpRequest, McpResponse, McpServerConfig } from "./types.js";
|
|
1
|
+
import { McpTransport, McpRequest, McpResponse, McpServerConfig, McpClientConfig, Logger } from "./types.js";
|
|
2
2
|
export type PendingRequest = {
|
|
3
3
|
resolve: Function;
|
|
4
4
|
reject: Function;
|
|
@@ -14,14 +14,14 @@ export type PendingRequest = {
|
|
|
14
14
|
*/
|
|
15
15
|
export declare abstract class BaseTransport implements McpTransport {
|
|
16
16
|
protected config: McpServerConfig;
|
|
17
|
-
protected clientConfig:
|
|
17
|
+
protected clientConfig: McpClientConfig;
|
|
18
18
|
protected connected: boolean;
|
|
19
19
|
protected pendingRequests: Map<number, PendingRequest>;
|
|
20
|
-
protected logger:
|
|
20
|
+
protected logger: Logger;
|
|
21
21
|
protected reconnectTimer: NodeJS.Timeout | null;
|
|
22
22
|
protected onReconnected?: () => Promise<void>;
|
|
23
23
|
protected backoffDelay: number;
|
|
24
|
-
constructor(config: McpServerConfig, clientConfig:
|
|
24
|
+
constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>);
|
|
25
25
|
abstract connect(): Promise<void>;
|
|
26
26
|
abstract disconnect(): Promise<void>;
|
|
27
27
|
abstract sendRequest(request: McpRequest): Promise<McpResponse>;
|
|
@@ -73,4 +73,4 @@ export declare function resolveArgs(args: string[], extraEnv?: Record<string, st
|
|
|
73
73
|
/**
|
|
74
74
|
* Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
|
|
75
75
|
*/
|
|
76
|
-
export declare function warnIfNonTlsRemoteUrl(rawUrl: string, logger:
|
|
76
|
+
export declare function warnIfNonTlsRemoteUrl(rawUrl: string, logger: Logger): void;
|
|
@@ -52,7 +52,7 @@ export class SseTransport extends BaseTransport {
|
|
|
52
52
|
const reader = response.body.getReader();
|
|
53
53
|
const decoder = new TextDecoder();
|
|
54
54
|
let buffer = "";
|
|
55
|
-
|
|
55
|
+
const state = { event: "", dataBuffer: this.currentDataBuffer };
|
|
56
56
|
while (true) {
|
|
57
57
|
const { done, value } = await reader.read();
|
|
58
58
|
if (done)
|
|
@@ -61,17 +61,7 @@ export class SseTransport extends BaseTransport {
|
|
|
61
61
|
const lines = buffer.split('\n');
|
|
62
62
|
buffer = lines.pop() || "";
|
|
63
63
|
for (const line of lines) {
|
|
64
|
-
|
|
65
|
-
if (trimmed.startsWith("event: ")) {
|
|
66
|
-
currentEvent = trimmed.substring(7).trim();
|
|
67
|
-
}
|
|
68
|
-
else if (trimmed === "") {
|
|
69
|
-
this.processEventLine(line, currentEvent);
|
|
70
|
-
currentEvent = "";
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
this.processEventLine(line, currentEvent);
|
|
74
|
-
}
|
|
64
|
+
this.processEventLine(line, state);
|
|
75
65
|
}
|
|
76
66
|
}
|
|
77
67
|
}
|
|
@@ -82,20 +72,22 @@ export class SseTransport extends BaseTransport {
|
|
|
82
72
|
this.scheduleReconnect();
|
|
83
73
|
}
|
|
84
74
|
}
|
|
85
|
-
processEventLine(line,
|
|
75
|
+
processEventLine(line, state) {
|
|
86
76
|
const trimmed = line.trim();
|
|
87
|
-
if (trimmed.startsWith("event: "))
|
|
77
|
+
if (trimmed.startsWith("event: ")) {
|
|
78
|
+
state.event = trimmed.substring(7).trim();
|
|
88
79
|
return;
|
|
80
|
+
}
|
|
89
81
|
if (trimmed.startsWith("data: ")) {
|
|
90
|
-
|
|
82
|
+
state.dataBuffer.push(trimmed.substring(6));
|
|
91
83
|
return;
|
|
92
84
|
}
|
|
93
85
|
if (trimmed === "") {
|
|
94
|
-
if (
|
|
86
|
+
if (state.dataBuffer.length === 0)
|
|
95
87
|
return;
|
|
96
|
-
const data =
|
|
97
|
-
|
|
98
|
-
if (
|
|
88
|
+
const data = state.dataBuffer.join("\n");
|
|
89
|
+
state.dataBuffer.length = 0;
|
|
90
|
+
if (state.event === "endpoint") {
|
|
99
91
|
if (data.startsWith("/")) {
|
|
100
92
|
const base = new URL(this.config.url);
|
|
101
93
|
this.endpointUrl = `${base.origin}${data}`;
|
|
@@ -61,7 +61,21 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
61
61
|
.filter((line) => line.startsWith('data:'))
|
|
62
62
|
.map((line) => line.substring(5).trim());
|
|
63
63
|
if (dataLines.length > 0) {
|
|
64
|
-
|
|
64
|
+
for (const dl of dataLines) {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(dl);
|
|
67
|
+
if (parsed.id !== undefined) {
|
|
68
|
+
jsonResponse = parsed;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
this.handleMessage(parsed);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch { /* skip malformed lines */ }
|
|
75
|
+
}
|
|
76
|
+
if (!jsonResponse) {
|
|
77
|
+
jsonResponse = JSON.parse(dataLines[dataLines.length - 1]);
|
|
78
|
+
}
|
|
65
79
|
}
|
|
66
80
|
else {
|
|
67
81
|
throw new Error("No data lines in SSE response");
|
package/dist/src/types.d.ts
CHANGED
|
@@ -30,12 +30,17 @@ export interface McpTool {
|
|
|
30
30
|
description: string;
|
|
31
31
|
inputSchema: any;
|
|
32
32
|
}
|
|
33
|
+
/** MCP JSON-RPC request. id is required for requests (omit only for notifications). */
|
|
33
34
|
export interface McpRequest {
|
|
34
35
|
jsonrpc: "2.0";
|
|
35
36
|
id?: number;
|
|
36
37
|
method: string;
|
|
37
38
|
params?: any;
|
|
38
39
|
}
|
|
40
|
+
/** MCP request that requires a response (id is mandatory). */
|
|
41
|
+
export interface McpCallRequest extends McpRequest {
|
|
42
|
+
id: number;
|
|
43
|
+
}
|
|
39
44
|
export declare function nextRequestId(): number;
|
|
40
45
|
export interface McpResponse {
|
|
41
46
|
jsonrpc: "2.0";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execSync, exec as execCb } from "child_process";
|
|
1
|
+
import { execSync, exec as execCb, execFile } from "child_process";
|
|
2
2
|
import { PACKAGE_VERSION } from "./protocol.js";
|
|
3
3
|
const PACKAGE_NAME = "@aiwerk/mcp-bridge";
|
|
4
4
|
let cachedUpdateInfo = null;
|
|
@@ -87,7 +87,7 @@ export async function runUpdate(logger) {
|
|
|
87
87
|
function npmViewVersion(_logger) {
|
|
88
88
|
return new Promise((resolve, reject) => {
|
|
89
89
|
const timeout = setTimeout(() => reject(new Error("npm view timed out")), 10_000);
|
|
90
|
-
|
|
90
|
+
execFile("npm", ["view", PACKAGE_NAME, "version"], { encoding: "utf-8" }, (err, stdout) => {
|
|
91
91
|
clearTimeout(timeout);
|
|
92
92
|
if (err)
|
|
93
93
|
return reject(err);
|