@aiwerk/mcp-bridge 1.0.0

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