@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
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { McpRouter } from "./mcp-router.js";
|
|
2
|
+
import { fetchToolsList, initializeProtocol, PACKAGE_VERSION } from "./protocol.js";
|
|
3
|
+
import { pickRegisteredToolName } from "./tool-naming.js";
|
|
4
|
+
import { SseTransport } from "./transport-sse.js";
|
|
5
|
+
import { StdioTransport } from "./transport-stdio.js";
|
|
6
|
+
import { StreamableHttpTransport } from "./transport-streamable-http.js";
|
|
7
|
+
/**
|
|
8
|
+
* Standalone MCP server that wraps the router.
|
|
9
|
+
* Implements the MCP protocol (initialize, tools/list, tools/call)
|
|
10
|
+
* and forwards tool calls to backend MCP servers.
|
|
11
|
+
*/
|
|
12
|
+
export class StandaloneServer {
|
|
13
|
+
config;
|
|
14
|
+
logger;
|
|
15
|
+
router = null;
|
|
16
|
+
initialized = false;
|
|
17
|
+
// Direct mode state
|
|
18
|
+
directTools = [];
|
|
19
|
+
directConnections = new Map();
|
|
20
|
+
constructor(config, logger) {
|
|
21
|
+
this.config = config;
|
|
22
|
+
this.logger = logger;
|
|
23
|
+
if (this.isRouterMode()) {
|
|
24
|
+
this.router = new McpRouter(config.servers || {}, config, logger);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
isRouterMode() {
|
|
28
|
+
return (this.config.mode ?? "router") === "router";
|
|
29
|
+
}
|
|
30
|
+
/** Start stdio mode: read JSON-RPC from stdin, write responses to stdout. */
|
|
31
|
+
async startStdio() {
|
|
32
|
+
const stdin = process.stdin;
|
|
33
|
+
const stdout = process.stdout;
|
|
34
|
+
stdin.setEncoding("utf8");
|
|
35
|
+
let buffer = "";
|
|
36
|
+
stdin.on("data", (chunk) => {
|
|
37
|
+
buffer += chunk;
|
|
38
|
+
const lines = buffer.split("\n");
|
|
39
|
+
buffer = lines.pop() || "";
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
if (!trimmed)
|
|
43
|
+
continue;
|
|
44
|
+
this.processLine(trimmed, stdout);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
stdin.on("end", () => {
|
|
48
|
+
this.logger.info("[mcp-bridge] stdin closed, shutting down");
|
|
49
|
+
this.shutdown().catch(err => {
|
|
50
|
+
this.logger.error("[mcp-bridge] Shutdown error:", err);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
this.logger.info("[mcp-bridge] Stdio server ready");
|
|
54
|
+
}
|
|
55
|
+
processLine(line, stdout) {
|
|
56
|
+
let request;
|
|
57
|
+
try {
|
|
58
|
+
request = JSON.parse(line);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
this.writeResponse(stdout, {
|
|
62
|
+
jsonrpc: "2.0",
|
|
63
|
+
id: 0,
|
|
64
|
+
error: { code: -32700, message: "Parse error" }
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Notifications (no id) — just acknowledge
|
|
69
|
+
if (request.id === undefined || request.id === null) {
|
|
70
|
+
// notifications/initialized, etc. — no response needed
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.handleRequest(request).then(response => {
|
|
74
|
+
this.writeResponse(stdout, response);
|
|
75
|
+
}).catch(err => {
|
|
76
|
+
this.writeResponse(stdout, {
|
|
77
|
+
jsonrpc: "2.0",
|
|
78
|
+
id: request.id,
|
|
79
|
+
error: { code: -32603, message: err instanceof Error ? err.message : String(err) }
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
writeResponse(stdout, response) {
|
|
84
|
+
stdout.write(JSON.stringify(response) + "\n");
|
|
85
|
+
}
|
|
86
|
+
/** Handle a single MCP JSON-RPC request. */
|
|
87
|
+
async handleRequest(request) {
|
|
88
|
+
const id = request.id ?? 0;
|
|
89
|
+
switch (request.method) {
|
|
90
|
+
case "initialize":
|
|
91
|
+
return this.handleInitialize(id);
|
|
92
|
+
case "tools/list":
|
|
93
|
+
return this.handleToolsList(id);
|
|
94
|
+
case "tools/call":
|
|
95
|
+
return this.handleToolsCall(id, request.params);
|
|
96
|
+
case "ping":
|
|
97
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
98
|
+
default:
|
|
99
|
+
return {
|
|
100
|
+
jsonrpc: "2.0",
|
|
101
|
+
id,
|
|
102
|
+
error: { code: -32601, message: `Method not found: ${request.method}` }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
handleInitialize(id) {
|
|
107
|
+
this.initialized = true;
|
|
108
|
+
return {
|
|
109
|
+
jsonrpc: "2.0",
|
|
110
|
+
id,
|
|
111
|
+
result: {
|
|
112
|
+
protocolVersion: "2024-11-05",
|
|
113
|
+
capabilities: {
|
|
114
|
+
tools: {}
|
|
115
|
+
},
|
|
116
|
+
serverInfo: {
|
|
117
|
+
name: "mcp-bridge",
|
|
118
|
+
version: PACKAGE_VERSION
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async handleToolsList(id) {
|
|
124
|
+
if (this.isRouterMode()) {
|
|
125
|
+
return {
|
|
126
|
+
jsonrpc: "2.0",
|
|
127
|
+
id,
|
|
128
|
+
result: {
|
|
129
|
+
tools: [{
|
|
130
|
+
name: "mcp",
|
|
131
|
+
description: McpRouter.generateDescription(this.config.servers),
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
server: { type: "string", description: "Server name" },
|
|
136
|
+
action: { type: "string", description: "list | call | refresh | status" },
|
|
137
|
+
tool: { type: "string", description: "Tool name for action=call" },
|
|
138
|
+
params: { type: "object", description: "Tool arguments" }
|
|
139
|
+
},
|
|
140
|
+
required: ["server"]
|
|
141
|
+
}
|
|
142
|
+
}]
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Direct mode: discover all tools from all servers
|
|
147
|
+
await this.discoverDirectTools();
|
|
148
|
+
const tools = this.directTools.map(t => ({
|
|
149
|
+
name: t.registeredName,
|
|
150
|
+
description: t.description,
|
|
151
|
+
inputSchema: t.inputSchema
|
|
152
|
+
}));
|
|
153
|
+
return { jsonrpc: "2.0", id, result: { tools } };
|
|
154
|
+
}
|
|
155
|
+
async handleToolsCall(id, params) {
|
|
156
|
+
const toolName = params?.name;
|
|
157
|
+
const toolArgs = params?.arguments ?? {};
|
|
158
|
+
if (!toolName) {
|
|
159
|
+
return {
|
|
160
|
+
jsonrpc: "2.0",
|
|
161
|
+
id,
|
|
162
|
+
error: { code: -32602, message: "Missing tool name" }
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (this.isRouterMode()) {
|
|
166
|
+
if (toolName !== "mcp") {
|
|
167
|
+
return {
|
|
168
|
+
jsonrpc: "2.0",
|
|
169
|
+
id,
|
|
170
|
+
error: { code: -32004, message: `Unknown tool: ${toolName}. In router mode, use the 'mcp' tool.` }
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const result = await this.router.dispatch(toolArgs.server, toolArgs.action, toolArgs.tool, toolArgs.params);
|
|
174
|
+
// Check if result is an error
|
|
175
|
+
if ("error" in result) {
|
|
176
|
+
return {
|
|
177
|
+
jsonrpc: "2.0",
|
|
178
|
+
id,
|
|
179
|
+
result: {
|
|
180
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
jsonrpc: "2.0",
|
|
186
|
+
id,
|
|
187
|
+
result: {
|
|
188
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
// Direct mode: find and call the tool
|
|
193
|
+
const entry = this.directTools.find(t => t.registeredName === toolName);
|
|
194
|
+
if (!entry) {
|
|
195
|
+
return {
|
|
196
|
+
jsonrpc: "2.0",
|
|
197
|
+
id,
|
|
198
|
+
error: {
|
|
199
|
+
code: -32004,
|
|
200
|
+
message: `Unknown tool: ${toolName}`,
|
|
201
|
+
data: { errorType: "unknown_tool" }
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const conn = this.directConnections.get(entry.serverName);
|
|
207
|
+
if (!conn || !conn.transport.isConnected()) {
|
|
208
|
+
return {
|
|
209
|
+
jsonrpc: "2.0",
|
|
210
|
+
id,
|
|
211
|
+
error: {
|
|
212
|
+
code: -32001,
|
|
213
|
+
message: `Server '${entry.serverName}' not connected`,
|
|
214
|
+
data: { errorType: "connection_failed", server: entry.serverName, retriable: true }
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const response = await conn.transport.sendRequest({
|
|
219
|
+
jsonrpc: "2.0",
|
|
220
|
+
method: "tools/call",
|
|
221
|
+
params: { name: entry.originalName, arguments: toolArgs }
|
|
222
|
+
});
|
|
223
|
+
if (response.error) {
|
|
224
|
+
return {
|
|
225
|
+
jsonrpc: "2.0",
|
|
226
|
+
id,
|
|
227
|
+
error: {
|
|
228
|
+
code: -32005,
|
|
229
|
+
message: response.error.message,
|
|
230
|
+
data: { errorType: "mcp_error", server: entry.serverName }
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return { jsonrpc: "2.0", id, result: response.result };
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
return {
|
|
238
|
+
jsonrpc: "2.0",
|
|
239
|
+
id,
|
|
240
|
+
error: {
|
|
241
|
+
code: -32001,
|
|
242
|
+
message: err instanceof Error ? err.message : String(err),
|
|
243
|
+
data: { errorType: "connection_failed", server: entry.serverName, retriable: true }
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Connect to all backend servers and discover their tools (direct mode). */
|
|
249
|
+
async discoverDirectTools() {
|
|
250
|
+
if (this.directTools.length > 0)
|
|
251
|
+
return; // Already discovered
|
|
252
|
+
const globalNames = new Set();
|
|
253
|
+
for (const [serverName, serverConfig] of Object.entries(this.config.servers)) {
|
|
254
|
+
try {
|
|
255
|
+
const transport = this.createTransport(serverName, serverConfig);
|
|
256
|
+
await transport.connect();
|
|
257
|
+
await initializeProtocol(transport, PACKAGE_VERSION);
|
|
258
|
+
this.directConnections.set(serverName, { transport, initialized: true });
|
|
259
|
+
const tools = await fetchToolsList(transport);
|
|
260
|
+
const localNames = new Set();
|
|
261
|
+
for (const tool of tools) {
|
|
262
|
+
const registeredName = pickRegisteredToolName(serverName, tool.name, this.config.toolPrefix, localNames, globalNames, this.logger);
|
|
263
|
+
localNames.add(registeredName);
|
|
264
|
+
globalNames.add(registeredName);
|
|
265
|
+
this.directTools.push({
|
|
266
|
+
serverName,
|
|
267
|
+
originalName: tool.name,
|
|
268
|
+
registeredName,
|
|
269
|
+
description: tool.description,
|
|
270
|
+
inputSchema: tool.inputSchema
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
this.logger.info(`[mcp-bridge] Discovered ${tools.length} tools from ${serverName}`);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
this.logger.error(`[mcp-bridge] Failed to connect to ${serverName}:`, err);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
createTransport(serverName, serverConfig) {
|
|
281
|
+
const onReconnected = async () => {
|
|
282
|
+
this.logger.info(`[mcp-bridge] ${serverName} reconnected`);
|
|
283
|
+
};
|
|
284
|
+
switch (serverConfig.transport) {
|
|
285
|
+
case "sse":
|
|
286
|
+
return new SseTransport(serverConfig, this.config, this.logger, onReconnected);
|
|
287
|
+
case "stdio":
|
|
288
|
+
return new StdioTransport(serverConfig, this.config, this.logger, onReconnected);
|
|
289
|
+
case "streamable-http":
|
|
290
|
+
return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected);
|
|
291
|
+
default:
|
|
292
|
+
throw new Error(`Unsupported transport: ${serverConfig.transport}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** Graceful shutdown: disconnect all backend servers. */
|
|
296
|
+
async shutdown() {
|
|
297
|
+
this.logger.info("[mcp-bridge] Shutting down...");
|
|
298
|
+
if (this.router) {
|
|
299
|
+
await this.router.disconnectAll();
|
|
300
|
+
}
|
|
301
|
+
for (const [name, conn] of this.directConnections) {
|
|
302
|
+
try {
|
|
303
|
+
await conn.transport.disconnect();
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
this.logger.error(`[mcp-bridge] Error disconnecting ${name}:`, err);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
this.directConnections.clear();
|
|
310
|
+
this.logger.info("[mcp-bridge] Shutdown complete");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function isNameTaken(name, localNames, globalNames) {
|
|
2
|
+
return localNames.has(name) || globalNames.has(name);
|
|
3
|
+
}
|
|
4
|
+
export function pickRegisteredToolName(serverName, toolName, toolPrefix, localNames, globalNames, logger) {
|
|
5
|
+
// toolPrefix: true = always prefix (default), false = never prefix, "auto" = prefix only on collision
|
|
6
|
+
const effectivePrefix = toolPrefix === undefined ? true : toolPrefix;
|
|
7
|
+
let candidate;
|
|
8
|
+
if (effectivePrefix === true) {
|
|
9
|
+
// Always prefix with server name
|
|
10
|
+
candidate = `${serverName}_${toolName}`.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
11
|
+
}
|
|
12
|
+
else if (effectivePrefix === false) {
|
|
13
|
+
// Never prefix — use raw tool name, no collision fallback
|
|
14
|
+
candidate = toolName.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
// "auto" — try without prefix, auto-prefix on collision
|
|
18
|
+
const unprefixed = toolName.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
19
|
+
if (isNameTaken(unprefixed, localNames, globalNames)) {
|
|
20
|
+
const prefixedName = `${serverName}_${toolName}`.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
21
|
+
logger?.warn(`[mcp-bridge] Global tool name collision detected for "${unprefixed}". Auto-prefixing with server name: "${prefixedName}"`);
|
|
22
|
+
candidate = prefixedName;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
candidate = unprefixed;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const uniqueBase = candidate;
|
|
29
|
+
let suffix = 2;
|
|
30
|
+
while (isNameTaken(candidate, localNames, globalNames)) {
|
|
31
|
+
candidate = `${uniqueBase}_${suffix}`;
|
|
32
|
+
suffix += 1;
|
|
33
|
+
}
|
|
34
|
+
if (candidate !== uniqueBase) {
|
|
35
|
+
logger?.warn(`[mcp-bridge] Tool name collision after sanitization on server ${serverName}: "${uniqueBase}" -> "${candidate}"`);
|
|
36
|
+
}
|
|
37
|
+
return candidate;
|
|
38
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { McpTransport, McpRequest, McpResponse, McpServerConfig } from "./types.js";
|
|
2
|
+
export type PendingRequest = {
|
|
3
|
+
resolve: Function;
|
|
4
|
+
reject: Function;
|
|
5
|
+
timeout: NodeJS.Timeout;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Base class for all MCP transports. Provides shared logic for:
|
|
9
|
+
* - Message handling (JSON-RPC response routing, notification dispatch)
|
|
10
|
+
* - Pending request management with timeout
|
|
11
|
+
* - Reconnection with exponential backoff + jitter
|
|
12
|
+
* - Environment variable resolution for headers, env, and args
|
|
13
|
+
* - Non-TLS remote URL warnings
|
|
14
|
+
*/
|
|
15
|
+
export declare abstract class BaseTransport implements McpTransport {
|
|
16
|
+
protected config: McpServerConfig;
|
|
17
|
+
protected clientConfig: any;
|
|
18
|
+
protected connected: boolean;
|
|
19
|
+
protected pendingRequests: Map<number, PendingRequest>;
|
|
20
|
+
protected logger: any;
|
|
21
|
+
protected reconnectTimer: NodeJS.Timeout | null;
|
|
22
|
+
protected onReconnected?: () => Promise<void>;
|
|
23
|
+
protected backoffDelay: number;
|
|
24
|
+
constructor(config: McpServerConfig, clientConfig: any, logger: any, onReconnected?: () => Promise<void>);
|
|
25
|
+
abstract connect(): Promise<void>;
|
|
26
|
+
abstract disconnect(): Promise<void>;
|
|
27
|
+
abstract sendRequest(request: McpRequest): Promise<McpResponse>;
|
|
28
|
+
abstract sendNotification(notification: any): Promise<void>;
|
|
29
|
+
isConnected(): boolean;
|
|
30
|
+
/** Human-readable transport name for log messages (e.g. "stdio", "SSE", "streamable-http"). */
|
|
31
|
+
protected abstract get transportName(): string;
|
|
32
|
+
/**
|
|
33
|
+
* Route an incoming JSON-RPC message to the appropriate handler:
|
|
34
|
+
* - notifications/tools/list_changed -> trigger tool refresh
|
|
35
|
+
* - Other notifications -> debug log
|
|
36
|
+
* - Responses with id -> resolve/reject matching pending request
|
|
37
|
+
*/
|
|
38
|
+
protected handleMessage(message: any): void;
|
|
39
|
+
/** Reject and clear all pending requests with the given reason. */
|
|
40
|
+
protected rejectAllPending(reason: string): void;
|
|
41
|
+
/**
|
|
42
|
+
* Schedule a reconnection attempt with exponential backoff and jitter.
|
|
43
|
+
* Rejects all pending requests before scheduling.
|
|
44
|
+
*/
|
|
45
|
+
protected scheduleReconnect(): void;
|
|
46
|
+
/** Cancel any scheduled reconnection timer. */
|
|
47
|
+
protected cleanupReconnectTimer(): void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve ${VAR} placeholders in a single string value using environment variables.
|
|
51
|
+
* Throws if a referenced variable is not defined.
|
|
52
|
+
*
|
|
53
|
+
* @param value - String potentially containing ${VAR} placeholders
|
|
54
|
+
* @param contextDescription - Human-readable context for error messages (e.g. 'header "Authorization"')
|
|
55
|
+
* @param extraEnv - Additional env vars to check before process.env (e.g. merged child process env)
|
|
56
|
+
*/
|
|
57
|
+
export declare function resolveEnvVars(value: string, contextDescription: string, extraEnv?: Record<string, string | undefined>): string;
|
|
58
|
+
/**
|
|
59
|
+
* Resolve ${VAR} placeholders in all values of a Record<string, string>.
|
|
60
|
+
*
|
|
61
|
+
* @param record - Key-value pairs with potential ${VAR} placeholders in values
|
|
62
|
+
* @param contextPrefix - Prefix for error context (e.g. "header", "env key")
|
|
63
|
+
* @param extraEnv - Additional env vars to check before process.env
|
|
64
|
+
*/
|
|
65
|
+
export declare function resolveEnvRecord(record: Record<string, string>, contextPrefix: string, extraEnv?: Record<string, string | undefined>): Record<string, string>;
|
|
66
|
+
/**
|
|
67
|
+
* Resolve ${VAR} placeholders in an array of command arguments.
|
|
68
|
+
*
|
|
69
|
+
* @param args - Array of argument strings with potential ${VAR} placeholders
|
|
70
|
+
* @param extraEnv - Additional env vars to check before process.env
|
|
71
|
+
*/
|
|
72
|
+
export declare function resolveArgs(args: string[], extraEnv?: Record<string, string | undefined>): string[];
|
|
73
|
+
/**
|
|
74
|
+
* Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
|
|
75
|
+
*/
|
|
76
|
+
export declare function warnIfNonTlsRemoteUrl(rawUrl: string, logger: any): void;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for all MCP transports. Provides shared logic for:
|
|
3
|
+
* - Message handling (JSON-RPC response routing, notification dispatch)
|
|
4
|
+
* - Pending request management with timeout
|
|
5
|
+
* - Reconnection with exponential backoff + jitter
|
|
6
|
+
* - Environment variable resolution for headers, env, and args
|
|
7
|
+
* - Non-TLS remote URL warnings
|
|
8
|
+
*/
|
|
9
|
+
export class BaseTransport {
|
|
10
|
+
config;
|
|
11
|
+
clientConfig;
|
|
12
|
+
connected = false;
|
|
13
|
+
pendingRequests = new Map();
|
|
14
|
+
logger;
|
|
15
|
+
reconnectTimer = null;
|
|
16
|
+
onReconnected;
|
|
17
|
+
backoffDelay = 0;
|
|
18
|
+
constructor(config, clientConfig, logger, onReconnected) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.clientConfig = clientConfig;
|
|
21
|
+
this.logger = logger;
|
|
22
|
+
this.onReconnected = onReconnected;
|
|
23
|
+
}
|
|
24
|
+
isConnected() {
|
|
25
|
+
return this.connected;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Route an incoming JSON-RPC message to the appropriate handler:
|
|
29
|
+
* - notifications/tools/list_changed -> trigger tool refresh
|
|
30
|
+
* - Other notifications -> debug log
|
|
31
|
+
* - Responses with id -> resolve/reject matching pending request
|
|
32
|
+
*/
|
|
33
|
+
handleMessage(message) {
|
|
34
|
+
if (!message.id && message.method === "notifications/tools/list_changed") {
|
|
35
|
+
if (this.onReconnected) {
|
|
36
|
+
this.onReconnected().catch((error) => {
|
|
37
|
+
this.logger.error("[mcp-bridge] Failed to refresh tools after list_changed notification:", error);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!message.id && message.method) {
|
|
43
|
+
this.logger.debug(`[mcp-bridge] Unhandled ${this.transportName} notification: ${message.method}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (message.id && this.pendingRequests.has(message.id)) {
|
|
47
|
+
const pending = this.pendingRequests.get(message.id);
|
|
48
|
+
clearTimeout(pending.timeout);
|
|
49
|
+
this.pendingRequests.delete(message.id);
|
|
50
|
+
if (message.error) {
|
|
51
|
+
pending.reject(new Error(message.error.message || "MCP error"));
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
pending.resolve(message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Reject and clear all pending requests with the given reason. */
|
|
59
|
+
rejectAllPending(reason) {
|
|
60
|
+
for (const [, pending] of this.pendingRequests) {
|
|
61
|
+
clearTimeout(pending.timeout);
|
|
62
|
+
pending.reject(new Error(reason));
|
|
63
|
+
}
|
|
64
|
+
this.pendingRequests.clear();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Schedule a reconnection attempt with exponential backoff and jitter.
|
|
68
|
+
* Rejects all pending requests before scheduling.
|
|
69
|
+
*/
|
|
70
|
+
scheduleReconnect() {
|
|
71
|
+
if (this.reconnectTimer)
|
|
72
|
+
return;
|
|
73
|
+
this.connected = false;
|
|
74
|
+
this.rejectAllPending("Connection lost, request cancelled");
|
|
75
|
+
const baseDelay = this.clientConfig.reconnectIntervalMs || 30000;
|
|
76
|
+
if (this.backoffDelay <= 0) {
|
|
77
|
+
this.backoffDelay = baseDelay;
|
|
78
|
+
}
|
|
79
|
+
const jitter = 0.5 + Math.random(); // 0.5x-1.5x jitter
|
|
80
|
+
const reconnectInterval = Math.round(this.backoffDelay * jitter);
|
|
81
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
82
|
+
this.reconnectTimer = null;
|
|
83
|
+
try {
|
|
84
|
+
await this.connect();
|
|
85
|
+
this.logger.info(`${this.transportName} transport reconnected successfully`);
|
|
86
|
+
this.backoffDelay = baseDelay;
|
|
87
|
+
if (this.onReconnected) {
|
|
88
|
+
await this.onReconnected();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
this.logger.error("Reconnection failed:", error);
|
|
93
|
+
this.backoffDelay = Math.min(this.backoffDelay * 2, 300000);
|
|
94
|
+
this.scheduleReconnect();
|
|
95
|
+
}
|
|
96
|
+
}, reconnectInterval);
|
|
97
|
+
}
|
|
98
|
+
/** Cancel any scheduled reconnection timer. */
|
|
99
|
+
cleanupReconnectTimer() {
|
|
100
|
+
if (this.reconnectTimer) {
|
|
101
|
+
clearTimeout(this.reconnectTimer);
|
|
102
|
+
this.reconnectTimer = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// -- Shared utility functions -----------------------------------------------
|
|
107
|
+
/**
|
|
108
|
+
* Resolve ${VAR} placeholders in a single string value using environment variables.
|
|
109
|
+
* Throws if a referenced variable is not defined.
|
|
110
|
+
*
|
|
111
|
+
* @param value - String potentially containing ${VAR} placeholders
|
|
112
|
+
* @param contextDescription - Human-readable context for error messages (e.g. 'header "Authorization"')
|
|
113
|
+
* @param extraEnv - Additional env vars to check before process.env (e.g. merged child process env)
|
|
114
|
+
*/
|
|
115
|
+
export function resolveEnvVars(value, contextDescription, extraEnv) {
|
|
116
|
+
return value.replace(/\$\{(\w+)\}/g, (_, varName) => {
|
|
117
|
+
const resolved = extraEnv?.[varName] ?? process.env[varName];
|
|
118
|
+
if (resolved === undefined) {
|
|
119
|
+
throw new Error(`[mcp-bridge] Missing required environment variable "${varName}" while resolving ${contextDescription}`);
|
|
120
|
+
}
|
|
121
|
+
return resolved;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolve ${VAR} placeholders in all values of a Record<string, string>.
|
|
126
|
+
*
|
|
127
|
+
* @param record - Key-value pairs with potential ${VAR} placeholders in values
|
|
128
|
+
* @param contextPrefix - Prefix for error context (e.g. "header", "env key")
|
|
129
|
+
* @param extraEnv - Additional env vars to check before process.env
|
|
130
|
+
*/
|
|
131
|
+
export function resolveEnvRecord(record, contextPrefix, extraEnv) {
|
|
132
|
+
const resolved = {};
|
|
133
|
+
for (const [key, value] of Object.entries(record)) {
|
|
134
|
+
resolved[key] = resolveEnvVars(value, `${contextPrefix} "${key}"`, extraEnv);
|
|
135
|
+
}
|
|
136
|
+
return resolved;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Resolve ${VAR} placeholders in an array of command arguments.
|
|
140
|
+
*
|
|
141
|
+
* @param args - Array of argument strings with potential ${VAR} placeholders
|
|
142
|
+
* @param extraEnv - Additional env vars to check before process.env
|
|
143
|
+
*/
|
|
144
|
+
export function resolveArgs(args, extraEnv) {
|
|
145
|
+
return args.map(arg => resolveEnvVars(arg, `arg "${arg}"`, extraEnv));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
|
|
149
|
+
*/
|
|
150
|
+
export function warnIfNonTlsRemoteUrl(rawUrl, logger) {
|
|
151
|
+
try {
|
|
152
|
+
const parsed = new URL(rawUrl);
|
|
153
|
+
if (parsed.protocol !== "http:")
|
|
154
|
+
return;
|
|
155
|
+
const host = parsed.hostname;
|
|
156
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "::1")
|
|
157
|
+
return;
|
|
158
|
+
logger.warn(`[mcp-bridge] WARNING: Non-TLS connection to ${host} — credentials may be transmitted in plaintext`);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Ignore malformed URL here; connect() validation will fail later.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { McpRequest, McpResponse } from "./types.js";
|
|
2
|
+
import { BaseTransport } from "./transport-base.js";
|
|
3
|
+
export declare class SseTransport extends BaseTransport {
|
|
4
|
+
private endpointUrl;
|
|
5
|
+
private sseAbortController;
|
|
6
|
+
private currentDataBuffer;
|
|
7
|
+
protected get transportName(): string;
|
|
8
|
+
connect(): Promise<void>;
|
|
9
|
+
private _onEndpointReceived;
|
|
10
|
+
private startEventStream;
|
|
11
|
+
private processEventLine;
|
|
12
|
+
sendNotification(notification: any): Promise<void>;
|
|
13
|
+
sendRequest(request: McpRequest): Promise<McpResponse>;
|
|
14
|
+
private isSameOrigin;
|
|
15
|
+
disconnect(): Promise<void>;
|
|
16
|
+
}
|