@aiwerk/mcp-bridge 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/mcp-bridge.d.ts +2 -0
- package/dist/bin/mcp-bridge.js +320 -0
- package/dist/src/config.d.ts +19 -0
- package/dist/src/config.js +145 -0
- package/{src/index.ts → dist/src/index.d.ts} +1 -30
- package/dist/src/index.js +21 -0
- package/dist/src/mcp-router.d.ts +65 -0
- package/dist/src/mcp-router.js +271 -0
- package/dist/src/protocol.d.ts +4 -0
- package/dist/src/protocol.js +58 -0
- package/dist/src/schema-convert.d.ts +11 -0
- package/dist/src/schema-convert.js +150 -0
- package/dist/src/standalone-server.d.ts +30 -0
- package/dist/src/standalone-server.js +312 -0
- package/dist/src/tool-naming.d.ts +3 -0
- package/dist/src/tool-naming.js +38 -0
- package/dist/src/transport-base.d.ts +76 -0
- package/dist/src/transport-base.js +163 -0
- package/dist/src/transport-sse.d.ts +16 -0
- package/dist/src/transport-sse.js +207 -0
- package/dist/src/transport-stdio.d.ts +20 -0
- package/dist/src/transport-stdio.js +281 -0
- package/dist/src/transport-streamable-http.d.ts +11 -0
- package/dist/src/transport-streamable-http.js +164 -0
- package/dist/src/types.d.ts +72 -0
- package/dist/src/types.js +4 -0
- package/dist/src/update-checker.d.ts +25 -0
- package/dist/src/update-checker.js +132 -0
- package/package.json +19 -4
- package/scripts/install-server.ps1 +25 -58
- package/scripts/install-server.sh +37 -90
- package/servers/apify/README.md +6 -6
- package/servers/github/README.md +6 -6
- package/servers/google-maps/README.md +6 -6
- package/servers/hetzner/README.md +6 -6
- package/servers/hostinger/README.md +6 -6
- package/servers/linear/README.md +6 -6
- package/servers/miro/README.md +6 -6
- package/servers/notion/README.md +6 -6
- package/servers/stripe/README.md +6 -6
- package/servers/tavily/README.md +6 -6
- package/servers/todoist/README.md +6 -6
- package/servers/wise/README.md +6 -6
- package/bin/mcp-bridge.js +0 -9
- package/bin/mcp-bridge.ts +0 -335
- package/src/config.ts +0 -168
- package/src/mcp-router.ts +0 -366
- package/src/protocol.ts +0 -69
- package/src/schema-convert.ts +0 -178
- package/src/standalone-server.ts +0 -385
- package/src/tool-naming.ts +0 -51
- package/src/transport-base.ts +0 -199
- package/src/transport-sse.ts +0 -230
- package/src/transport-stdio.ts +0 -312
- package/src/transport-streamable-http.ts +0 -188
- package/src/types.ts +0 -88
- package/src/update-checker.ts +0 -155
- package/tests/collision.test.ts +0 -60
- package/tests/env-resolve.test.ts +0 -68
- package/tests/mcp-router.test.ts +0 -301
- package/tests/schema-convert.test.ts +0 -70
- package/tests/transport-base.test.ts +0 -214
- package/tsconfig.json +0 -15
package/src/config.ts
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import { homedir } from "os";
|
|
4
|
-
import { BridgeConfig, Logger } from "./types.js";
|
|
5
|
-
import { resolveEnvVars } from "./transport-base.js";
|
|
6
|
-
import { randomBytes } from "crypto";
|
|
7
|
-
|
|
8
|
-
const DEFAULT_CONFIG_DIR = join(homedir(), ".mcp-bridge");
|
|
9
|
-
const DEFAULT_CONFIG_FILE = "config.json";
|
|
10
|
-
const DEFAULT_ENV_FILE = ".env";
|
|
11
|
-
|
|
12
|
-
/** Parse a simple KEY=VALUE .env file (no npm dependency). */
|
|
13
|
-
export function parseEnvFile(content: string): Record<string, string> {
|
|
14
|
-
const env: Record<string, string> = {};
|
|
15
|
-
for (const line of content.split("\n")) {
|
|
16
|
-
const trimmed = line.trim();
|
|
17
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
18
|
-
const eqIdx = trimmed.indexOf("=");
|
|
19
|
-
if (eqIdx === -1) continue;
|
|
20
|
-
const key = trimmed.substring(0, eqIdx).trim();
|
|
21
|
-
let value = trimmed.substring(eqIdx + 1).trim();
|
|
22
|
-
// Strip surrounding quotes
|
|
23
|
-
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
24
|
-
(value.startsWith("'") && value.endsWith("'"))) {
|
|
25
|
-
value = value.slice(1, -1);
|
|
26
|
-
}
|
|
27
|
-
if (key) env[key] = value;
|
|
28
|
-
}
|
|
29
|
-
return env;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Recursively resolve ${VAR} placeholders in a JSON-compatible value. */
|
|
33
|
-
function resolveConfigValue(value: unknown, extraEnv: Record<string, string | undefined>): unknown {
|
|
34
|
-
if (typeof value === "string") {
|
|
35
|
-
return resolveEnvVars(value, "config value", extraEnv);
|
|
36
|
-
}
|
|
37
|
-
if (Array.isArray(value)) {
|
|
38
|
-
return value.map(item => resolveConfigValue(item, extraEnv));
|
|
39
|
-
}
|
|
40
|
-
if (value !== null && typeof value === "object") {
|
|
41
|
-
const resolved: Record<string, unknown> = {};
|
|
42
|
-
for (const [k, v] of Object.entries(value)) {
|
|
43
|
-
resolved[k] = resolveConfigValue(v, extraEnv);
|
|
44
|
-
}
|
|
45
|
-
return resolved;
|
|
46
|
-
}
|
|
47
|
-
return value;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface LoadConfigOptions {
|
|
51
|
-
configPath?: string;
|
|
52
|
-
logger?: Logger;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Load and validate bridge config.
|
|
57
|
-
* 1. Read ~/.mcp-bridge/config.json (or custom path)
|
|
58
|
-
* 2. Parse ~/.mcp-bridge/.env
|
|
59
|
-
* 3. Resolve ${ENV_VAR} in config values
|
|
60
|
-
* 4. Validate required fields
|
|
61
|
-
*/
|
|
62
|
-
export function loadConfig(options: LoadConfigOptions = {}): BridgeConfig {
|
|
63
|
-
const configDir = options.configPath
|
|
64
|
-
? join(options.configPath, "..") // If a file path is given, derive directory
|
|
65
|
-
: DEFAULT_CONFIG_DIR;
|
|
66
|
-
|
|
67
|
-
const configPath = options.configPath || join(DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_FILE);
|
|
68
|
-
const envPath = join(
|
|
69
|
-
options.configPath ? join(options.configPath, "..") : DEFAULT_CONFIG_DIR,
|
|
70
|
-
DEFAULT_ENV_FILE
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
if (!existsSync(configPath)) {
|
|
74
|
-
throw new Error(
|
|
75
|
-
`Config file not found: ${configPath}\nRun 'mcp-bridge init' to set up.`
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Load .env file
|
|
80
|
-
let dotEnv: Record<string, string> = {};
|
|
81
|
-
if (existsSync(envPath)) {
|
|
82
|
-
try {
|
|
83
|
-
dotEnv = parseEnvFile(readFileSync(envPath, "utf-8"));
|
|
84
|
-
} catch (err) {
|
|
85
|
-
options.logger?.warn(`[mcp-bridge] Failed to parse .env file: ${err instanceof Error ? err.message : err}`);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Merge .env into process.env (don't overwrite existing)
|
|
90
|
-
for (const [key, value] of Object.entries(dotEnv)) {
|
|
91
|
-
if (process.env[key] === undefined) {
|
|
92
|
-
process.env[key] = value;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Read and parse config
|
|
97
|
-
const rawConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
98
|
-
|
|
99
|
-
// Resolve ${VAR} placeholders using .env + process.env
|
|
100
|
-
const mergedEnv: Record<string, string | undefined> = { ...dotEnv };
|
|
101
|
-
for (const [k, v] of Object.entries(process.env)) {
|
|
102
|
-
if (mergedEnv[k] === undefined) mergedEnv[k] = v;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
let config: BridgeConfig;
|
|
106
|
-
try {
|
|
107
|
-
config = resolveConfigValue(rawConfig, mergedEnv) as BridgeConfig;
|
|
108
|
-
} catch (err) {
|
|
109
|
-
throw new Error(`Config resolution failed: ${err instanceof Error ? err.message : err}`);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Validate required fields
|
|
113
|
-
if (!config.servers || typeof config.servers !== "object") {
|
|
114
|
-
throw new Error("Config must have a 'servers' object");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return config;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Get the default config directory path. */
|
|
121
|
-
export function getConfigDir(configPath?: string): string {
|
|
122
|
-
return configPath ? join(configPath, "..") : DEFAULT_CONFIG_DIR;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Initialize the config directory with template files. */
|
|
126
|
-
export function initConfigDir(logger: Logger): void {
|
|
127
|
-
const dir = DEFAULT_CONFIG_DIR;
|
|
128
|
-
|
|
129
|
-
mkdirSync(dir, { recursive: true });
|
|
130
|
-
|
|
131
|
-
// Set directory permissions (Linux/macOS)
|
|
132
|
-
try {
|
|
133
|
-
chmodSync(dir, 0o700);
|
|
134
|
-
} catch { /* Windows doesn't support chmod */ }
|
|
135
|
-
|
|
136
|
-
const configPath = join(dir, DEFAULT_CONFIG_FILE);
|
|
137
|
-
if (!existsSync(configPath)) {
|
|
138
|
-
const template: BridgeConfig = {
|
|
139
|
-
mode: "router",
|
|
140
|
-
servers: {},
|
|
141
|
-
toolPrefix: true,
|
|
142
|
-
connectionTimeoutMs: 5000,
|
|
143
|
-
requestTimeoutMs: 60000,
|
|
144
|
-
routerIdleTimeoutMs: 600000,
|
|
145
|
-
routerMaxConcurrent: 5,
|
|
146
|
-
http: {
|
|
147
|
-
auth: {
|
|
148
|
-
type: "bearer",
|
|
149
|
-
token: "${MCP_BRIDGE_TOKEN}"
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
writeFileSync(configPath, JSON.stringify(template, null, 2) + "\n");
|
|
154
|
-
logger.info(`Created config: ${configPath}`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const envPath = join(dir, DEFAULT_ENV_FILE);
|
|
158
|
-
if (!existsSync(envPath)) {
|
|
159
|
-
const token = randomBytes(32).toString("hex");
|
|
160
|
-
writeFileSync(envPath, `# MCP Bridge environment variables\nMCP_BRIDGE_TOKEN=${token}\n`);
|
|
161
|
-
try {
|
|
162
|
-
chmodSync(envPath, 0o600);
|
|
163
|
-
} catch { /* Windows */ }
|
|
164
|
-
logger.info(`Created .env: ${envPath} (with generated token)`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
logger.info(`Config directory ready: ${dir}`);
|
|
168
|
-
}
|
package/src/mcp-router.ts
DELETED
|
@@ -1,366 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
McpClientConfig,
|
|
3
|
-
McpServerConfig,
|
|
4
|
-
McpTool,
|
|
5
|
-
McpTransport
|
|
6
|
-
} from "./types.js";
|
|
7
|
-
import { SseTransport } from "./transport-sse.js";
|
|
8
|
-
import { StdioTransport } from "./transport-stdio.js";
|
|
9
|
-
import { StreamableHttpTransport } from "./transport-streamable-http.js";
|
|
10
|
-
import { fetchToolsList, initializeProtocol, PACKAGE_VERSION } from "./protocol.js";
|
|
11
|
-
|
|
12
|
-
type RouterErrorCode =
|
|
13
|
-
| "unknown_server"
|
|
14
|
-
| "unknown_tool"
|
|
15
|
-
| "connection_failed"
|
|
16
|
-
| "mcp_error"
|
|
17
|
-
| "invalid_params";
|
|
18
|
-
|
|
19
|
-
export interface RouterToolHint {
|
|
20
|
-
name: string;
|
|
21
|
-
description: string;
|
|
22
|
-
requiredParams: string[];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface RouterServerStatus {
|
|
26
|
-
name: string;
|
|
27
|
-
transport: string;
|
|
28
|
-
status: "connected" | "idle" | "disconnected";
|
|
29
|
-
tools: number;
|
|
30
|
-
lastUsed?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export type RouterDispatchResponse =
|
|
34
|
-
| { server: string; action: "list"; tools: RouterToolHint[] }
|
|
35
|
-
| { server: string; action: "refresh"; refreshed: true; tools: RouterToolHint[] }
|
|
36
|
-
| { server: string; action: "call"; tool: string; result: any }
|
|
37
|
-
| { action: "status"; servers: RouterServerStatus[] }
|
|
38
|
-
| {
|
|
39
|
-
error: RouterErrorCode;
|
|
40
|
-
message: string;
|
|
41
|
-
available?: string[];
|
|
42
|
-
code?: number;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export interface RouterTransportRefs {
|
|
46
|
-
sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: any, onReconnected?: () => Promise<void>) => McpTransport;
|
|
47
|
-
stdio: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: any, onReconnected?: () => Promise<void>) => McpTransport;
|
|
48
|
-
streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: any, onReconnected?: () => Promise<void>) => McpTransport;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
interface RouterServerState {
|
|
52
|
-
transport: McpTransport;
|
|
53
|
-
initialized: boolean;
|
|
54
|
-
toolsCache?: RouterToolHint[];
|
|
55
|
-
toolNames: string[];
|
|
56
|
-
lastUsedAt: number;
|
|
57
|
-
idleTimer: NodeJS.Timeout | null;
|
|
58
|
-
initPromise?: Promise<void>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
62
|
-
const DEFAULT_MAX_CONCURRENT = 5;
|
|
63
|
-
|
|
64
|
-
export class McpRouter {
|
|
65
|
-
private readonly servers: Record<string, McpServerConfig>;
|
|
66
|
-
private readonly clientConfig: McpClientConfig;
|
|
67
|
-
private readonly logger: any;
|
|
68
|
-
private readonly transportRefs: RouterTransportRefs;
|
|
69
|
-
private readonly idleTimeoutMs: number;
|
|
70
|
-
private readonly maxConcurrent: number;
|
|
71
|
-
private readonly states = new Map<string, RouterServerState>();
|
|
72
|
-
|
|
73
|
-
constructor(
|
|
74
|
-
servers: Record<string, McpServerConfig>,
|
|
75
|
-
clientConfig: McpClientConfig,
|
|
76
|
-
logger: any,
|
|
77
|
-
transportRefs?: Partial<RouterTransportRefs>
|
|
78
|
-
) {
|
|
79
|
-
this.servers = servers;
|
|
80
|
-
this.clientConfig = clientConfig;
|
|
81
|
-
this.logger = logger;
|
|
82
|
-
this.transportRefs = {
|
|
83
|
-
sse: transportRefs?.sse ?? SseTransport,
|
|
84
|
-
stdio: transportRefs?.stdio ?? StdioTransport,
|
|
85
|
-
streamableHttp: transportRefs?.streamableHttp ?? StreamableHttpTransport
|
|
86
|
-
};
|
|
87
|
-
this.idleTimeoutMs = clientConfig.routerIdleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
88
|
-
this.maxConcurrent = clientConfig.routerMaxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
static generateDescription(servers: Record<string, McpServerConfig>): string {
|
|
92
|
-
const serverNames = Object.keys(servers);
|
|
93
|
-
if (serverNames.length === 0) {
|
|
94
|
-
return "Call MCP server tools. No servers configured.";
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const serverList = serverNames
|
|
98
|
-
.map((name) => {
|
|
99
|
-
const desc = servers[name].description;
|
|
100
|
-
return desc ? `${name} (${desc})` : name;
|
|
101
|
-
})
|
|
102
|
-
.join(", ");
|
|
103
|
-
|
|
104
|
-
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.`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async dispatch(server?: string, action: string = "call", tool?: string, params?: any): Promise<RouterDispatchResponse> {
|
|
108
|
-
try {
|
|
109
|
-
const normalizedAction = action || "call";
|
|
110
|
-
|
|
111
|
-
// Status action: no server required, shows all server states
|
|
112
|
-
if (normalizedAction === "status") {
|
|
113
|
-
return this.getStatus();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (!server) {
|
|
117
|
-
return this.error("invalid_params", "server is required");
|
|
118
|
-
}
|
|
119
|
-
if (!this.servers[server]) {
|
|
120
|
-
return this.error("unknown_server", `Server '${server}' not found`, Object.keys(this.servers));
|
|
121
|
-
}
|
|
122
|
-
if (normalizedAction === "list") {
|
|
123
|
-
try {
|
|
124
|
-
const tools = await this.getToolList(server);
|
|
125
|
-
return { server, action: "list", tools };
|
|
126
|
-
} catch (error) {
|
|
127
|
-
return this.error("connection_failed", `Failed to connect to ${server}: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (normalizedAction === "refresh") {
|
|
132
|
-
try {
|
|
133
|
-
const state = await this.ensureConnected(server);
|
|
134
|
-
state.toolsCache = undefined;
|
|
135
|
-
state.toolNames = [];
|
|
136
|
-
const tools = await this.getToolList(server);
|
|
137
|
-
return { server, action: "refresh", refreshed: true, tools };
|
|
138
|
-
} catch (error) {
|
|
139
|
-
return this.error("connection_failed", `Failed to connect to ${server}: ${error instanceof Error ? error.message : String(error)}`);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (normalizedAction !== "call") {
|
|
144
|
-
return this.error("invalid_params", `action must be one of: list, call, refresh`);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (!tool) {
|
|
148
|
-
return this.error("invalid_params", "tool is required for action=call");
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
await this.getToolList(server);
|
|
153
|
-
} catch (error) {
|
|
154
|
-
return this.error("connection_failed", `Failed to connect to ${server}: ${error instanceof Error ? error.message : String(error)}`);
|
|
155
|
-
}
|
|
156
|
-
const state = this.states.get(server)!;
|
|
157
|
-
|
|
158
|
-
if (!state.toolNames.includes(tool)) {
|
|
159
|
-
return this.error("unknown_tool", `Tool '${tool}' not found on server '${server}'`, state.toolNames);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
this.markUsed(server);
|
|
163
|
-
const response = await state.transport.sendRequest({
|
|
164
|
-
jsonrpc: "2.0",
|
|
165
|
-
method: "tools/call",
|
|
166
|
-
params: {
|
|
167
|
-
name: tool,
|
|
168
|
-
arguments: params ?? {}
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
if (response.error) {
|
|
173
|
-
return this.error("mcp_error", response.error.message, undefined, response.error.code);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return { server, action: "call", tool, result: response.result };
|
|
177
|
-
} catch (error) {
|
|
178
|
-
return this.error("mcp_error", error instanceof Error ? error.message : String(error));
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async getToolList(server: string): Promise<RouterToolHint[]> {
|
|
183
|
-
if (!this.servers[server]) {
|
|
184
|
-
throw new Error(`Server '${server}' not found`);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const state = await this.ensureConnected(server);
|
|
188
|
-
if (state.toolsCache) {
|
|
189
|
-
this.markUsed(server);
|
|
190
|
-
return state.toolsCache;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const tools = await fetchToolsList(state.transport);
|
|
194
|
-
state.toolNames = tools.map((tool) => tool.name);
|
|
195
|
-
state.toolsCache = tools.map((tool) => ({
|
|
196
|
-
name: tool.name,
|
|
197
|
-
description: tool.description || "",
|
|
198
|
-
requiredParams: this.extractRequiredParams(tool)
|
|
199
|
-
}));
|
|
200
|
-
|
|
201
|
-
this.markUsed(server);
|
|
202
|
-
return state.toolsCache;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
private getStatus(): RouterDispatchResponse {
|
|
206
|
-
const serverStatuses: RouterServerStatus[] = Object.entries(this.servers).map(([name, config]) => {
|
|
207
|
-
const state = this.states.get(name);
|
|
208
|
-
let status: "connected" | "idle" | "disconnected" = "disconnected";
|
|
209
|
-
if (state?.transport.isConnected()) {
|
|
210
|
-
const idleMs = Date.now() - state.lastUsedAt;
|
|
211
|
-
status = idleMs > 60_000 ? "idle" : "connected";
|
|
212
|
-
}
|
|
213
|
-
return {
|
|
214
|
-
name,
|
|
215
|
-
transport: config.transport,
|
|
216
|
-
status,
|
|
217
|
-
tools: state?.toolNames.length ?? 0,
|
|
218
|
-
...(state?.lastUsedAt ? { lastUsed: new Date(state.lastUsedAt).toISOString() } : {})
|
|
219
|
-
};
|
|
220
|
-
});
|
|
221
|
-
return { action: "status", servers: serverStatuses };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async disconnectAll(): Promise<void> {
|
|
225
|
-
for (const serverName of Object.keys(this.servers)) {
|
|
226
|
-
await this.disconnectServer(serverName);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
private async ensureConnected(server: string): Promise<RouterServerState> {
|
|
231
|
-
let state = this.states.get(server);
|
|
232
|
-
if (!state) {
|
|
233
|
-
const transport = this.createTransport(server, this.servers[server]);
|
|
234
|
-
state = {
|
|
235
|
-
transport,
|
|
236
|
-
initialized: false,
|
|
237
|
-
toolNames: [],
|
|
238
|
-
lastUsedAt: Date.now(),
|
|
239
|
-
idleTimer: null
|
|
240
|
-
};
|
|
241
|
-
this.states.set(server, state);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (state.initPromise) {
|
|
245
|
-
await state.initPromise;
|
|
246
|
-
return state;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
state.initPromise = (async () => {
|
|
250
|
-
if (!state!.transport.isConnected()) {
|
|
251
|
-
await state!.transport.connect();
|
|
252
|
-
}
|
|
253
|
-
if (!state!.initialized) {
|
|
254
|
-
await initializeProtocol(state!.transport, PACKAGE_VERSION);
|
|
255
|
-
state!.initialized = true;
|
|
256
|
-
}
|
|
257
|
-
this.markUsed(server);
|
|
258
|
-
await this.enforceMaxConcurrent(server);
|
|
259
|
-
})();
|
|
260
|
-
|
|
261
|
-
try {
|
|
262
|
-
await state.initPromise;
|
|
263
|
-
return state;
|
|
264
|
-
} finally {
|
|
265
|
-
state.initPromise = undefined;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
private async enforceMaxConcurrent(activeServer: string): Promise<void> {
|
|
270
|
-
const connectedServers = [...this.states.entries()]
|
|
271
|
-
.filter(([_, s]) => s.transport.isConnected())
|
|
272
|
-
.map(([name, s]) => ({ name, lastUsedAt: s.lastUsedAt }));
|
|
273
|
-
|
|
274
|
-
if (connectedServers.length <= this.maxConcurrent) {
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
connectedServers.sort((a, b) => a.lastUsedAt - b.lastUsedAt);
|
|
279
|
-
for (const candidate of connectedServers) {
|
|
280
|
-
if (candidate.name === activeServer) {
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
await this.disconnectServer(candidate.name);
|
|
284
|
-
this.logger.info(`[mcp-bridge] Router evicted idle server via LRU: ${candidate.name}`);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
private async disconnectServer(server: string): Promise<void> {
|
|
290
|
-
const state = this.states.get(server);
|
|
291
|
-
if (!state) return;
|
|
292
|
-
|
|
293
|
-
if (state.idleTimer) {
|
|
294
|
-
clearTimeout(state.idleTimer);
|
|
295
|
-
state.idleTimer = null;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (state.transport.isConnected()) {
|
|
299
|
-
await state.transport.disconnect();
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
state.initialized = false;
|
|
303
|
-
state.toolsCache = undefined;
|
|
304
|
-
state.toolNames = [];
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
private markUsed(server: string): void {
|
|
308
|
-
const state = this.states.get(server);
|
|
309
|
-
if (!state) return;
|
|
310
|
-
|
|
311
|
-
state.lastUsedAt = Date.now();
|
|
312
|
-
|
|
313
|
-
if (state.idleTimer) {
|
|
314
|
-
clearTimeout(state.idleTimer);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
state.idleTimer = setTimeout(() => {
|
|
318
|
-
this.disconnectServer(server).catch((error) => {
|
|
319
|
-
this.logger.warn(`[mcp-bridge] Router idle disconnect failed for ${server}:`, error);
|
|
320
|
-
});
|
|
321
|
-
}, this.idleTimeoutMs);
|
|
322
|
-
// Don't keep the process alive just for idle disconnect
|
|
323
|
-
if (state.idleTimer && typeof state.idleTimer.unref === "function") {
|
|
324
|
-
state.idleTimer.unref();
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
private createTransport(serverName: string, serverConfig: McpServerConfig): McpTransport {
|
|
329
|
-
const onReconnected = async () => {
|
|
330
|
-
const state = this.states.get(serverName);
|
|
331
|
-
if (!state) return;
|
|
332
|
-
state.initialized = false;
|
|
333
|
-
state.toolsCache = undefined;
|
|
334
|
-
state.toolNames = [];
|
|
335
|
-
};
|
|
336
|
-
|
|
337
|
-
if (serverConfig.transport === "sse") {
|
|
338
|
-
return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected);
|
|
339
|
-
}
|
|
340
|
-
if (serverConfig.transport === "stdio") {
|
|
341
|
-
return new this.transportRefs.stdio(serverConfig, this.clientConfig, this.logger, onReconnected);
|
|
342
|
-
}
|
|
343
|
-
if (serverConfig.transport === "streamable-http") {
|
|
344
|
-
return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
throw new Error(`Unsupported transport: ${serverConfig.transport}`);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
private extractRequiredParams(tool: McpTool): string[] {
|
|
351
|
-
const required = tool.inputSchema?.required;
|
|
352
|
-
if (!Array.isArray(required)) {
|
|
353
|
-
return [];
|
|
354
|
-
}
|
|
355
|
-
return required.filter((name: unknown) => typeof name === "string");
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
private error(error: RouterErrorCode, message: string, available?: string[], code?: number): RouterDispatchResponse {
|
|
359
|
-
return {
|
|
360
|
-
error,
|
|
361
|
-
message,
|
|
362
|
-
...(available ? { available } : {}),
|
|
363
|
-
...(typeof code === "number" ? { code } : {})
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
}
|
package/src/protocol.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "fs";
|
|
2
|
-
import { join, dirname } from "path";
|
|
3
|
-
import { fileURLToPath } from "url";
|
|
4
|
-
import { McpRequest, McpResponse, McpTool, McpTransport } from "./types.js";
|
|
5
|
-
|
|
6
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
-
const __dirname = dirname(__filename);
|
|
8
|
-
|
|
9
|
-
export const PACKAGE_VERSION: string = (() => {
|
|
10
|
-
try {
|
|
11
|
-
return JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version;
|
|
12
|
-
} catch {
|
|
13
|
-
return "0.0.0";
|
|
14
|
-
}
|
|
15
|
-
})();
|
|
16
|
-
|
|
17
|
-
export async function initializeProtocol(transport: McpTransport, version: string): Promise<void> {
|
|
18
|
-
const initRequest: McpRequest = {
|
|
19
|
-
jsonrpc: "2.0",
|
|
20
|
-
method: "initialize",
|
|
21
|
-
params: {
|
|
22
|
-
protocolVersion: "2024-11-05",
|
|
23
|
-
capabilities: {},
|
|
24
|
-
clientInfo: {
|
|
25
|
-
name: "mcp-bridge",
|
|
26
|
-
version: version || PACKAGE_VERSION
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const response = await transport.sendRequest(initRequest);
|
|
32
|
-
if (response.error) {
|
|
33
|
-
throw new Error(`Initialize failed: ${response.error.message}`);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
await transport.sendNotification({
|
|
37
|
-
jsonrpc: "2.0",
|
|
38
|
-
method: "notifications/initialized"
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export async function fetchToolsList(transport: McpTransport): Promise<McpTool[]> {
|
|
43
|
-
const allTools: McpTool[] = [];
|
|
44
|
-
let cursor: string | undefined;
|
|
45
|
-
|
|
46
|
-
while (true) {
|
|
47
|
-
const request: McpRequest = {
|
|
48
|
-
jsonrpc: "2.0",
|
|
49
|
-
method: "tools/list",
|
|
50
|
-
...(cursor ? { params: { cursor } } : {})
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const response: McpResponse = await transport.sendRequest(request);
|
|
54
|
-
if (response.error) {
|
|
55
|
-
throw new Error(response.error.message);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const pageTools = Array.isArray(response.result?.tools) ? response.result.tools : [];
|
|
59
|
-
allTools.push(...pageTools);
|
|
60
|
-
|
|
61
|
-
const nextCursor = response.result?.nextCursor;
|
|
62
|
-
if (!nextCursor) {
|
|
63
|
-
break;
|
|
64
|
-
}
|
|
65
|
-
cursor = nextCursor;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return allTools;
|
|
69
|
-
}
|